From 133d2f0f91b6f050ff9267881531d13b15ba8852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 22 Oct 2026 09:31:15 +0200 Subject: [PATCH] 0.34.6_repo_cleanup --- SHA256SUMS | 506 ++-- build.rs | 3 - scripts/build/swagger_assets.py | 7 +- scripts/preflight.py | 13 +- src/acl.rs | 31 - src/acl/{dynamic.rs => dynamic/mod.rs} | 13 +- src/acl/dynamic/tests.rs | 3 - src/acl/tests.rs | 8 + src/acme.rs | 6 +- src/acme/jobs.rs | 9 - src/acme/model.rs | 1 - src/acme/{providers.rs => providers/mod.rs} | 6 +- src/acme/runtime_bootstrap.rs | 25 +- src/acme/runtime_env.rs | 19 +- src/acme/runtime_versions.rs | 69 +- src/acme/tests.rs | 30 +- src/admin/access.rs | 41 +- src/admin/api_token_access.rs | 2 - src/admin/api_tokens.rs | 2 - src/admin/certificate_api.rs | 34 +- src/admin/certificates.rs | 4 - src/admin/dashboard_presets.rs | 3 - src/admin/documentation.rs | 11 +- src/admin/documentation/documents.rs | 3 - src/admin/file_logs_io.rs | 88 +- src/admin/file_logs_query.rs | 122 +- src/admin/logging_api.rs | 4 - src/admin/mod.rs | 2 - src/admin/operations.rs | 42 +- src/admin/panel_assets.rs | 91 +- src/admin/panel_content.rs | 44 +- src/admin/preferences.rs | 7 - src/admin/preferences_columns.rs | 7 - src/admin/preferences_tests.rs | 2 - src/admin/{public.rs => public/mod.rs} | 13 +- src/admin/refresh_preferences.rs | 7 +- src/admin/router.rs | 10 - src/admin/session.rs | 177 +- src/admin/status.rs | 7 - src/admin/status_live.rs | 5 - src/admin/status_system.rs | 33 +- src/admin/tests.rs | 173 +- src/auth/{files.rs => files/mod.rs} | 5 +- src/auth/mod.rs | 3 - src/auth/store.rs | 15 - src/backup.rs | 4 - src/backup/ownership.rs | 7 +- src/backup/snapshot.rs | 3 - src/backup/tests.rs | 1 - src/balance.rs | 3 - src/balance/health.rs | 12 - src/balance/lease_runtime.rs | 14 - src/balance/model.rs | 15 - src/balance/pool_core.rs | 47 +- src/balance/pool_view.rs | 26 +- src/balance/tests.rs | 32 +- src/{basic_auth.rs => basic_auth/mod.rs} | 9 +- .../tests.rs => cache_regression_tests.rs} | 411 ++- src/cache/cold/cleanup.rs | 8 - src/cache/cold/cold_tier_tests.rs | 955 +++++++ src/cache/cold/demotion.rs | 26 +- src/cache/cold/startup.rs | 2 - src/cache/cold/worker.rs | 5 - src/cache/collapse.rs | 44 +- src/cache/dashboard.rs | 31 +- src/cache/{tests => }/dashboard_tests.rs | 53 +- src/cache/fill.rs | 61 +- .../http_invalidation_tests.rs | 12 +- src/cache/invalidation/invalidate.rs | 31 +- src/cache/lifecycle.rs | 77 +- src/cache/lookup/hot.rs | 45 +- .../{tests => lookup}/hot_hit_034_tests.rs | 113 +- src/cache/lookup/insert.rs | 3 - src/cache/lookup/tiered.rs | 16 +- src/cache/mod.rs | 6 - src/cache/model/cache.rs | 25 +- src/cache/model/entry.rs | 44 +- src/cache/model/http_invalidation.rs | 15 +- src/cache/model/maintenance.rs | 14 +- src/cache/model/settings.rs | 64 +- src/cache/model/views.rs | 5 - src/cache/policy/request.rs | 29 +- src/cache/policy/response.rs | 32 +- src/cache/{tests => policy}/scope_tests.rs | 2 - src/cache/range.rs | 8 +- src/cache/stats.rs | 48 +- src/cache/tests.rs | 18 +- src/cache/tests/cold_tier_tests.rs | 857 ------- src/cache/tickets.rs | 28 +- src/cache/{tests => }/v022_cache_tests.rs | 258 +- src/{compression.rs => compression/mod.rs} | 22 +- src/config.rs | 6 +- src/config/backend.rs | 13 - src/config/compile.rs | 154 +- src/config/internal_routing.rs | 25 +- src/config/model.rs | 26 - src/config/route_policy_tests.rs | 99 + src/config/routing.rs | 6 - src/config/snapshot.rs | 57 +- src/config/tests.rs | 2201 +++++++++-------- src/config/transport_tests.rs | 51 + src/db.rs | 4 - src/db/api_tokens.rs | 105 +- src/db/certificate_api.rs | 41 +- src/db/dashboard_presets.rs | 4 - src/demo.rs | 2 - src/diagnostics.rs | 4 - src/docker.rs | 4 - src/docker/model.rs | 26 +- src/docker/reconcile.rs | 22 +- src/docker/tests.rs | 30 +- src/forwarding.rs | 31 - src/forwarding/peer_formatting.rs | 4 - src/forwarding/tests.rs | 2 - src/geoip.rs | 6 - src/{headers.rs => headers/mod.rs} | 5 +- src/http3.rs | 60 - src/http3/body.rs | 8 +- src/http3/diagnostics.rs | 9 +- src/http3/identity.rs | 4 - src/http3/settings.rs | 3 - src/http3/tests.rs | 72 +- src/logging.rs | 4 - src/logging/error_runtime.rs | 18 +- src/logging/format.rs | 29 +- src/logging/model.rs | 13 - src/logging/runtime.rs | 85 +- src/logging/tests.rs | 119 +- src/main.rs | 2 +- src/main/admin_runtime.rs | 61 +- src/main/bootstrap.rs | 53 +- src/main/{shutdown.rs => shutdown/mod.rs} | 5 +- src/main/tasks.rs | 5 - src/metrics.rs | 4 +- src/metrics/global.rs | 2 - src/metrics/rate_sampling.rs | 66 +- src/metrics/{vhost.rs => vhost/mod.rs} | 7 +- src/metrics/vhost/tests.rs | 10 +- src/{not_found.rs => not_found/mod.rs} | 18 +- src/not_found/tests.rs | 5 +- src/{pages.rs => pages/mod.rs} | 60 +- src/pages/tests.rs | 152 +- src/prometheus.rs | 2 - src/prometheus/docker_misc.rs | 5 - src/prometheus/json.rs | 20 - src/prometheus/model.rs | 8 - src/prometheus/tests.rs | 2 - src/prometheus/web_php.rs | 1 - src/proxy/{body.rs => body/mod.rs} | 56 +- src/proxy/body/tests.rs | 2 - src/proxy/cache_helpers.rs | 38 - src/proxy/cache_http_invalidation.rs | 24 - ...ts.rs => cache_http_invalidation_tests.rs} | 0 .../{cache_plan.rs => cache_plan/mod.rs} | 40 +- src/proxy/error_fallback.rs | 12 - src/proxy/forward/cache_fast_path_tests.rs | 658 +++++ src/proxy/forward/cache_lookup.rs | 5 - .../{tests => forward}/h3_navigation_tests.rs | 72 +- src/proxy/forward/response.rs | 7 - src/proxy/forward/route_cache.rs | 16 - src/proxy/forward/success_finish.rs | 8 - src/proxy/forward/success_prepare.rs | 19 - src/proxy/forward/upstream.rs | 16 - src/proxy/forward/web_source.rs | 37 - src/proxy/forward_entry.rs | 26 - .../{tests => headers}/authority_tests.rs | 5 - src/proxy/{headers.rs => headers/mod.rs} | 37 +- src/proxy/identity.rs | 4 - src/proxy/mod.rs | 8 +- src/proxy/redirect_helpers.rs | 3 - .../{request_id.rs => request_id/mod.rs} | 8 +- src/proxy/tests/cache_fast_path_tests.rs | 358 --- src/proxy/types.rs | 14 - src/proxy/upstream_helpers.rs | 21 +- .../mod.rs} | 18 - src/proxy/web_cache_revalidation/tests.rs | 2 - src/proxy/websocket.rs | 11 +- src/proxy/websocket/handshake.rs | 12 - src/proxy/websocket/http2_tests.rs | 41 + src/proxy/websocket/http3_tests.rs | 27 + src/proxy/websocket/validation.rs | 13 - src/{rate_limit.rs => rate_limit/mod.rs} | 84 +- src/rate_limit/tests.rs | 1 - src/{redirect.rs => redirect/mod.rs} | 8 +- src/runtime/certificates.rs | 8 - src/runtime/constructors.rs | 3 - src/runtime/docker.rs | 2 - src/runtime/external_config.rs | 5 - src/runtime/tests.rs | 3 - src/runtime/types.rs | 14 - src/runtime/updates.rs | 18 - src/{secret_store.rs => secret_store/mod.rs} | 4 +- src/{security.rs => security/mod.rs} | 3 +- src/server.rs | 6 +- src/server/http.rs | 10 - src/server/http1_connection.rs | 5 - src/server/listeners.rs | 16 +- src/server/sendfile.rs | 23 +- src/server/{serve.rs => serve/mod.rs} | 6 +- src/server/serve/tests.rs | 1 - src/server/tls_runtime.rs | 7 - src/settings.rs | 31 - src/settings/public_origin.rs | 9 - src/settings/tests.rs | 2 - src/stream.rs | 3 - src/stream/health.rs | 3 - src/stream/model.rs | 10 - src/stream/pool.rs | 10 - src/system_stats/{linux.rs => linux/mod.rs} | 5 +- src/system_stats/mod.rs | 23 - src/{tinyauth.rs => tinyauth/mod.rs} | 8 +- src/tls.rs | 7 +- src/tls/{tests/acme.rs => acme_tests.rs} | 0 src/tls/certificates.rs | 29 - src/tls/external_paths.rs | 16 - ...ernal_paths.rs => external_paths_tests.rs} | 67 +- src/tls/server_config.rs | 16 - src/tls/tests.rs | 2 +- src/tls/types.rs | 12 - src/{traffic.rs => traffic/mod.rs} | 57 +- src/traffic/tests.rs | 2 - src/transform/apply.rs | 19 - src/transform/compile.rs | 4 - src/transform/css.rs | 3 - src/transform/{html.rs => html/mod.rs} | 8 +- src/transform/{js.rs => js/mod.rs} | 10 +- src/transform/{json.rs => json/mod.rs} | 5 +- src/transform/mod.rs | 3 - src/transform/model.rs | 31 - src/transform/{svg.rs => svg/mod.rs} | 5 +- src/{update_check.rs => update_check/mod.rs} | 67 +- src/upstream.rs | 29 - src/upstream/client.rs | 34 - src/upstream/client_tests.rs | 2 - src/upstream/protocol_tests.rs | 7 - src/upstream/tests.rs | 2 - src/{user_agent.rs => user_agent/mod.rs} | 8 +- src/vhost.rs | 3 - src/vhost/error_fallback.rs | 2 - src/vhost/model.rs | 70 - src/vhost/redirect.rs | 4 - src/vhost/tests.rs | 33 + src/vhost/validation.rs | 18 - src/web_server/config/model.rs | 38 - src/web_server/fastcgi/protocol.rs | 7 - src/web_server/fastcgi/response.rs | 3 - src/web_server/filesystem.rs | 20 - src/web_server/htaccess/model.rs | 2 - src/web_server/htaccess/parse.rs | 5 +- src/web_server/metrics.rs | 9 +- src/web_server/mod.rs | 6 - src/web_server/response/file.rs | 6 - .../{file_body.rs => file_body/mod.rs} | 10 - src/web_server/response/file_body/tests.rs | 1 - src/web_server/response/headers.rs | 8 - src/web_server/routing.rs | 3 - src/web_server/serve.rs | 17 - src/web_server/tests.rs | 25 +- tests/acl_picker_corner_styles.cjs | 24 - tests/acl_picker_dom.cjs | 72 - tests/cache_outcomes.cjs | 96 - tests/css_usage_audit.cjs | 47 - tests/docker_vhost_actions.cjs | 187 -- tests/docker_vhost_metrics.cjs | 139 -- tests/http2.rs | 52 - tests/http3.rs | 43 - tests/modal_frontend_0346.cjs | 60 - tests/policies.rs | 108 - tests/transport_config.rs | 48 - tests/ux_0345.cjs | 71 - tests/ux_0345_followup.cjs | 89 - tests/vhost_content_type_ui.cjs | 29 - tests/vhost_ui_layout.cjs | 59 - 273 files changed, 5969 insertions(+), 6926 deletions(-) rename src/acl/{dynamic.rs => dynamic/mod.rs} (86%) rename src/acme/{providers.rs => providers/mod.rs} (95%) rename src/admin/{public.rs => public/mod.rs} (75%) rename src/auth/{files.rs => files/mod.rs} (93%) rename src/{basic_auth.rs => basic_auth/mod.rs} (92%) rename src/cache/{tests/tests.rs => cache_regression_tests.rs} (87%) create mode 100644 src/cache/cold/cold_tier_tests.rs rename src/cache/{tests => }/dashboard_tests.rs (89%) rename src/cache/{tests => invalidation}/http_invalidation_tests.rs (87%) rename src/cache/{tests => lookup}/hot_hit_034_tests.rs (66%) rename src/cache/{tests => policy}/scope_tests.rs (97%) delete mode 100644 src/cache/tests/cold_tier_tests.rs rename src/cache/{tests => }/v022_cache_tests.rs (83%) rename src/{compression.rs => compression/mod.rs} (88%) create mode 100644 src/config/route_policy_tests.rs create mode 100644 src/config/transport_tests.rs rename src/{headers.rs => headers/mod.rs} (94%) rename src/main/{shutdown.rs => shutdown/mod.rs} (97%) rename src/metrics/{vhost.rs => vhost/mod.rs} (88%) rename src/{not_found.rs => not_found/mod.rs} (86%) rename src/{pages.rs => pages/mod.rs} (74%) rename src/proxy/{body.rs => body/mod.rs} (89%) rename src/proxy/{tests/http_invalidation_tests.rs => cache_http_invalidation_tests.rs} (100%) rename src/proxy/{cache_plan.rs => cache_plan/mod.rs} (67%) create mode 100644 src/proxy/forward/cache_fast_path_tests.rs rename src/proxy/{tests => forward}/h3_navigation_tests.rs (58%) rename src/proxy/{tests => headers}/authority_tests.rs (98%) rename src/proxy/{headers.rs => headers/mod.rs} (71%) rename src/proxy/{request_id.rs => request_id/mod.rs} (56%) delete mode 100644 src/proxy/tests/cache_fast_path_tests.rs rename src/proxy/{web_cache_revalidation.rs => web_cache_revalidation/mod.rs} (76%) create mode 100644 src/proxy/websocket/http2_tests.rs create mode 100644 src/proxy/websocket/http3_tests.rs rename src/{rate_limit.rs => rate_limit/mod.rs} (84%) rename src/{redirect.rs => redirect/mod.rs} (97%) rename src/{secret_store.rs => secret_store/mod.rs} (98%) rename src/{security.rs => security/mod.rs} (97%) rename src/server/{serve.rs => serve/mod.rs} (92%) rename src/system_stats/{linux.rs => linux/mod.rs} (96%) rename src/{tinyauth.rs => tinyauth/mod.rs} (96%) rename src/tls/{tests/acme.rs => acme_tests.rs} (100%) rename src/tls/{tests/external_paths.rs => external_paths_tests.rs} (70%) rename src/{traffic.rs => traffic/mod.rs} (83%) rename src/transform/{html.rs => html/mod.rs} (91%) rename src/transform/{js.rs => js/mod.rs} (85%) rename src/transform/{json.rs => json/mod.rs} (79%) rename src/transform/{svg.rs => svg/mod.rs} (85%) rename src/{update_check.rs => update_check/mod.rs} (87%) rename src/{user_agent.rs => user_agent/mod.rs} (90%) rename src/web_server/response/{file_body.rs => file_body/mod.rs} (77%) delete mode 100644 tests/acl_picker_corner_styles.cjs delete mode 100644 tests/acl_picker_dom.cjs delete mode 100644 tests/cache_outcomes.cjs delete mode 100644 tests/css_usage_audit.cjs delete mode 100644 tests/docker_vhost_actions.cjs delete mode 100644 tests/docker_vhost_metrics.cjs delete mode 100644 tests/http2.rs delete mode 100644 tests/http3.rs delete mode 100644 tests/modal_frontend_0346.cjs delete mode 100644 tests/policies.rs delete mode 100644 tests/transport_config.rs delete mode 100644 tests/ux_0345.cjs delete mode 100644 tests/ux_0345_followup.cjs delete mode 100644 tests/vhost_content_type_ui.cjs delete mode 100644 tests/vhost_ui_layout.cjs diff --git a/SHA256SUMS b/SHA256SUMS index 24a13fb..bf61241 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -9,7 +9,7 @@ fbba365b98bfc31b891d8ef240270b76987d67778b4d795552069e6b94f3f9ea Cargo.toml fcd1ace66c47e43e13b8cfadd76a9ae02ad545af944fa684c489034227776432 README.md ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml -f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs +7309cfaa188c7a1fcf30be1b8313ee99c00b7fe5d6d626ddefcc7345fa066112 build.rs 2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md 13d514de5042a230cacf8c10abe1ef183c94ac799a89b8e2c111152ac7c69b48 deploy/rustproxy.service c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh @@ -106,7 +106,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py -26abaf315e087d2c14d477c80baef82453068dd2d258d091aaa2d7b238fa9428 scripts/preflight.py +91c5ab0f82af5a4eb5589f5ebb19975f053eacaa46fa3d59b74f3ddde6370562 scripts/preflight.py 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py @@ -114,52 +114,52 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py -7f953ac05756e6e5d08e1b73835ed6a1226ec8e9439bc519e5ed04bcf3f4b492 src/acl.rs -63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs -4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs -5caa062edfa2eb9e53ba0193089a7cd3204b4c9851564dbd4881c26b7a29347a src/acl/tests.rs -6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs -587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs -3f158f46e80b403b658b4e9b1e3710e38c70e47b2d476b8223d9c394ffeeba95 src/acme/model.rs -c5334de6c732d76b2bea7bfc17b3ac305a250bbaad31a5420d6a7d7abb200449 src/acme/providers.rs +5130529d8f55ba9d89c29235a7eac133a80c9e9b2591267b1d082207568abbeb src/acl.rs +adab6c8cd7c3f826e300effbd7d10c1bf12ee49253b3e5ad445e4ac04900f5dc src/acl/dynamic/mod.rs +1eee2ea2c8dd6068dd0bbb058a06d26436f14767d8efbc2010bbdb4e3e523672 src/acl/dynamic/tests.rs +75120cd3115aea6fc40665086617eab9816f08f556cef8db370806195c7641bf src/acl/tests.rs +6189029ba493982acc38c9dc1f47fa1f0c933dec5ecfb95b60f8be83d33b5aa3 src/acme.rs +b9eff08fddf9b8787aa04e72288ea2ca0033965e4563d060ca51f267e4963918 src/acme/jobs.rs +f7f737aa28d2d209f4f20a221885e3bb3596dfc8e3c8c9a22ba53bb975c52df8 src/acme/model.rs +40cd44f2b0d43ba45471ef5a012b3737ca3e16e33f6404b4f13b9cd9bd57f0ac src/acme/providers/mod.rs d1dcb7bdfb7cfdc9c3b88cfb6908201c58532bc888744a13f45853079f214659 src/acme/providers/tests.rs -29ff0647417b0f1d50a4cc075ab51afecff9ddf47db3eb7948d51db5a38f33f3 src/acme/runtime_bootstrap.rs -87f64198770ae026c03a242fa542db2590ca734b02e756f1e5c28d66236f753a src/acme/runtime_env.rs -f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs -e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs -b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs -12883df8e5a88d9da6026959b4736838ba9dd276f900ca14e33622dceeedbb25 src/admin/api_token_access.rs -0a8a6fb22b90b11ab8ed329779aa3813f6a63a39edac7fe8d821402821073635 src/admin/api_tokens.rs -b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs -51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs +3ec53c7f6e1feb1dcd23bf4ab54cabd8b3601d48977cc01a0ec47c3b2bd88182 src/acme/runtime_bootstrap.rs +069b79d455d1126fd3967837493af896a2da9c584ceb582f2ffcceff321795eb src/acme/runtime_env.rs +579ddf69314d7f1e11270b00135de64762d77d5ebc25bf98a52945086a4cea72 src/acme/runtime_versions.rs +9c1fa211bfd307ce301ea7ef5cdac15269d69d5e01f47bc08a4cfc26502c56ac src/acme/tests.rs +1171a1854708210e2f2107445a7c39620b8048478ff0f252f20193be5385cdf9 src/admin/access.rs +bcf131abdae425a85b5a54ade51346f623de1e10fa08e18f7c10f1fd2dd0aec1 src/admin/api_token_access.rs +5f5b150bb8f51f57462e519650093354ae7b40fcb80c4380fd3391a33f4b30f2 src/admin/api_tokens.rs +852820625bd330f6eb87aa734d02ef9f42d1eb5ce31b0f10a8cc06b1287b5bf1 src/admin/certificate_api.rs +a402209141667fdce69698a60dc0b867248effa191494d0a0d93d468d7df9476 src/admin/certificates.rs 521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs -b5f6d49f1add583cfc8cd4aa36ffe4db56dac4de3b151fe8239fe42f507019cc src/admin/dashboard_presets.rs -5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs -ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/documentation/documents.rs +159c3970d25fa29acf2483fc9422b003e5d63884713f62baa8375e4a4020730e src/admin/dashboard_presets.rs +e9b03088840689636cba66eae0e4e53c495900aae3c2021add6ea3a2ef91977b src/admin/documentation.rs +d4a249e8c2bf06a12fbd561fcdf5a5211aa5ed0e0dcd5111b84bbb13b3640c49 src/admin/documentation/documents.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs -c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs -c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs -4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs -8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs +b4f225850d3d137ef23569d69774a46ccd5b02b21f7a035133cd14f45179d8ea src/admin/file_logs_io.rs +75a51d477283f98d1b0d9b0aba1d593681fe4d120e243d9807c7ab3aa3ec21b5 src/admin/file_logs_query.rs +f66ff718f82a326f80808b0a5088bd05a48625be802e5ba305fff3c344b59948 src/admin/logging_api.rs +1ba0520c6348246cbb35b60e596e99dc600137c0696de86508cf06587bd86563 src/admin/mod.rs fff31f340853b3f1a26b443e483a0a3937acb1a2cdaff67643933a32a06887ac src/admin/openapi.json -d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs -30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs -75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs -92b3e05f04711d8001b56816405859631d9e917868e15be965c7e76ef4ddef99 src/admin/preferences.rs -4a5cb4c3a3aa8b9a519020854506ce4be5e688a6c4298260322ae641dded7fe9 src/admin/preferences_columns.rs -668cab06d8c8ddbeda2595bcb7aec236e0c56b22ca6adfe9f19c3d160de0fb39 src/admin/preferences_tests.rs -4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs +74ad6cebcdfdd5ded422c6a720b5ad7d5f9566f1d995efeaec95319e2fe5a452 src/admin/operations.rs +d75c1d31c8b57d64b0bf8d925b0b7630411b6a4b3e4be239fd9b8c82b5b97a82 src/admin/panel_assets.rs +1a54d9c0594070d24e0faaf9497208085a5c7e0345dd30125e700727be34e010 src/admin/panel_content.rs +730c559afaa8a50affc3eb6dc6fbde7005c0356237352fcf8c5423b65725a8fe src/admin/preferences.rs +35a7f99c7448c90b8637f4b3a7becca138f1f378fe5dc3f87122151525bb20f9 src/admin/preferences_columns.rs +51dbfdf8b228cd4a381aa720b19146418d23763670fca74ff729bd88b13d6782 src/admin/preferences_tests.rs +62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs -9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs -ae6cc6020b896a54b9ba2dcbc0fbed9f13d289ebd7ef8804008386fca8533e85 src/admin/router.rs -0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs -978f50b9ec93dc9bd6736f3dfd9a4c60ae732c9764a0cb232abcb1d42d9315d4 src/admin/status.rs -6d3224349d08b1052ca66eb153aba513de53bcb666c6f57f00075509c6b6cc47 src/admin/status_live.rs -c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs +e81a0357d88ee3e3f9031755f4b0e2f5d381936abbbe569c5cde4cbc421b64e3 src/admin/refresh_preferences.rs +00b5dd3090792986217b28c69bf826748145129e5517b260d95019b1d7b2ebb2 src/admin/router.rs +2e7b0871e4af18ed3410f5b79c5fca6c75e6593d71b70b90aa3d80e3770bbb89 src/admin/session.rs +0c86d94b714cfff26e184541ed3bfcb317c88e45afe084d5f3b28558946dd9b3 src/admin/status.rs +2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs +060e0b378cc48ce17c7e007675467d369ebaf113b64074e595cc471bbfe41800 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -5e52252a8e886d8e28eed1d456cdfb7387ea6a193007043662889ff366f6d6f5 src/admin/tests.rs +fd38c6843e09508e90937362719da67a3678c657642ad5147cbce53ef891b011 src/admin/tests.rs c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js @@ -231,278 +231,282 @@ b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/ 69c8848159dcc7c4d93f49de3be75553cbecd0731d64302e2b644ae61196e704 src/admin/web/vhosts/render.js 4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js -53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs +2d40c2cb32d7365df0c9cd6c8cb6f0483c7a799786c648f4836a0d9740c6c5ef src/auth/files/mod.rs b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs -775178ee5781e64574bdd7c1fb7d64e7e283977fbd9c98c99896532a7f1df612 src/auth/mod.rs -c96a87e82ff0c1c9ce3d0cd5135903bb53af78d1c4fb6ebfe5fea503bf5644ea src/auth/store.rs +50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs +25b9f78a7eee277902d375266bd0f023b79794bcdf646214fc161c5e6dab72d1 src/auth/store.rs 745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs -09c3a622a7d2169e49e3369ea5f0f816eab2f717eaf1675c625d518c8f34d367 src/backup.rs +32a25e53ac9542424debeaf1b637155bd4e60abd8692df082729d81232eb90b3 src/backup.rs 76aa2afe9e7302be39b3512f30d0448d9134300f8ae930d64ada40cf3ad4a8a3 src/backup/archive.rs -732cd4f9ed38ae69f8bbae61b1db8baaa6f86b07ca21c41f24a206a30f0a96fc src/backup/ownership.rs -076350506215faec3cfe7d9de4e60baf29672cc22a41eabe601dcc510c257799 src/backup/snapshot.rs -5c19490d834d9971166b6b784a75b081bb95fd50c8c634ef3c87924dfffd4a19 src/backup/tests.rs +bb9de9d12dd57289b1941fd05b25f7de1249c3f114d663071b702a05b135879b src/backup/ownership.rs +37f44cf3ecd71c7e817c76462c4c7de7ca6ecd68c0544f6429a861cdcde1fbdd src/backup/snapshot.rs +ece7a5eda4b57bae8f5d819a309cb411b95b1845f5746ac162dee51456365857 src/backup/tests.rs f5afae218f3a6f91bc2f1f5a90094630ce5b7ca574de61807b5a317b13937ec2 src/backup/validation.rs -426a946b2c9e244f09f6cbc5f9664b8ad0737529a9bb5a3d549d27196a1f8043 src/balance.rs -ca1e03b56a872dca37ed9d05ead41c1538a808e80b09fb9494823c443cd51c61 src/balance/health.rs -70c44291960b835c7a456cf066469b0002e1e35ba497663361d659d452a52e5e src/balance/lease_runtime.rs -aba0f4ae46f05b07bfc434b7174553ce20e5187e1b40a6cf946eb2aefbe1840b src/balance/model.rs -8550e5d64aa6fee442cea3e8e243fc4cfc1e8aa39726bc5cc6b2afa9307e3d05 src/balance/pool_core.rs +eec564a7ef886d18ab75289025a47d62bb702f07d63f92d2f83b7afeecebbb15 src/balance.rs +8f7975b488fad31f8d83ffedf8a5b6774b86b59290ce0711e779bf2496d5503f src/balance/health.rs +344631248c71164187497a10d177d8eaa3e2f4a08111520d2536ec89ae55e1e0 src/balance/lease_runtime.rs +ebe2b95f8fc5904850a88207f0690449ced49b28dc14ce5608b706659e338fca src/balance/model.rs +aebcdcc1aaf8e38c4f286e6744c09525eef99625d1050479780c0f3c5b429c21 src/balance/pool_core.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs -8abb6048303ee8c022546a528d5693167766e7450efd8e20f37ec6fd0fa9ef5e src/balance/pool_view.rs -814be4263c35b84de2b3c3bb3c60d7ead9f593154ac85aa6c8ae35700ee591c7 src/balance/tests.rs -2b1d2da46b132c8f5e75784bc1d458e231a08342b76dc2aebfbe8124a5ddc959 src/basic_auth.rs +a801a99646441cb748ab05f01101cc9c7a680788eb803c5b786ec6c5a7533e24 src/balance/pool_view.rs +05227b05c37af2aebbde1ae37d81c2789088590ca45dcce3ae926c4fcf027bc9 src/balance/tests.rs +16a22aa72f311d1fea6d8da03c3ea1590bc1f248b21876354eecc0da0fd93308 src/basic_auth/mod.rs 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs +fb4bcb9d8852d2aeff41569291fe96d31c719d60789e860629a18cd15b8f24bf src/cache/cache_regression_tests.rs 7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs -a9eeb93519aae2de7a1282296ced91d2eb8307cc966f8fbf6085af0fd21b0ad2 src/cache/cold/cleanup.rs -8354ae90c1a0857773a0c6d79427925e0115d94ccfafb809ccc1ebf4ef10f9b9 src/cache/cold/demotion.rs -2eaba3261da2a547e9af91330319a3abd3a8ad8b5b44a337a84040720209017d src/cache/cold/startup.rs -e7c7fd71727d542c2df3e03a4a1cba9d839c516963f66bbfde1c29ec81053500 src/cache/cold/worker.rs -d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs -563919c53d38e9b83505a5715469833569efb2a09e91a2b9a89c335187368dee src/cache/dashboard.rs -7eaf3947cfc89cc6ef55a64fef0b3fedf1d9a1c33322aba2b5170c0ea34a5f2c src/cache/fill.rs +b75ff512a1c6344483e00d2c67578ceea6b5472c279da0246f3622daa68d7e16 src/cache/cold/cleanup.rs +cd5cace53675f995f526e7296822ee5c7fb4b05d01c0ebb641d3efceb04175f4 src/cache/cold/cold_tier_tests.rs +79547f697246651c89aac685b26f0701dd3a1db6b028695fa5ecc3d2ae14f643 src/cache/cold/demotion.rs +e80f84449412c4ae7bc753672a554160519bf1f186b5707252c3286cbe13f02e src/cache/cold/startup.rs +83095bc93132f306c537b31ba929669fedac99af3c4f133d0533b5ed7ac4e6ed src/cache/cold/worker.rs +c1ce42ca57844c9f80234c1995f1b151dcc9373a0d55387ee887a8098d434f9c src/cache/collapse.rs +81d61fad3b5d3d6cc131cb3cbed8438528e7473b23026440ff1b993ee69b5876 src/cache/dashboard.rs +5820a33f7be9473b2a67613462e95db029c2e44385e460323936d314963d6b10 src/cache/dashboard_tests.rs +7c2549ce80a50f6e998b8f74a5fb86300f7ebfbf8a24b4c35f855bf6a40a7880 src/cache/fill.rs 675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs -3b8f77bde71253a3ca600be5438b7cc72f7da258f3afa5de2cf64553e49922d1 src/cache/invalidation/invalidate.rs -f3c5ec56f8d81a285877f6a07239081c2b1b064fc9143ba3bf36d616b0239d86 src/cache/lifecycle.rs +d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/invalidation/http_invalidation_tests.rs +ba90f7ae78b15c9cd13f9b75bccfb290d99e9cbba2023de73c7198ae24ca5637 src/cache/invalidation/invalidate.rs +bfe59157d1656cdaf8e7cd82e659b25c7fcfd21c9b7ad17f7390466d7393957d src/cache/lifecycle.rs bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs -bbd5659a3197e443b3b5b726d39d7b7934bb9aeaedcdc04285bbf4321d1359d4 src/cache/lookup/hot.rs -5c3f749b3028e436a99614c22dbd05907b63c12a5c7c6baeda24c8f506db4f65 src/cache/lookup/insert.rs -b38f93fa2eac694aac4dd2f1d69b55688928b53b78bf095d479843956c78e605 src/cache/lookup/tiered.rs -5d4338ecccdbe9d78476e28491d92d4a1f96ab3c1d40590a7cfd1ba131b3ee12 src/cache/mod.rs +d5d01ffcc68e5ec6a913f20df108964a5b325f1aa972c078ba5e6defde81591d src/cache/lookup/hot.rs +a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/lookup/hot_hit_034_tests.rs +e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/lookup/insert.rs +0338e0066e134b9e8032f117d9557b9bc5edc574f11710c2d36a6ca1c7467a99 src/cache/lookup/tiered.rs +639d5be6258699ec7e9f260ec33bd2c366203b6655bf0e98f30517c2e12ceeeb src/cache/mod.rs 0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs -9704265423d53e87c04cbac11effdcc3792439699494138dc86466ab50656993 src/cache/model/cache.rs +952ce10a89c714571a5b8a79d5fb3ca60f5216901f95b3f82e7e257b4682fa9d src/cache/model/cache.rs 0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs -29bfce96b2ffd102d9c848c7b717a45fedb9aa44511f62f7a1f28a53dafd063a src/cache/model/entry.rs -88d74fc1b7b9ca15a54a8f4893ad3993105952148bcfee099e9bc9727b622aa3 src/cache/model/http_invalidation.rs -5ea7d8e72c714e5c5330dd81b5a6e2c8ed79e4367af85f133e63a756ad9788a3 src/cache/model/maintenance.rs -5441fa3a384add28b6fdd15300cf2648f8237dbfc211cad9117754203be76911 src/cache/model/settings.rs -55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs +4a44e2161c2e8b2d6decca6caf1f9315a723c888e0894236c88c656b01b8442d src/cache/model/entry.rs +eee9e6614cc5a185e07686f9ad24525728cc49dfe8b9ce6a6bad0c14f3f0bcd3 src/cache/model/http_invalidation.rs +a7b4b861127fe2b1527f12e5ce01ebacf60fe266f913467a2cc66d7f80e98c60 src/cache/model/maintenance.rs +298fa122c7583bf8447d08255c4d0ab335e23c7e48cac3f621f235c201046fb7 src/cache/model/settings.rs +c2efe75f740ba31e83c49bf76e7fc179e897f41c140eea700e2675ae648e4a1c src/cache/model/views.rs e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs -989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs -d6051d55168fe1db97980a54c22c708f4f325ba72bfc36f35a1851eb5dcc051d src/cache/policy/response.rs -7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs -cf2513043813421e10260cef63645fdb8c5249114f16d91eae9f919b0214242a src/cache/stats.rs -93bd3e9e5c1a045c784cee0e1fdb9e1c5d862ed70d5d91c54c5a769bca8fd949 src/cache/tests.rs -9183fa8e6086e92fb148fc278fd866d44e3dd988c848a071e32066e44a1cbf0a src/cache/tests/cold_tier_tests.rs -25ed7635bfd5e34de55b319bf163db9a0af26642664ce7685ebe1241bab52cac src/cache/tests/dashboard_tests.rs -a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/tests/hot_hit_034_tests.rs -d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.rs -1659ffd8d227d2ec8b98e6a5e06b6143e6ac4ac06ab423c184980abcb47085da src/cache/tests/scope_tests.rs -d4429cf4f35fd8d0082108fb62602fde72c9c2fc6edfadfe3268c6a8214a8f89 src/cache/tests/tests.rs -af28534625859ff66011e10c54cff8f82c2cfa1c173c4d99b89c27c1437f0659 src/cache/tests/v022_cache_tests.rs -f76b41a2b0ffa5699a456cc2cfa0b063098c90552fe01e0d05e6ee91a01cbe57 src/cache/tickets.rs -e0146bf5d2e2e33aedcccd9205e5bd457559c9d77dc7ce266f8c165d1b867f0f src/compression.rs +d01b8443c6d02a9b5d7f1477719865ea105126b8620e8fe13d7b4711e47fffb1 src/cache/policy/request.rs +7cf0318d2b6dfc5f00806d608d78eeae9535b4fbb22b28826aad11b72579f9f0 src/cache/policy/response.rs +e59efd322f518ebd7b5b154115188f382c2cf7882ca0a401736ba5d685981ea5 src/cache/policy/scope_tests.rs +982d4b32f5ef2f80e6e18dd7334152a903631d73edd2db532915e272b6bd2f59 src/cache/range.rs +48c260e8dd75c89fb4518f6a20ece21017f69b0f5c9f9d80222ae34fab62b3b3 src/cache/stats.rs +42a6bd984ee8ab69ba03030a113818252b26d5b922262b7759367d67899353e0 src/cache/tests.rs +276a557d0ad85c4d5a7a3dae1e49e748591c79879f4c0d5784ec277ce8d08023 src/cache/tickets.rs +9547a2fc3cf5220aa3edf3283e09b091f8b0fb6ccbe36e53f84c625c7482e08e src/cache/v022_cache_tests.rs +c2d8548d12aeb2aaf02d47f6162904170526c9592aad9839a6bd5490feb269bc src/compression/mod.rs 2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs -7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs -56b3d37e7447995bec2fdda1c17dcc74b60958a63ca88b88ff57e56347d8b0b9 src/config/backend.rs -cb882e390bc429df11a1ca7c3a1d4175ffbb70c4e821b168c5d7a11f55163d14 src/config/compile.rs -a4bb70f4da2ae1fa890e65ec15f87e919e2288637dc0eb2c383234e8babbcb05 src/config/internal_routing.rs -8ab99c684da56a8efd7a44ab6e7de2877097150fa163b17e5cfedbf30bca5eeb src/config/model.rs -25efab17c6955e33a3b3807b147ef25f1e8de352939fa60f5c9f24a5ef7602ef src/config/routing.rs -48ecd9de5a54b0a4aa484f38a7e171f60a033b725e14b158d1ef07b67997f9eb src/config/snapshot.rs -76af9d85af2bf16e98a02af25f818fc449e2aacba8c17689c2b7c3e59fe77b29 src/config/tests.rs -f3c39dc4ac09b9aa7fa8ff01da933f67f25f5030c05f54eef1c7d1e064d1f3c1 src/db.rs -ba82a4dede3dadd4b6c62434b0a0086420278067a46e93286d09b13e9ebc9d36 src/db/api_tokens.rs -30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs -20ba52d3755c359d670968efa53b0604ff7cbed63a416f9f765b50a1f45074ac src/db/dashboard_presets.rs +e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs +9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs +7261770b504b5d2bb4637a2a755c8ddd8301636aaf703ce9c8a01b0623bf339b src/config/compile.rs +4febd62f15f33079b843f59fed94ffb1e9cc8693f58e54f4ab15ee03fd653b9f src/config/internal_routing.rs +0d9f62fe4600b230e200f41652effc22ac7c1b649fbe63505ed6ea34c644feea src/config/model.rs +5a2b888c2b2b3e506a8f1ee846ebdef60c1b3a3befe312d05431d4591a78c709 src/config/route_policy_tests.rs +40e9483d7b2450f55d29b46c408727f4e331b85ecda44fbd7b96bd9a09f02655 src/config/routing.rs +422c43ef9fbbf6b9b8a44c162a95cda890ead6dfa1ccd24da4782844cbcb8834 src/config/snapshot.rs +e09a3a8ca70ce7e42007553d48798bcb8b4fbdf791e338b36f238d7ab99fd05e src/config/tests.rs +596a6697e8eea1d78fc86baaf8e9eb906ac38db6c1fe9a7ac62ecac85875940d src/config/transport_tests.rs +eb04a8922da26e45c9b6005c0cdf1b1ddf2da3c3b680650b22a1d1f16d17a4b2 src/db.rs +e37168285f1c7e17a17a028a151784743e2ed6b9b5daa5560eacd6c0403eefb6 src/db/api_tokens.rs +111baffc0fc7d2001d6b7d2542f95593ba5723bf37af7b209a273bc56545436c src/db/certificate_api.rs +5491dd7bb9b3fceac97c4dea63aba6d5c7abcb0fbd06e36ae986e00c79bc07d0 src/db/dashboard_presets.rs 2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs -19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs -e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs -b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs +dada314e1709e960eb2e225b4d10c3cd12606c62c4bc2c0220aec1ba133e4ade src/demo.rs +5808205f03401e3f2c7f5c826d5de76e83e4b6f94c64ad3e50f505f985f6d26e src/diagnostics.rs +045593a42a6e4f632e5052c655788f0843faa313fc929265b89509f6dfff91ed src/docker.rs 93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs -61ee4067da4824513791155326ea3f78d4dd711a486f21cddc44671a3bd0f1d9 src/docker/model.rs -eef783112c8c45c64133edb1205f088179cd394f79f39cd452eb1f53dbffb00d src/docker/reconcile.rs -f0bd0e64bdce72ac77d69805bee17d29776392187a09fd0087f019cc8c3e232f src/docker/tests.rs -61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs -f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs -01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs -af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs +da83e954040b281e99a9cacdf750d1745333b6c8ea1a5c3b6793ff01168e49e4 src/docker/model.rs +a5239889203ac3fd6c82824ec3a8dfb1f250648ab05e3dea02a09183f82c1db2 src/docker/reconcile.rs +3c0d97c9f6b8a5ccdf779ac2a965c39b9bd899dbb28a84e128d81fa75ccf696c src/docker/tests.rs +e36db1ed3188ffe54df7fc4ef76c4b453f23683bb7b8cdf601046e2bd50e0403 src/forwarding.rs +08d2e08a798abb944a53570f0bdaa08f1266b1107677ceda070091af4c2fc121 src/forwarding/peer_formatting.rs +b821e2efd2939cb13d01e402532f33feba6682acb6a7a3223be086c1cd171820 src/forwarding/tests.rs +5cef023035309caa5ca2035a11a77cd485f72092ab1c7670398d415460f84bc2 src/geoip.rs 80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs 06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs d3dc58628683472ef4d205fa5c42f0b7ef83cf90846146d96941cd3b36a40766 src/geoip/tests.rs 70091bdf6f38f03b6df8c8c4407c300677e57f293130122503f936500963d37c src/geoip/update.rs c1b49bef28d87712e4c46138e70b52c4bcfb1a00196579031fac043d0b4aaa7b src/geoip/update_http.rs 9a912dad0b8d1e10410fd95d0da3684656092ef85cf8b23eda4ffe7fb92921d8 src/geoip/update_tests.rs -f6ebafafa82391f8cb3b5a09331f3762b6ca5a9fb64bafb5772cde3ab814dfcf src/headers.rs +61a2b01db9010f9debfb38b43a26bf588438a5e470dff938e4e54f9d7ba46732 src/headers/mod.rs e4f07bfb7eb75b6897ef812870caff158679a3046d64582428abe8c6df490dbe src/headers/tests.rs -502b170c1a708abc0e0b6b5579c66f4c0da816dd53f5ff82b2b97a1506644c01 src/http3.rs -eb57117938a447d9a6c2ec1806ebd9fecc640a68c88950a8b35fa9bad14d0dc6 src/http3/body.rs -ed25da264a2d25b40c4fcddc97c626a3a43d4f3a9be6c6d31af58b9e4925c198 src/http3/diagnostics.rs -37f54961d8f52b79a75fb2be31e81f4fdb1e66b06350345c4483d6197b8a0bbc src/http3/identity.rs -9d31c89ed7139d13f12f2031183f9542ee25e3f9d56303617c086747fe1487f6 src/http3/settings.rs -8b7e840c93f08f49a5b15b2f594366930536070d76391db9fd2dd1e884ce88a8 src/http3/tests.rs +d7c764af29c132e3d8d1fae1bdbe6bf20a27046dc294f83761fea335726f2306 src/http3.rs +4b56bddbfaa1db9151807c7bc016e573e78afce2ecf593df628e2e311df61658 src/http3/body.rs +dd4e4200743b43895c0ee2071dc9ce36659b10251ce23d1082be169594a20d76 src/http3/diagnostics.rs +49ce4452ef7973cafa2e4071e2e14be0e16000e931a6501e4dc613652e49e230 src/http3/identity.rs +0b41072f6bcf28725c80f471584cd55d4900424ce5d55ac7f6909813788c63fd src/http3/settings.rs +01ed8610a8d169bafecf5ef91d325042d9b59a228caa8a89f76005cee20e6811 src/http3/tests.rs 68fb5ab6a889995a66ad12cb6acf88c5d04f0aca572e2ae15f1a9cd171a5f24b src/lib.rs -2551189a3e5701371026c9abd2c5d44d37495f28fb67e220180421bfd315e3dd src/logging.rs -21ddb01b8f9c7fab60840d18103ef8de4716ff0c890cc1525b472b69af7916ec src/logging/error_runtime.rs -add1293410e87ddae85ba4b8bec57b56ff54415b8112576c450f85920ab6c38c src/logging/format.rs -f5815d778cc1785723faed4abb15f9880f22b6524b5289fe1e7158905aac0986 src/logging/model.rs -b9bef61d332df0dec47f15d864a7aaea240935c432bf935e40144916e37553bc src/logging/runtime.rs -106f769f45a832079b170e1cf11d7e6a78adba354bb0a88bfb8e3aff14f8cce6 src/logging/tests.rs -3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs -7b81540a0f035f99bf2765a7d3ed75f9fcb8b72895a1ee51bc1aae3ae0c09869 src/main/admin_runtime.rs -22e8ec9b36c66ec53a78b582f54aa320b2eb301c96ae4f23f92bcb84d4f1ebd7 src/main/bootstrap.rs +e8399753a6ff49b7de19edb0a268bf871c9979a518354160cf07b4669051fcbb src/logging.rs +4310aa77bec91dc473808787cebef664d2c9bda8d78742ad0fd410670f0f4f56 src/logging/error_runtime.rs +9f9705e07cba9f3c2888babb2eef63b706549042e0f4ce29fb6a1646ae58f9b8 src/logging/format.rs +e51524d45ddf54574bd33d71dd4ab18abbb642390fb6b6dda2a2c6e5439fe9a2 src/logging/model.rs +3afbf5083695f21ae4a8bff10b81ec3dec55124007c31b8a0d36c3014e4873a0 src/logging/runtime.rs +48187ebfebeaf6863207ce338df2d6d0f418fc3aa0689481c23c2a8ad9970b52 src/logging/tests.rs +0e9993aa6679381d91f3a22068b30e1611439d7fe50900f49a4a73ffdfbdb3b1 src/main.rs +04ad738f90c67754f9f133863a0e2ae94665357f527c2fc3db05863087ae4d83 src/main/admin_runtime.rs +2c32c9b2014f9fcb111b64dd85f2106a8731b6d57fc3721522b2ec5f4349f4fd src/main/bootstrap.rs 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs -33935951388699d3a78bcd4314fbde8226bc535e9c05932e7edef65e894bd7b9 src/main/shutdown.rs +b240033715323e7cb7b9bcaf92edd481f7089b4da6bab25005ca6733da8756dc src/main/shutdown/mod.rs 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs -e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs -1def53b16ae1a498e6d31e4ecd7337af240121abe36ca505fed087bd4e426ab8 src/metrics.rs -6eb36a1a8a2bd04833aba17cca9645a8c55fcf93d512c9ac44c5368b83fe4059 src/metrics/global.rs -2715e2aa3068d9a4dd8f9cbf44e31917c4166111e6fd770219fc2e26cbbec3e0 src/metrics/rate_sampling.rs -78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs -e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs -a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs +ca01c97cc52a50797306193a546e4379fe3ce9401b0c936c756b881180315ab0 src/main/tasks.rs +a719c51aebcba0f16f8810c37618201954e79d6806da085ee6c5e4697a05b521 src/metrics.rs +9c7ed78345abc33d260f8d5cd52339bf8de748c08b53eaa2bdea8a32a0ea2a20 src/metrics/global.rs +def9a23ea4edd87fe142ac597387f1c0235088c95c693809f5793279d4cbe02f src/metrics/rate_sampling.rs +f27f1f8bed769883d1a7ae6a48f89f7712394dfab65d05702b0f944f0a45bd8e src/metrics/vhost/mod.rs +40cc25ed1993127dece24857221b6da2debac37faf403abcb451ec442a240273 src/metrics/vhost/tests.rs +e7ddf1f453a3e207ece18a4358cb44cc02ee68333ecc0b6c68028ad17661cc8b src/not_found/mod.rs c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs -3567c2b65b09553e8aa46fb223dcab885b358fee9fc69cd0faf148059433b9e4 src/pages.rs -da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs -63a84af77b6c3dd390aa9dfde71c527eec6e1a3c0a38c1f02c08055e525f250c src/prometheus.rs +d32d757ace8753eccb842c24b7af77061141478d10fe0acdfb05263d19e9a64c src/pages/mod.rs +8416712d3e8ce81a1794152ef98220313066527d613142cc0958c4915add8c39 src/pages/tests.rs +5650a3f70162abd97021e0a51a92cac2c4dd39004426ef99dff36cbb0741aeac src/prometheus.rs 192a780d5874afc158d62a7dbe1687d734635afe9342fff526b8a2f6db4e5633 src/prometheus/core.rs -74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs -97926653a7c00e884784c2739a998d666abb3a891f1642bcf1ecc6c49d0efa62 src/prometheus/json.rs -c34c3204bfb07a0de95250dfc1208979a5e701ccf5b4093cfbfd9b996ff3ba15 src/prometheus/model.rs -67ea91c74c9e7860ee16da8f3543f642d5b6549a148a50a35e4d8f660510517f src/prometheus/tests.rs +2321ee667569803dfe6c92c0e98adba0ac8fcdc6a3eacd5857233f1acae93daf src/prometheus/docker_misc.rs +cc909edf740a091f9057912f555dd888b644fa1cb49647fe0e2e1f5dba8ff5af src/prometheus/json.rs +f9aab3297fcf647295849f77cbf99197f4dd27efd6817e54c03451e04972430c src/prometheus/model.rs +2ea975c96fb45bfeeda95583e909abb26328a16e411fa46a1e31b2a359f7e711 src/prometheus/tests.rs 028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs -fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs -2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs -66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs -2c65303210bc6783e4c75d0ecedc9be5a1bbeb063ceea8e1ac9983edbe9f5d31 src/proxy/cache_helpers.rs -aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs -676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs +f1b0254f53b1a4336bd8170ad441ba072b49a114e497b985cb20699b4e375044 src/prometheus/web_php.rs +0e8db3facf4f4df7b05f058e2dccc8521d88bea9da67ac0889b77d349d524d9c src/proxy/body/mod.rs +57a71c9c6e4e0deba5d05963b9870af8f4c4d8dbd321ed4e52cee88746c997f0 src/proxy/body/tests.rs +dff5d29ad6d126fde26f2b5e770c3b35275a82b6d59e41dd85edf28ac5eb2740 src/proxy/cache_helpers.rs +7e3837d2f6ba7d07ca2cbabda57b315f5a7f4d75a13e5eddf6861a150d0d2ed6 src/proxy/cache_http_invalidation.rs +d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/cache_http_invalidation_tests.rs +a679baaa062beadff9ddb61a3bd6cde1ce27cc62147d2e27b468c0a8cd32ceee src/proxy/cache_plan/mod.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs -a8848cbdf85368548b003af2b43823c453866c472af4c7d2ff0ec7da14c44c8d src/proxy/error_fallback.rs -f2f9cae1c74eaf66fc365c0709a6eb64e0b27457d4a00728e02cff8a641a1295 src/proxy/forward/cache_lookup.rs -cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forward/response.rs -44e1bae0a35ddae6a084c1d50b936fd10dd51059f331525b758447b526a2bddf src/proxy/forward/route_cache.rs -691b54a0cc33089e67d61ffc715b5c015976bae3fbd453b26cb06764be2482e5 src/proxy/forward/success_finish.rs -0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs -3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs -bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs -d21cefaeb3b4e4bd187705124e281f44bb0d2b597fbe076f3b2ca00a1c024302 src/proxy/forward_entry.rs +a0b9e2e9ab0bc89c7b64bd19365198234b1c5c13f455ea47be122a5660639b17 src/proxy/error_fallback.rs +c825f436ce0730ce542f51a1363a0fdfb0b1a4113a2257384d052de1afd56359 src/proxy/forward/cache_fast_path_tests.rs +a20cd11a504e423e5b1af5cebb5b4186530d4eaacaa02c3212a1174cdbfb6498 src/proxy/forward/cache_lookup.rs +0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/forward/h3_navigation_tests.rs +ce97cfcb07d90415937f0520ee7175502b22e60694c1ea8e3d2b47678d190e5f src/proxy/forward/response.rs +02e1fbe16e59c746d57dfbabf655a729a2cde8131886689532a414c47467a53f src/proxy/forward/route_cache.rs +3f91a40a01378d1fbac56ec376df6626e0c4fb6c3b8c8d42583b9c398d368ad9 src/proxy/forward/success_finish.rs +e631c63fc10a23e4533bbd158659710b184656b323d0e6169a8627294c6972ba src/proxy/forward/success_prepare.rs +e9ac9be64f6f83c4ca2cd5d299649f24dd16197712083c6dc10e55c662c65b80 src/proxy/forward/upstream.rs +53ae013330edab4d97a9cdab2c513f8548f6816aac60f7035bf3433a6a2db818 src/proxy/forward/web_source.rs +3340583d395fcc859cf3421f4e33cf8347915ee4c468f7d0e4abebce8156fdca src/proxy/forward_entry.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs -cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs +5b07b98d855453d5a4272f5fc98a75973c3e6032416e0fe4f7f151c1bf239fd8 src/proxy/headers/authority_tests.rs +3e642599bb0aaa3ab36d83a69d9e04541cddde90f38661934ee0255011ceefb5 src/proxy/headers/mod.rs 0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs -9ed6577b381285d2b7b874f60fd38d8192a0a5f3dbabdb5b737091158c43ac31 src/proxy/identity.rs -050c66311f71cac75c1d4c8547351a1d6f1111ed0db128691366fdd73647e90a src/proxy/mod.rs -55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs -2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs +c04451ac8abebf4662ccc3ee4610d5342ad5382da2098f7af5db274d079770b6 src/proxy/identity.rs +a08ee061e146cb7a08311106f73a04468c89f77b6ad05a2c623621a2ae9faf2a src/proxy/mod.rs +5559efb5984e8da34235e2e5cb34cd6865a34ede20e159bc9f04fc9190a5c09f src/proxy/redirect_helpers.rs +de1763b89d98607e20f0f6e3c9b546483927cbe5450efc2e5634fe691fd52ad6 src/proxy/request_id/mod.rs b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs -6a46cc0245175db56384ad793065141dece2213657ca73620fcc66fbe53a4b1b src/proxy/tests/authority_tests.rs -91776c89a0ac3560902ec751846325ae5168289d2cd8c568808c89fa82aa5f69 src/proxy/tests/cache_fast_path_tests.rs -0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs -d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs -fecc1c7ffff740caa7168370e59234a17b9836a6dce6d08b770ee9648c22709b src/proxy/types.rs -1455d33b16daf9f88bba76255795fcab6e050fb9c2bbea0bd601ac90e8ec30d4 src/proxy/upstream_helpers.rs -2d9142e3ae95895b5271b4009ab48236a3a2074ce51179c021193b35777560e4 src/proxy/web_cache_revalidation.rs -d7b4b809b12ee89293df45e9a64b1c9981fb3f89deaab3762f4a3ad3992dd7b1 src/proxy/web_cache_revalidation/tests.rs -69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs -d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/websocket/handshake.rs +580fefdc7dfb27da73f86f4d2067d46a1f856d2f398b0598d37a743e880d3dac src/proxy/types.rs +2ed96bc94003a881ab60f6871e3215598a3a519dc9f2480137f57292b70dc888 src/proxy/upstream_helpers.rs +991bcc5caebc882849b6abc928230c0a6ec6e25f6aff6a8856e8a9807b9de72d src/proxy/web_cache_revalidation/mod.rs +c827ad43de9546dbc5d9744b1e8209da0f70d2be0304a60a776f9a71d75f57d8 src/proxy/web_cache_revalidation/tests.rs +3d591201595a3b1e928ded20a8736477a47e5acc3bcbde158f7c17547ce155ba src/proxy/websocket.rs +f8972bedb1a487fac86177774cd98cf2619fb25af97e57cfac2853db5cbf91a9 src/proxy/websocket/handshake.rs +c02d42b5c7eeedbc54c58e98691f7d8e39f8e9be63a48f6d6b4ad6b8ea431b7e src/proxy/websocket/http2_tests.rs +faedf6cc2bd4c42f342a0aa188b634d6b8d280ca0e121032977eadaecb88e9d7 src/proxy/websocket/http3_tests.rs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/websocket/tunnel.rs -15fabd38576b61f650c039acdbf7ac5a5bd24330062475b94315285b2dc258b6 src/proxy/websocket/validation.rs -545247eb00b0116efbd4cc913cc251195a55feded1530e0d8d8945abbae78039 src/rate_limit.rs -e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 src/rate_limit/tests.rs -d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs +999cbf85cb6e917db529e30ba4a5eed1cc9e95a054da7b3039c730bb80061449 src/proxy/websocket/validation.rs +00aff5b5b0719930230eff4d7e3adbe998cf0258bf7361d0913679c863afd3fa src/rate_limit/mod.rs +2fcfa9706eb0708519d5181debaf3c85a86e401a8898df72f9eaf0ab72c9df85 src/rate_limit/tests.rs +e53c40b85792d4804a432e3c97dc13af8e29304bb0d250ee9ab9018bde522eeb src/redirect/mod.rs 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs 244d72b3dab36ec505c96d26a967420714fd3807268820f680fb3dcea0e88c0f src/runtime.rs -8d0d7b01871c8c588c40a54330a1053c85024655fbe38516ca6c94c3c19a8956 src/runtime/certificates.rs -02c598ffcf6af5aea95cd6bdd1a173bc63db8afdeb9153ec87bf0964b7244714 src/runtime/constructors.rs -92b4b284f1b69dca58e4e3f76199f0aecc397eed26d10123b427af1bb8a4a57e src/runtime/docker.rs -9015732f5592cb8d5503aee9199a0ead6c42ae20687d506e8752bd1065eafd82 src/runtime/external_config.rs -ee2050b9f8efb45a209c91d76043a1ba29c4289978896aaf7e7d344d48fdc369 src/runtime/tests.rs -0a585647b205290d0f7e52f9fe83b7ece1403cd87ca0e726669e27a3fb9faaa9 src/runtime/types.rs -78b734fc804bb3d673f6cc0ab0bc8862612072ca7f4423d7c9bdacbe78223fc2 src/runtime/updates.rs -208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs +3c71d8eef9ccb49ad93cf6014c5c27756ce0eaeddfca116048f24c483fda68e2 src/runtime/certificates.rs +2d610dc36f746215ad3bc1d860ee624a9233886d73a019fd243e51901503fc53 src/runtime/constructors.rs +eb2b0eeeb617cad704732d87acb667530144836e26edb0189929b1f25f35c6b5 src/runtime/docker.rs +18f7460e3738386e070f66932cd77240001255447a769d6e19a40573254a1473 src/runtime/external_config.rs +a3b65ac6133979b2905b878e5634537994816abb79480af219a17896c6dc4e33 src/runtime/tests.rs +d7a65497d1faa2f09a09c0a0ef77b444990cfb5ab379c85f6c27c1f9c0c143cc src/runtime/types.rs +11e99eb425887ad12b73d949f4b497fabc6897fee8fc7647fcb3766269c2fdc0 src/runtime/updates.rs +e3dbd60953db5a976985dd796e4268b41a0f3df59764c4664015bd41e2578058 src/secret_store/mod.rs 6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs -6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs +98854bfeb35cc5266210b1c33dffb36b8441e27e61d90ded8b681430696d156f src/security/mod.rs 0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs -98c52aa3b50d2e211ab752ccaf353c9a8c1500a87f9c1684b5dd0ba9d10e4619 src/server.rs -f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/http.rs -164384468c933320abf1c656f1e07a687aa869f5794d3e5a9b429885b5272266 src/server/http1_connection.rs -cd33fa634a5f1ee1d425e9e78269f9575b2e324967115383c80c6883c878ccfe src/server/listeners.rs -4424a99b0b688d1119ee25626778b4804aedfaa0799a4a9bc0ee99bc5c7ba96b src/server/sendfile.rs -71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs -e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs -960a82e02f09d75141fe3c1eecca915b66f17fdc8167196cd2fe21d5e6859376 src/server/tls_runtime.rs -73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs -240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs -1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs -9dbf814870b8636770f579c3d36d16f5b6afe8bc66adfad356c26d7ae7dbce45 src/stream.rs -254840126be2310426f6332f5efbe98147d9d8de1999731f40a3d13325c1dc6c src/stream/health.rs -bdc56be7dae85a97a4d5b8d46ef31ec4177292b1cadd507fe423b6d4ee1403bd src/stream/model.rs -de138a4ac68bcddba3a4f17ceba008bab643359d76a68726a755069f19334bed src/stream/pool.rs +0e8589f56e3229dec556b427b87c2cc5a5a9ffaa9ea84fc2522dc72d3239027d src/server.rs +ad4cfd4a7d6f331a8ce3bf17a6c461dba788bb41994f667033c6963df3b96332 src/server/http.rs +00dbce1ff1ea08774bcfd79d900d71704256e06ab0cc46c379c3d107c99ba50e src/server/http1_connection.rs +1cb8260c74de89c3e13bc225da32cd86fe883acd3c720d0cb2fc44c0c55e940c src/server/listeners.rs +a64bc0d4352ec8d97895978a98b6b66e0459842d1ec410a1d123d5d29d9ef058 src/server/sendfile.rs +5f6a59c6085c1250a93a70020661d1e8174d3b600b494d16f8eaf7941ee3bb99 src/server/serve/mod.rs +048a05a46108f50bef7da50b0c03b752d76e586b411de8ca3f55313bbdb08aee src/server/serve/tests.rs +669ea1041c103cb952f7a516363c8fe7baf51622f721aabdd57015e0e0279530 src/server/tls_runtime.rs +d0af5ee91ff7f0315d347b3fd41d60b41e9dd095e0d015ecd99f17c4f3a923f7 src/settings.rs +298673b6407b0ceda6ebc2ad4f4d787688cb0a5f144164f74895c745bcc53aa0 src/settings/public_origin.rs +a4fdf54f87bcc96b31e8a0ea400398a70011a044bf2ff3de78a0489b767f9de3 src/settings/tests.rs +2ad7888b263e1dd9f0bd588dc35590c9eb232f8fbacd10ea7554cb55b4dbd25b src/stream.rs +c7771539fbb94544947e958e410c46b73f9647011e56b1b229ce7035fe21851f src/stream/health.rs +c02e85af45590866f20933561f85ea9b4f2ead62c2ce41db75113632670a7b5f src/stream/model.rs +5b74acf26aad3b73a10501866b716de5dace753453d5f09c4f22c414e5ad880c src/stream/pool.rs 6ec42b8647c95a0c2025462e0be29c71481ce1c3b1f4d4193926471f1cf06be0 src/stream/runtime.rs 9346cbd71e006e41a7188f28eb86f9b00bac822e4cb31c76c8d6d2ec5d4c4711 src/stream/tests.rs -3070d78a7b88c2e47ed59960fa35e7d220a4cd7d78e48f6c6d2bcc5ff77f9620 src/system_stats/linux.rs +63924d279f5fdfde24d2e0663518a123dd673cc6ed9ce52233c4b6851bfb1efd src/system_stats/linux/mod.rs d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs -751d59582fb92c84e0c0cc7c0855c9d0a545805c9ba3be61826e50fb96c297ed src/system_stats/mod.rs +2c7da96822d605193f0208df70de670612259fd8c762c998ae828848cb210e11 src/system_stats/mod.rs 050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs -53d40d0e169dffe18c802f07764cc0b4bfdf61d758eadb8f0d12e55d7438a7cd src/tinyauth.rs +e7ed064a1706de9a7345e41fb8b6288b4467c12d1653e8afba6dc392c7a3a36a src/tinyauth/mod.rs 0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs -1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs -1d7a6e0df1ca4d4acfbc1d059e60d80c9ccf31fa7d130091df44bff9f8ad4d31 src/tls/certificates.rs -436575144aa352498a4696f12c2258bef3a898f3f50e6fbc6d67c3b93afa099f src/tls/external_paths.rs -627c973870f6da0b18a90f84d3e92ed3f9fbdd5587490e4424dd77baaab3674c src/tls/server_config.rs -5816e5e5b870a29d3f96d9e409fb2b667c1b2f4f599fd2b0ee25b522166df49e src/tls/tests.rs -2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/tests/acme.rs -93b359e9defd62506729bc90e56c96783e2f14dd477dd67f337ca83149da7168 src/tls/tests/external_paths.rs -3292498e7122bb9fbee49d29e51e37cbb337c51af62ce367a06f6b01f6c0491b src/tls/types.rs -6ae584944343b83ee3a2772cd93c985ddeaece7a9e6467b7cdbf5da22ee88028 src/traffic.rs -e796efe9d94e2e79220287196fe2d2d9f4a1c7640b9f71652d409478e25d3c09 src/traffic/tests.rs -d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs -87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs -b2c5236589093454ec60984b785ad6a4af6149d47f6db37d8e08c17fec7a2c1b src/transform/css.rs -5478670cef87777b14a2dab308f5a9182df56d7ea2f5625bdc94aa7e3d661503 src/transform/html.rs +df98becda799b6bf4ff5e2fec7d0e0a486e6b7eca6f9f5ad49ffdafd49a4f498 src/tls.rs +2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/acme_tests.rs +7425ee6c839f85d792e9251c6b9039e72fb49a5820a2266f751e6f4967a5871c src/tls/certificates.rs +2ff3c219f87bd82677045a4809d3d94921c5b230bbc2f3e7131b07548f73ea7e src/tls/external_paths.rs +60c16793b3d6115596e79e2cfe5436ad774709d20ca1cd55883e0f44e40ec871 src/tls/external_paths_tests.rs +7b4c0f58b3a1e1791bb3c682a001b78bfdbbdafdbc143db8063b3f22dc168616 src/tls/server_config.rs +9d6f048e791a3b7152904d1728258d298ad9d34aa3f83d13baf1b09a6fb7601d src/tls/tests.rs +d9d79edf5a8ef6e3b39a21d35dd5ec6e5a336047117918949e7ec6f5b20ed3f1 src/tls/types.rs +d637a77a9271736993f2e49022f752750364d4c6ba3cfd5cf7c9c3c8f37154b3 src/traffic/mod.rs +dc8a83fae47da0d86e6ce0a329cb5274233264df56eebdb56b9bf37cb7be2bb4 src/traffic/tests.rs +65fcac840f8b5f1508213e3f374aa318b450b0f9f90ba827c5ea935b9c60bee7 src/transform/apply.rs +34a06c2dc635e80690961ef61fe84110f9578f55adf89f92a04668783ccb0a98 src/transform/compile.rs +5a16b628af31fced425903a9d4b6ee83e1fe06ba023d574db16467baefe00ad3 src/transform/css.rs +2b9d5c11d4c02b4a3ad3b9bb1a18c63693f245b854084f5eba16595df47b47f8 src/transform/html/mod.rs aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs -a1d2fec07617c8a8dc425bfaeaf77c844152a0a174568a88a5baf4c317cc6c5c src/transform/js.rs +047acd7209c1db19709801abbe3ee1076c1fc33f0fe80cc5d9b60669d5f1bae4 src/transform/js/mod.rs cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/js/tests.rs -226ae561dc7b0243c04e55e28cdfd2b76acd48cf614d1919b81c196660b03cfb src/transform/json.rs +b0d20cf6784bf31457ea5a853e2fa5d85f6452395a78a538392703d97e687a11 src/transform/json/mod.rs 216e24e29ea87be4a124f95f898e155f127fd959d7d5d9b30f8cd0b2b5018f30 src/transform/json/tests.rs -6a8c28be93fb590942493c075b29ace1527981542bd183f232aa82b62e951eca src/transform/mod.rs -fde2fa3aa94b30d8536afec1c11348cfbd922d0b5ff89e91d34266e8e3863e26 src/transform/model.rs -a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/svg.rs +d13fa462d6df516a4cfaee9f4e184044c5b6345e311aeb3d9058efafe54e4c5e src/transform/mod.rs +954b86300c3ea47281acb2f43b0771e838f83ac09eb55e882e42de26aee3128f src/transform/model.rs +4b52257e1a3643910b5e7043852d7218230c0021c31215d90d0f8e384f2ae233 src/transform/svg/mod.rs 0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs d1f3f53fedae0d8ef69eeee457f7d30ed5a670a1ed4ced60683400ee46680260 src/transform/tests.rs -6146c59f36cf419623747d497b3de685da6c84964df43317bebdcb21deb9ee09 src/update_check.rs +64f711d2a0063036e3a2cba470b44eb165f3574bc28cf273296c2c3adb9ed4a5 src/update_check/mod.rs c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs -92efcdaafb9f5a8514daf63d7fcde29be977d7bdcd90162eda0ce00d6967ffbd src/upstream.rs -ca695e246bee5389b3757fe8588e48bff5d0ad473bc8588ad4ba7b52c99d7dfa src/upstream/client.rs -56c8a9d1c801be0f41b35c75cb42716c46976951948009c8707122c0a6a1eb58 src/upstream/client_tests.rs -75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs -8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs -a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs +b7ee3629a8fde0972bc1822b764f5fa2c813a760dd5dfb7f1ce643a7db8f9f75 src/upstream.rs +fe9d4deb34cea2408ac5a1030315989f29a6a7d078a46ac1b59d12aa07f67878 src/upstream/client.rs +eb513923f8951c8bf30bad3cbac77d34f5ebbc7af3ba69a08a25bae834f8a016 src/upstream/client_tests.rs +8c88d928b823e4b6ba6d852f74664c770d8b3fce32bc76c583dc1f672fe33f14 src/upstream/protocol_tests.rs +ae880b40197454ea3fbf4d2113984dc372098b2e54b97e87c83cc319de253377 src/upstream/tests.rs +b88cb47a009be4924bcf069b05c3e788d1eaf2b15cbdcf3099036ece89cd0d66 src/user_agent/mod.rs 6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs -3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs -6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs -3c24678359cc6e2ec94c2a0e1cc399b591ef683adab9b965633c898c6241130f src/vhost/model.rs -35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs -94bb735627f2c4a0af5c4df45bb2b41299caf37b7e6e4d0d7a860d3890eb40e0 src/vhost/tests.rs -e912d5dd09fa172032ca04d89572b845dd8f38ab5bb8598433b07629181f791d src/vhost/validation.rs +d61c427c1e9cf457f1232b822d73e846e9f329cfbe61215e7ca061d53762155d src/vhost.rs +54b0c1d2f04e5c654123c3422645eb5a0a9609141cc4a1c6addca2d028392772 src/vhost/error_fallback.rs +9d358be766f767d9e1a42f585edcf7fc1cba8f22e5eaac294a83920425ec03c7 src/vhost/model.rs +e003b935d91791f20d80c59bb46f9bb0820694dcea5d95e65364b53b016284ef src/vhost/redirect.rs +dd6e121445402a2ac8744f0e4f23d879b4356f47c1bb00737bd15ba459661978 src/vhost/tests.rs +c95cc8f4549460917633a12ccb352a00a83c0f004b23c0b9ddecba42eee3ea17 src/vhost/validation.rs 8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs 9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs -bcaa1ae15134d85668b05a88d8abea01e5d188f535ad83cd4459732fd56f2f44 src/web_server/config/model.rs +daeb5e05e4129764102b3f04e62b2dde38a8c7739bb475a13bc8c8f6d5844da1 src/web_server/config/model.rs a863ad1337fbc353c0f42caea4ac8b3644792e2aa08c37f992b7882b4b8dc6d5 src/web_server/config/validation.rs 86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs -200cba8cb3482e49db73d2c60cb60786175177c7d4be2a566cd281fec7ba7fca src/web_server/fastcgi/protocol.rs -cef44ccdf882d84892a64bdbfdc1ba01380fce5e5f86d2829b0f9613f738b964 src/web_server/fastcgi/response.rs -e15361622683268c5606a39a9e5db3938e630999767898391226e94ad2e421e8 src/web_server/filesystem.rs +cd032866a9d74d6802c2d99f2799ce971c6a8400e39d7ceaf542cac8c4ee97fc src/web_server/fastcgi/protocol.rs +e954308d94848471f6a9d70a50cd2181d17b9c41b2705a3e6c6c0e882a03733b src/web_server/fastcgi/response.rs +b98666353e4af560ab8423d4772d9ad26451dabb1c6d60f5faf547116a2bcd24 src/web_server/filesystem.rs d4fec3682532b0f2d11a0aefcd024f69fb623a39922ddb6c528a2d2de265347d src/web_server/htaccess.rs -ca3cd376b313af1ce0ee65081b3327eb213f000d5a215aa4ee6b764de9702126 src/web_server/htaccess/model.rs -76de1d11df75f4a64556b2821fd0a1774d26c9f0ca0e5f2b5fcfad0722d43d47 src/web_server/htaccess/parse.rs +66b846cce61345ea6d182db03e4f8eddc5d4a3cbe87f2046523310f1ea13a5a3 src/web_server/htaccess/model.rs +9c57b7c556984f79404d4afeb9841d0280a198b94c42d887bc6d96b7748a0e67 src/web_server/htaccess/parse.rs 66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs 8ca74d9cc4470045301f70eb4a9fbabd0dbfe74434429148d2230df9f996c06c src/web_server/htaccess/tests.rs -3341264f6e8041ac5a5d3c4b1601a69787386116019b5febc1a5e22bdb1186f9 src/web_server/metrics.rs -0dbef9b71869fc52cf01e9cb400ffd48fb433ae625383edee6c6c2d735608a76 src/web_server/mod.rs +b4a75519d9c7bb4f176126a284ea5da68fe512674200765da0984b245e4278ba src/web_server/metrics.rs +029bc2b277b2fd7528d5c9f5637453ce6d1d3ec2df7e441f6844cf55edef6b5b src/web_server/mod.rs 67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server/response.rs -833c9fea744b9649046e1a64d6fc6972fbf6cea640e1943b0db52b7ccfa9faad src/web_server/response/file.rs -ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs -c2e54fd803a10f74490f183e8c030fd0e3e72f5de4e79c55bc393ab57d90ab9a src/web_server/response/file_body/tests.rs -b35e00e4084b4de2125e9b6fff9699358b881277427bed8706fb2e8286e3cf28 src/web_server/response/headers.rs -305c0109b4f869a99381a142853f9e2205f5c7c4f904b2dfa4643274bfc542af src/web_server/routing.rs +c69502a28eec224eeded7cad93159aae3e7c136721a19f4542d5f682e055f61b src/web_server/response/file.rs +cbeb022847e64d30e9d63aac92bee00d29281b3602e1a0589f3c978cbc7c2238 src/web_server/response/file_body/mod.rs +4e91d09fa086d388e384d14b347eff8c74c010915ca7c84171e074872323e341 src/web_server/response/file_body/tests.rs +c63a5935bf8e101f1813e029dd62da7d073eb598453ee98aafe9ffa6f85157c2 src/web_server/response/headers.rs +f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server/routing.rs 400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs -c645740e5175e32a9ee743700e99a2f16b6fd894de5f734b84021b774dd747eb src/web_server/serve.rs -dc3a18c14c3cc28519103e860b0f42a59d163db15df3f68b76778cdfa6786e46 src/web_server/tests.rs +b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs +df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_picker_corner_styles.cjs 2467a6c2295d3fb2ad84cdb4a85176372953034fef2f4d95dc3bf58f019efe21 tests/acl_picker_dom.cjs 8bfee0027ce317f3427ecdf9193549d4a52db58a7b2b99db7cdeebc458d7d4c0 tests/cache_outcomes.cjs @@ -510,11 +514,7 @@ cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_pick 2e957134ae1b041ab1f1cac6149892a2704bd61a37fbe4afc0810e2911b5cedd tests/docker_vhost_actions.cjs c1b806e756a84913d0a1cd2ace0b0f94fb5f5817e0480870a65279b4d7fc5bbb tests/docker_vhost_metrics.cjs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json -1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs -347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs 930cc63183479400d01195574ec0246a482618915bc3c24b4d48a11faa286018 tests/modal_frontend_0346.cjs -eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs -7a28cfdb43a3cecf90136cf8898c060c382af557ce4ff168a6672391a0e5c26b tests/transport_config.rs 071d590439bba50b526e1b2344b5ee414210af8f4f6a318d1611640386de576f tests/ux_0345.cjs b312f9f5d5f4b3d30f935ab3f3c893fd8b8f2d571acc8198a1a89ed9ecd1f0ac tests/ux_0345_followup.cjs 67353441435c6378780e4b6c5dd8732d7160f7fbce8df9cf4791cbd6037f5840 tests/vhost_content_type_ui.cjs diff --git a/build.rs b/build.rs index b3f2f29..2d2fef1 100644 --- a/build.rs +++ b/build.rs @@ -1,9 +1,6 @@ -//! Build extensible language packs and pinned, authenticated Swagger UI assets. use serde_json::{json, Value}; use std::{collections::HashSet, env, fs, path::PathBuf, process::Command}; -// Prepare the actual embedded bytes here, not in a dependency's earlier build. -// Cargo config discovery is deliberately irrelevant to the pinned default. fn swagger_assets( root: &std::path::Path, out: &std::path::Path, diff --git a/scripts/build/swagger_assets.py b/scripts/build/swagger_assets.py index 99f848d..4d59ffa 100755 --- a/scripts/build/swagger_assets.py +++ b/scripts/build/swagger_assets.py @@ -108,8 +108,7 @@ def read_assets(path: Path, checksum: str | None) -> tuple[dict[str, bytes], str snapshot = stream.read(MAX_ARCHIVE + 1) if not 0 < len(snapshot) <= MAX_ARCHIVE: raise AssetError("Swagger archive exceeds the 64 MiB limit.") - # Parse exactly the same bounded bytes that were hashed, not a reopened file - # that could have changed between verification and ZIP member reads. + digest = hashlib.sha256(snapshot).hexdigest() if checksum and digest != checksum: raise AssetError("Swagger archive SHA-256 mismatch; no assets were published.") @@ -217,12 +216,10 @@ def main() -> int: checksum = expected_digest(os.environ.get("SWAGGER_UI_ARCHIVE_SHA256")) offline = os.environ.get("CARGO_NET_OFFLINE", "").lower() in ("1", "true") paths = prepare(args.out_dir.resolve(), source, checksum, offline=offline) - # A fresh cryptographically random build identifier, shared by all JS/CSS assets. - # Regenerated only when Cargo actually runs the build script. + import secrets atomic_write(args.out_dir.resolve() / "asset-build-id.txt", secrets.token_hex(8).encode("ascii")) for path in paths: - # These values are Cargo directives, so never allow extra output lines. if any(c in str(path) for c in "\r\n\0"): raise AssetError("Build input/output path contains an invalid control character.") print(f"cargo:rerun-if-changed={path}") diff --git a/scripts/preflight.py b/scripts/preflight.py index 937d726..e062b0d 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -12,10 +12,6 @@ LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85" def check(root: Path, repair_known: bool = False) -> list[str]: messages: list[str] = [] - - # 0.25.1 split-source migration: these monoliths are obsolete when the new - # bounded module tree is present. Preserve any local edits instead of silently - # deleting them when a source archive is extracted over an older checkout. obsolete_split_files = ( "src/admin/web/app.js", "src/admin/web/vhosts.js", @@ -40,8 +36,6 @@ def check(root: Path, repair_known: bool = False) -> list[str]: target = backup_root / relative target.parent.mkdir(parents=True, exist_ok=True) if target.exists(): - # Never overwrite a prior backup; if the bytes match, the current stale - # copy can simply be removed. Otherwise force the operator to reconcile it. if target.read_bytes() != obsolete.read_bytes(): raise ValueError( f"Backup target {target.relative_to(root)} already contains different data; " @@ -74,12 +68,9 @@ def check(root: Path, repair_known: bool = False) -> list[str]: flat = module.parent.with_suffix(".rs") if flat.is_file(): raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") - for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]: + for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]: if not (root / path).is_file(): raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}") - # The Reader uses compile-time include_str! for its bundled Markdown. A - # dangling registration is a real compiler error, unlike an omitted old - # release audit. Match both reader_doc! and the expanded include_str! form. registry = root / "src/admin/documentation/documents.rs" if not registry.is_file(): raise ValueError("Incomplete RustProxy 0.34.2 source tree: missing src/admin/documentation/documents.rs") @@ -89,8 +80,6 @@ def check(root: Path, repair_known: bool = False) -> list[str]: for doc_path in sorted(embedded_docs): if not (root / doc_path).is_file(): raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration") - # Fragments inserted by include! cannot start with inner doc comments. - # Scan all Rust includes, not just an individual admin fragment (E0753). for parent in (root / "src").rglob("*.rs"): source = parent.read_text(encoding="utf-8") for relative in re.findall(r'\binclude!\s*\(\s*"([^"\n]+\.rs)"\s*\)', source): diff --git a/src/acl.rs b/src/acl.rs index e1f68f2..ec17ab7 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -1,4 +1,3 @@ -//! Explicit network ACLs. Peer IP is default; only the central explicit trusted-proxy policy may resolve a forwarded client IP. use anyhow::{ensure, Context, Result}; use ipnet::IpNet; use serde::{Deserialize, Serialize}; @@ -76,20 +75,12 @@ impl Acl { }) } } -/// One reusable profile. Only the set of profiles attached to the same Vhost -/// is combined; local Vhost ACLs, locations and global host rules remain -/// independent restrictions. #[derive(Debug, Clone)] pub struct ProfileAccess { pub acl: Option, pub countries: crate::rate_limit::CountryAccess, } -/// Keep the single-profile rules unchanged. With multiple profiles, explicit -/// IP or country denies prevail across all profiles, explicit allows are a -/// union, and unmatched defaults allow only when every profile allows by -/// default. This avoids interpreting an unmatched default-deny as a veto of -/// another profile's explicit exception. pub fn allows_profiles_with_country( profiles: &[ProfileAccess], peer: IpAddr, @@ -120,8 +111,6 @@ pub fn allows_profiles_with_country( explicit_allow || defaults_allow } -/// The legacy singular reference remains supported. Modern clients write the -/// array only; if both exist we require both policies to avoid silent bypass. pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator { legacy.into_iter().chain(ids.iter().map(String::as_str)) } @@ -157,8 +146,6 @@ impl CompiledAcl { allowed.then_some(Action::Allow) } - /// Explicit deny beats all matching allow rules. Otherwise, allow or the default applies. - /// Host policy and route policy are both evaluated, so route allow cannot override host deny. pub fn allows(&self, peer: IpAddr) -> bool { match self.explicit_action(peer) { Some(Action::Deny) => false, @@ -168,10 +155,6 @@ impl CompiledAcl { } } -/// Evaluate the IP and country controls that belong to one access-policy scope. -/// Explicit IP denies and blocked countries always deny. In allow-only country mode, -/// a listed country or an explicit IP allow is sufficient; this lets a default-deny -/// policy express e.g. "private networks OR PL/DE" without weakening other scopes. pub fn allows_with_country( acl: Option<&CompiledAcl>, countries: &crate::rate_limit::CountryAccess, @@ -203,10 +186,6 @@ pub fn allows_with_country( } } } -/// Validate the already-decoded routing representation used internally after -/// matching/rewriting. Encoded spaces from a valid request are literal spaces -/// at this stage; reserved separators, percent and dot-segment ambiguity stay -/// forbidden. pub fn validate_decoded_routing_path(path: &str) -> Result<()> { ensure!( path.starts_with('/') && path.len() <= 8192, @@ -225,10 +204,6 @@ pub fn validate_decoded_routing_path(path: &str) -> Result<()> { Ok(()) } -/// Validate a routing path without allocating for the common already-normalized -/// form. The slow path delegates to `routing_path` to preserve percent decode, -/// UTF-8 validation, slash folding and ambiguity rejection exactly as before. -/// Callers must not use this value as the upstream URL: the raw URI is forwarded. pub fn routing_path_view(raw: &str) -> Result> { ensure!( raw.starts_with('/') && raw.len() <= 8192, @@ -251,12 +226,6 @@ pub fn routing_path_view(raw: &str) -> Result> { Ok(std::borrow::Cow::Borrowed(raw)) } -/// Routing/ACL use one decoded representation; upstream still receives the original path. -/// Reject encoded separators, percent (double decoding), controls, ambiguous dot segments, -/// matrix parameters and malformed UTF-8. Repeated literal separators are -/// collapsed for routing/ACL decisions (matching nginx's default merge_slashes -/// behavior), while the upstream request still retains its original target. -/// No silent fallback to a less-restrictive route. pub fn routing_path(raw: &str) -> Result { ensure!( raw.starts_with('/') && raw.len() <= 8192, diff --git a/src/acl/dynamic.rs b/src/acl/dynamic/mod.rs similarity index 86% rename from src/acl/dynamic.rs rename to src/acl/dynamic/mod.rs index 4e0cdb1..38d3991 100644 --- a/src/acl/dynamic.rs +++ b/src/acl/dynamic/mod.rs @@ -1,4 +1,3 @@ -//! Bounded, fail-closed DNS/DDNS ACL address cache. No resolver runs in the request path. use crate::config::Config; use anyhow::{ensure, Result}; use arc_swap::ArcSwap; @@ -36,8 +35,6 @@ pub(crate) fn matches(name: &str, ip: IpAddr) -> bool { }) } -/// Strict ASCII DNS hostnames only (IDNs can be supplied in punycode). -/// A and AAAA results are eligible. IPs and CIDRs remain separate static rules. 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"); @@ -53,8 +50,6 @@ pub(crate) fn hostname(value: &str) -> Result { Ok(candidate.to_ascii_lowercase()) } -/// Walk every ACL scope, including standalone host policies, ACL profiles, -/// inline vhost/location ACLs and Docker overrides. Limit process-wide DNS work. pub(crate) fn configured_hostnames(config: &Config) -> Result> { let mut names = BTreeSet::new(); let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> { @@ -63,7 +58,6 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result> { if rule.network.parse::().is_ok() || rule.network.parse::().is_ok() { continue; } - // A slash is always a CIDR error, never a DNS name. 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"); @@ -86,8 +80,6 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result> { } async fn resolve(name: String) -> (String, Option) { - // Keep the returned cache key owned independently of the DNS lookup future. - // The lookup result can retain the borrowed hostname until it is dropped. let lookup_name = name.clone(); let answer = tokio::time::timeout( Duration::from_secs(LOOKUP_SECONDS), @@ -129,7 +121,6 @@ pub(crate) async fn poll_loop( let snapshot = state.config.load(); let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; }; drop(snapshot); - // Resolve concurrently with hard bounds; write one immutable snapshot. let results: HashMap<_, _> = tokio::select! { _ = shutdown.cancelled() => break, result = stream::iter(names.iter().cloned()) @@ -138,7 +129,6 @@ pub(crate) async fn poll_loop( .filter_map(|(name, answer)| async move { answer.map(|value| (name, value)) }) .collect() => result, }; - // A concurrently changed configuration must not publish obsolete DNS data. let current = state.config.load(); if configured_hostnames(¤t.envelope.config).ok().as_ref() != Some(&names) { CHANGED.notify_one(); @@ -146,9 +136,8 @@ pub(crate) async fn poll_loop( } CACHE.store(Arc::new(results)); } - // Do not retain permit decisions when the resolver lifecycle stops. CACHE.store(Arc::new(HashMap::new())); } #[cfg(test)] -include!("dynamic/tests.rs"); +include!("tests.rs"); diff --git a/src/acl/dynamic/tests.rs b/src/acl/dynamic/tests.rs index bd7ade6..14f03d4 100644 --- a/src/acl/dynamic/tests.rs +++ b/src/acl/dynamic/tests.rs @@ -13,19 +13,16 @@ mod tests { let ip: IpAddr = "198.51.100.4".parse().unwrap(); let entry = Addresses { ips: vec![ip], valid_until: Instant::now() + Duration::from_secs(1) }; assert!(entry.valid_until > Instant::now()); - // The request path accepts only cached exact addresses, never a subnet. let mut cache = HashMap::new(); cache.insert(name.to_owned(), entry); CACHE.store(Arc::new(cache)); assert!(matches(name, ip)); assert!(!matches(name, "198.51.100.5".parse().unwrap())); - // A formerly resolved IP cannot remain permitted after expiration. let mut expired = HashMap::new(); expired.insert(name.to_owned(), Addresses { ips: vec![ip], valid_until: Instant::now() - Duration::from_secs(1), }); CACHE.store(Arc::new(expired)); assert!(!matches(name, ip)); - // IPv6 addresses have the same exact-match and rotation semantics. let new_ip: IpAddr = "2001:db8::8".parse().unwrap(); let mut rotated = HashMap::new(); rotated.insert(name.to_owned(), Addresses { diff --git a/src/acl/tests.rs b/src/acl/tests.rs index 8cdf46f..f5df869 100644 --- a/src/acl/tests.rs +++ b/src/acl/tests.rs @@ -360,4 +360,12 @@ mod tests { assert!(validate_profile_ids(Some("old"), &(0..8).map(|v| format!("profile{v}")).collect::>()).is_err()); } + #[test] + fn percent_encoded_spaces_are_valid_request_paths() { + assert_eq!( + routing_path("/Ubuntu%2014.04%20LTS/").unwrap(), + "/Ubuntu 14.04 LTS/" + ); + } + } diff --git a/src/acme.rs b/src/acme.rs index 7e80bd7..3a56706 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -1,10 +1,6 @@ -//! Bounded control-plane adapter for Certbot HTTP-01/DNS-01 providers. Never on traffic paths. -//! Provider credentials enter via stdin, are encrypted in Secret Store, and never -//! travel through argv, logs or API responses. - include!("acme/model.rs"); include!("acme/runtime_env.rs"); -include!("acme/providers.rs"); +include!("acme/providers/mod.rs"); include!("acme/jobs.rs"); #[cfg(test)] include!("acme/tests.rs"); diff --git a/src/acme/jobs.rs b/src/acme/jobs.rs index 872c84d..8078598 100644 --- a/src/acme/jobs.rs +++ b/src/acme/jobs.rs @@ -159,7 +159,6 @@ impl Manager { "acme.not_due" } .into(); - // A dry run must not postpone a due production renewal. if !dry_run { entry.failures = 0; entry.next_check = now + 43200 + (rand::random::() as i64 % 1800); @@ -231,8 +230,6 @@ async fn deploy_renewal( .await .map_err(|_| "acme.validation_worker_failed".to_owned())? .map_err(|_| "acme.certificate_validation_failed".to_owned())?; - // CAS retries preserve edits to other parts of the document. A simultaneous - // change of this certificate wins over the worker; never silently overwrite it. for _ in 0..3 { let snapshot = state.config.load_full(); let Some(current) = snapshot @@ -280,16 +277,10 @@ async fn deploy_renewal( } Err("acme.configuration_changed".into()) } -/// One scheduler per process, sharing the issuance semaphore. It does not run -/// its own shell hooks or stop listeners; validated snapshots rotate TCP and QUIC TLS. pub async fn renewal_loop( weak: std::sync::Weak, shutdown: tokio_util::sync::CancellationToken, ) { - // Observations are restored from SQLite before listeners are exposed. If an - // older installation has no SQLite observations, the normal scheduler will - // safely reconstruct them on its first check; certificate/key state is not - // derived from historical observation sidecars. let delay = 300 + (rand::random::() as u64 % 300); tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(delay)) => {} } loop { diff --git a/src/acme/model.rs b/src/acme/model.rs index 3912e12..fad856a 100644 --- a/src/acme/model.rs +++ b/src/acme/model.rs @@ -394,7 +394,6 @@ impl Manager { finished.message = message; } } - // Never write provider stderr, request JSON or TSIG material to audit. let _ = state .store .audit( diff --git a/src/acme/providers.rs b/src/acme/providers/mod.rs similarity index 95% rename from src/acme/providers.rs rename to src/acme/providers/mod.rs index 23fcb60..52c8bd6 100644 --- a/src/acme/providers.rs +++ b/src/acme/providers/mod.rs @@ -35,8 +35,6 @@ async fn execute( .map_err(|_| "acme.validation_worker_failed".to_owned())? } -/// A reserved validation path: only an active HTTP-01 job and its exact DNS -/// names can read bounded tokens. No origin, cache, auth or custom pages here. pub async fn challenge( axum::extract::State(state): axum::extract::State>, request: axum::extract::Request, @@ -140,10 +138,8 @@ pub async fn challenge( } #[cfg(test)] -include!("providers/tests.rs"); +include!("tests.rs"); -/// Public diagnostics contain no account/credential material. Desired renewal -/// permission lives with the certificate; observations never overwrite it. #[derive(Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct RenewalState { diff --git a/src/acme/runtime_bootstrap.rs b/src/acme/runtime_bootstrap.rs index aee53a2..3fce318 100644 --- a/src/acme/runtime_bootstrap.rs +++ b/src/acme/runtime_bootstrap.rs @@ -13,7 +13,6 @@ async fn create_virtual_environment( return false; } let _ = tokio::fs::remove_dir_all(staging).await; - // Fall back to an application-local virtualenv bootstrap. let bootstrap = staging.with_extension("virtualenv-bootstrap"); let _ = tokio::fs::remove_dir_all(&bootstrap).await; let install_bootstrap = |break_system_packages: bool| { @@ -30,30 +29,20 @@ async fn create_virtual_environment( "--timeout", "30", ]); - // Some distro pip builds enforce PEP 668 even for --target. Retrying with - // --break-system-packages is safe here because the destination is the - // private bootstrap directory; distribution site-packages are untouched. if break_system_packages { install.arg("--break-system-packages"); } - install.arg("--target").arg(&bootstrap).arg("virtualenv>=20,<21"); + install + .arg("--target") + .arg(&bootstrap) + .arg("virtualenv>=20,<21"); install }; - let installed = command_success( - install_bootstrap(false), - Duration::from_secs(300), - shutdown, - ) - .await; + let installed = + command_success(install_bootstrap(false), Duration::from_secs(300), shutdown).await; if !installed { let _ = tokio::fs::remove_dir_all(&bootstrap).await; - if !command_success( - install_bootstrap(true), - Duration::from_secs(300), - shutdown, - ) - .await - { + if !command_success(install_bootstrap(true), Duration::from_secs(300), shutdown).await { let _ = tokio::fs::remove_dir_all(&bootstrap).await; return false; } diff --git a/src/acme/runtime_env.rs b/src/acme/runtime_env.rs index fb16b48..a23d7a9 100644 --- a/src/acme/runtime_env.rs +++ b/src/acme/runtime_env.rs @@ -24,7 +24,6 @@ pub struct RuntimeStatus { pub python: bool, pub pip: bool, pub certbot: bool, - /// Versions of the executables actually selected for ACME, never a guessed system default. pub python_version: Option, pub pip_version: Option, pub certbot_version: Option, @@ -75,13 +74,14 @@ fn find_on_path(name: &str) -> Option { if candidate.components().count() > 1 { return candidate.is_file().then(|| candidate.to_owned()); } - for directory in env::var_os("PATH").into_iter().flat_map(|paths| env::split_paths(&paths).collect::>()) { + for directory in env::var_os("PATH") + .into_iter() + .flat_map(|paths| env::split_paths(&paths).collect::>()) + { let path = directory.join(name); if path.is_file() { return Some(path); } - // Windows PATH lookup uses PATHEXT; Path::is_file alone does not. - // Python and Certbot are installed as .exe launchers on Windows. #[cfg(windows)] if path.extension().is_none() { let executable = path.with_extension("exe"); @@ -230,7 +230,8 @@ async fn ensure_runtime(state: &App) -> Result { let managed = data_dir.join("acme-python"); let managed_directory = Some(managed.to_string_lossy().into_owned()); if managed_environment_valid(&managed, &state.shutdown).await { - let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed))); + let versions = + runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed))); state.acme.set_runtime(RuntimeStatus { status: "ready", mode: "managed", @@ -274,7 +275,9 @@ async fn ensure_runtime(state: &App) -> Result { let system_versions = runtime_versions( &python, system_pip, - distro_certbot.as_deref().filter(|_| distro_certbot_available), + distro_certbot + .as_deref() + .filter(|_| distro_certbot_available), ); state.acme.set_runtime(RuntimeStatus { status: "initializing", @@ -291,7 +294,8 @@ async fn ensure_runtime(state: &App) -> Result { let build = build_managed_environment(&python, &managed, system_pip, &state.shutdown).await; if build.is_ok() { tracing::info!(path = %managed.display(), "managed ACME runtime ready"); - let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed))); + let versions = + runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed))); state.acme.set_runtime(RuntimeStatus { status: "ready", mode: "managed", @@ -383,7 +387,6 @@ impl Drop for ProcessGroup { fn drop(&mut self) { #[cfg(unix)] if let Some(id) = self.0 { - // SAFETY: this process group belongs only to the managed child. unsafe { libc::kill(-(id as i32), libc::SIGKILL); } diff --git a/src/acme/runtime_versions.rs b/src/acme/runtime_versions.rs index b1239f2..62bf164 100644 --- a/src/acme/runtime_versions.rs +++ b/src/acme/runtime_versions.rs @@ -1,7 +1,3 @@ -// Version information is diagnostic, not a health check. Read package metadata -// for the *selected* runtime without launching Python, pip, Certbot or a shell. -// A missing/ambiguous version stays None; do not guess an interpreter patch -// version from an executable name or report a hard-coded Certbot pin. use std::io::Read; const MAX_VERSION_METADATA_BYTES: u64 = 16 * 1024; @@ -21,7 +17,10 @@ fn metadata_text(path: &Path) -> Option { fn safe_version(raw: &str) -> Option { let version = raw.trim(); (version.len() <= 48 - && version.bytes().next().is_some_and(|byte| byte.is_ascii_digit()) + && version + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_digit()) && version .bytes() .all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte))) @@ -41,8 +40,6 @@ fn python_environment_root(executable: &Path) -> Option { fn interpreter_version(root: &Path) -> Option { let content = metadata_text(&root.join("pyvenv.cfg"))?; - // CPython venv writes `version = X.Y.Z`. `version_info` from virtualenv - // is a fallback when its standard `version` field is not present. for key in ["version", "version_info"] { for line in content.lines() { if let Some((name, value)) = line.split_once('=') { @@ -61,20 +58,20 @@ fn python_minor(version: &str) -> Option { let mut parts = version.split('.'); let major = parts.next()?; let minor = parts.next()?; - (major.bytes().all(|b| b.is_ascii_digit()) - && minor.bytes().all(|b| b.is_ascii_digit())) - .then(|| format!("python{major}.{minor}")) + (major.bytes().all(|b| b.is_ascii_digit()) && minor.bytes().all(|b| b.is_ascii_digit())) + .then(|| format!("python{major}.{minor}")) } fn package_sites(root: &Path, python_version: Option<&str>) -> Vec { - let mut sites = vec![root.join("Lib/site-packages"), root.join("lib/python3/dist-packages")]; + let mut sites = vec![ + root.join("Lib/site-packages"), + root.join("lib/python3/dist-packages"), + ]; for base in [root.join("lib"), root.join("lib64")] { if let Some(minor) = python_version.and_then(python_minor) { sites.push(base.join(&minor).join("site-packages")); sites.push(base.join(&minor).join("dist-packages")); } else if let Ok(entries) = std::fs::read_dir(&base) { - // Python installations with no pyvenv.cfg may contain multiple - // interpreter versions. Never choose between conflicting results. for entry in entries.take(MAX_SITE_DIRECTORIES).flatten() { let path = entry.path(); if entry.file_name().to_string_lossy().starts_with("python3.") && path.is_dir() { @@ -91,14 +88,26 @@ fn package_version(root: &Path, package: &str, python_version: Option<&str>) -> let prefix = format!("{package}-"); let mut result: Option = None; for site in package_sites(root, python_version) { - let Ok(entries) = std::fs::read_dir(site) else { continue }; + let Ok(entries) = std::fs::read_dir(site) else { + continue; + }; for entry in entries.take(MAX_PACKAGES_PER_SITE).flatten() { let name = entry.file_name(); - let Some(dir_name) = name.to_str() else { continue }; - let Some(suffix) = dir_name.strip_prefix(prefix.as_str()) - .and_then(|name| name.strip_suffix(".dist-info")) else { continue }; - let Some(directory_version) = safe_version(suffix) else { continue }; - let Some(content) = metadata_text(&entry.path().join("METADATA")) else { continue }; + let Some(dir_name) = name.to_str() else { + continue; + }; + let Some(suffix) = dir_name + .strip_prefix(prefix.as_str()) + .and_then(|name| name.strip_suffix(".dist-info")) + else { + continue; + }; + let Some(directory_version) = safe_version(suffix) else { + continue; + }; + let Some(content) = metadata_text(&entry.path().join("METADATA")) else { + continue; + }; let mut reported_name = None; let mut reported_version = None; for line in content.lines() { @@ -107,13 +116,19 @@ fn package_version(root: &Path, package: &str, python_version: Option<&str>) -> } else if let Some(value) = line.strip_prefix("Version: ") { reported_version = safe_version(value); } - if reported_name.is_some() && reported_version.is_some() { break; } + if reported_name.is_some() && reported_version.is_some() { + break; + } } if !reported_name.is_some_and(|name| name.eq_ignore_ascii_case(package)) - || reported_version.as_deref() != Some(directory_version.as_str()) { + || reported_version.as_deref() != Some(directory_version.as_str()) + { continue; } - if result.as_ref().is_some_and(|previous| previous != &directory_version) { + if result + .as_ref() + .is_some_and(|previous| previous != &directory_version) + { return None; } result = Some(directory_version); @@ -133,7 +148,8 @@ fn runtime_versions(python: &Path, has_pip: bool, certbot: Option<&Path>) -> Run let root = python_environment_root(python); let python_version = root.as_deref().and_then(interpreter_version); let pip_version = if has_pip { - root.as_deref().and_then(|root| package_version(root, "pip", python_version.as_deref())) + root.as_deref() + .and_then(|root| package_version(root, "pip", python_version.as_deref())) } else { None }; @@ -146,6 +162,9 @@ fn runtime_versions(python: &Path, has_pip: bool, certbot: Option<&Path>) -> Run }; package_version(&certbot_root, "certbot", certbot_python_version) }); - RuntimeVersions { python: python_version, pip: pip_version, certbot: certbot_version } + RuntimeVersions { + python: python_version, + pip: pip_version, + certbot: certbot_version, + } } - diff --git a/src/acme/tests.rs b/src/acme/tests.rs index 401f03c..4abf396 100644 --- a/src/acme/tests.rs +++ b/src/acme/tests.rs @@ -111,7 +111,6 @@ mod renewal_regressions { "acme.configuration_changed" ); assert_eq!(app.config.load().envelope.revision, 0); - // Explicit Check now remains allowed when scheduled checks are disabled. deploy_renewal(&app, &original, &output, false) .await .unwrap(); @@ -147,7 +146,6 @@ mod renewal_regressions { } } - mod runtime_metadata_tests { use super::*; @@ -166,13 +164,22 @@ mod runtime_metadata_tests { std::fs::create_dir_all(venv_bin(root)).unwrap(); std::fs::write(&python, "").unwrap(); std::fs::write(&certbot, "").unwrap(); - std::fs::write(root.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.13.5\n").unwrap(); + std::fs::write( + root.join("pyvenv.cfg"), + "home = /usr/bin\nversion = 3.13.5\n", + ) + .unwrap(); let site = if cfg!(windows) { root.join("Lib/site-packages") } else { root.join("lib/python3.13/site-packages") }; - write_package(&site, "pip", "25.2", "Metadata-Version: 2.3\nName: pip\nVersion: 25.2\n"); + write_package( + &site, + "pip", + "25.2", + "Metadata-Version: 2.3\nName: pip\nVersion: 25.2\n", + ); write_package(&site, "certbot", "5.8.0", "Name: certbot\nVersion: 5.8.0\n"); let versions = runtime_versions(&python, true, Some(&certbot)); assert_eq!(versions.python.as_deref(), Some("3.13.5")); @@ -202,9 +209,18 @@ mod runtime_metadata_tests { let executable = root.join("bin/python3"); std::fs::create_dir_all(root.join("bin")).unwrap(); std::fs::write(&executable, "").unwrap(); - write_package(&root.join("lib/python3/dist-packages"), "pip", "23.0", "Name: pip\nVersion: 23.0\n"); - write_package(&root.join("lib/python3.13/site-packages"), "pip", "25.0", "Name: pip\nVersion: 25.0\n"); + write_package( + &root.join("lib/python3/dist-packages"), + "pip", + "23.0", + "Name: pip\nVersion: 23.0\n", + ); + write_package( + &root.join("lib/python3.13/site-packages"), + "pip", + "25.0", + "Name: pip\nVersion: 25.0\n", + ); assert!(runtime_versions(&executable, true, None).pip.is_none()); } } - diff --git a/src/admin/access.rs b/src/admin/access.rs index 2310981..5d5d7cd 100644 --- a/src/admin/access.rs +++ b/src/admin/access.rs @@ -46,8 +46,6 @@ fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { } } fn unauthorized(request: &Request) -> Response { - // Only top-level documentation navigation gets a fixed, same-origin login - // redirect. API/spec/assets ALWAYS return a small 401, never a login HTML body. if (request.method() == Method::GET || request.method() == Method::HEAD) && matches!(request.uri().path(), "/docs" | "/docs/") { @@ -65,9 +63,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result { }; if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) { - // Hyper exposes :authority through the request URI for H2/H3. Browsers do - // not have to send a legacy Host field, so requiring one here rejects - // perfectly valid administration requests negotiated over ALPN h2. let authority = request.uri().authority().ok_or(())?.as_str(); if let Some(scheme) = request.uri().scheme_str() { if scheme != if tls { "https" } else { "http" } { @@ -86,9 +81,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result { return Ok(authority.to_owned()); } - // The administration listener is an origin server, never a forward proxy. - // HTTP/1.x therefore keeps the existing exact single-Host requirement and - // rejects absolute-form targets. if request.uri().scheme().is_some() || request.uri().authority().is_some() { return Err(()); } @@ -131,14 +123,7 @@ fn admin_host_allowed( !a.as_str().contains('@') && ((direct_host && port == configured_addr.port()) - // An explicit DNS allow-list describes the browser-facing - // authority. A reverse proxy is therefore free to terminate - // on 80/443 (or another external port) while forwarding to - // the private administration socket on its configured port. || explicit_allowed - // Preserve the historical direct-TLS convenience when no - // strict DNS allow-list exists; unlike the explicit list it - // remains bound to the actual administration listener port. || (dns_without_allowlist && port == configured_addr.port())) }) } @@ -229,21 +214,19 @@ fn external_admin_authority( } } -// In pinned-origin mode accept only bounded, consistent proxy-hop metadata: -// the first hop must report browser HTTPS, later internal hops may use HTTP. -// We still do NOT pick a trusted scheme from these values; the explicit pin -// defines the panel origin. A clear public HTTP or unknown value fails closed. fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool { let mut hops = 0; for value in headers.get_all("x-forwarded-proto").iter() { - let Ok(value) = value.to_str() else { return false; }; + let Ok(value) = value.to_str() else { + return false; + }; for part in value.split(',') { hops += 1; - if hops > 8 { return false; } + if hops > 8 { + return false; + } let scheme = part.trim(); - if (hops == 1 && scheme != "https") - || !matches!(scheme, "http" | "https") - { + if (hops == 1 && scheme != "https") || !matches!(scheme, "http" | "https") { return false; } } @@ -251,11 +234,6 @@ fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool { true } -/// A configured panel URL is authoritative only for an explicitly trusted -/// direct reverse-proxy peer. In this mode X-Forwarded-Proto is deliberately -/// ignored: conflicting hop values such as "https, http" are not evidence of -/// the browser scheme. The public URL comes from administrator configuration, -/// not from a user-controlled header. fn configured_admin_public_authority( state: &App, headers: &HeaderMap, @@ -267,7 +245,6 @@ fn configured_admin_public_authority( return Ok(None); }; if !trusted_admin_proxy(state, peer) { - // Never apply the pin to arbitrary direct/untrusted connections. return Ok(None); } if !admin_host_allowed( @@ -314,10 +291,6 @@ fn external_admin_scheme( } } -/// Plain HTTP requests accepted on an HTTPS administration socket are redirected -/// to the same authority/path. The TLS server only selects this router after it -/// has positively identified a non-TLS first byte, so admin content is never -/// served over this path. pub fn https_redirect_router() -> Router { Router::new().fallback(|request: Request| async move { let raw_host = request diff --git a/src/admin/api_token_access.rs b/src/admin/api_token_access.rs index 2766c14..d3c2718 100644 --- a/src/admin/api_token_access.rs +++ b/src/admin/api_token_access.rs @@ -1,5 +1,3 @@ -// Narrow method/path allowlist: tokens never inherit browser-only administration -// routes (password/session, credentials, backups, docs, preferences or SSE). fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> bool { let read = method == Method::GET && matches!( diff --git a/src/admin/api_tokens.rs b/src/admin/api_tokens.rs index e5ca3f6..0461327 100644 --- a/src/admin/api_tokens.rs +++ b/src/admin/api_tokens.rs @@ -1,5 +1,3 @@ -// General-purpose API credentials are independent of browser sessions, -// certificate deployment credentials, and the Prometheus scrape credential. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CreateApiToken { diff --git a/src/admin/certificate_api.rs b/src/admin/certificate_api.rs index dcc701b..565f2e1 100644 --- a/src/admin/certificate_api.rs +++ b/src/admin/certificate_api.rs @@ -119,8 +119,6 @@ async fn certificate_api_rotate( Ok(Json(value)) } -// Per-credential settings are intentionally separate from token rotation. -// The input cannot include token/token_hash, and every read returns only metadata. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CertificateApiEdit { @@ -134,7 +132,10 @@ async fn certificate_api_detail( Path(key_id): Path, ) -> Result, ApiError> { if !valid_certificate_api_key_id(&key_id) { - return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); + return Err(ApiError( + StatusCode::NOT_FOUND, + "certificate_api.not_found".into(), + )); } let credential = state .store @@ -152,11 +153,17 @@ async fn certificate_api_edit( Json(input): Json, ) -> Result, ApiError> { if !valid_certificate_api_key_id(&key_id) { - return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); + return Err(ApiError( + StatusCode::NOT_FOUND, + "certificate_api.not_found".into(), + )); } let comment = input.comment.trim(); if !valid_certificate_api_comment(comment) { - return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "certificate_api.invalid_comment".into())); + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "certificate_api.invalid_comment".into(), + )); } let mut certificate_ids: Vec = input .certificate_ids @@ -168,7 +175,9 @@ async fn certificate_api_edit( certificate_ids.dedup(); if certificate_ids.is_empty() || certificate_ids.len() > 32 - || certificate_ids.iter().any(|value| value == "*" || !crate::config::valid_id(value)) + || certificate_ids + .iter() + .any(|value| value == "*" || !crate::config::valid_id(value)) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, @@ -178,7 +187,11 @@ async fn certificate_api_edit( let credential = state .store .edit_certificate_api_credential( - &key_id, comment, input.allow_deploy, &certificate_ids, &session.username, + &key_id, + comment, + input.allow_deploy, + &certificate_ids, + &session.username, ) .await .map_err(auth::internal)? @@ -192,7 +205,10 @@ async fn certificate_api_regenerate( Path(key_id): Path, ) -> Result, ApiError> { if !valid_certificate_api_key_id(&key_id) { - return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); + return Err(ApiError( + StatusCode::NOT_FOUND, + "certificate_api.not_found".into(), + )); } let token = auth::random_secret(); let hash = auth::secret_hash(&token); @@ -202,7 +218,6 @@ async fn certificate_api_regenerate( .await .map_err(auth::internal)? .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?; - // The only endpoint in this flow that returns the new bearer secret. Ok(Json(json!({ "credential": credential, "token": token }))) } @@ -242,4 +257,3 @@ async fn certificate_api_revoke_all( .map_err(auth::internal)?; Ok(Json(json!({"configured": false, "revoked": revoked}))) } - diff --git a/src/admin/certificates.rs b/src/admin/certificates.rs index 6d4e40c..8500eb7 100644 --- a/src/admin/certificates.rs +++ b/src/admin/certificates.rs @@ -24,7 +24,6 @@ async fn certificate_deploy( )); } } - // Check the credential scope before disclosing certificate storage state. let directory = state.policy.cert_dir.clone().ok_or_else(|| { ApiError( StatusCode::CONFLICT, @@ -106,8 +105,6 @@ async fn certificate_deploy( let key_file = config.tls.certificates[index].key_file.clone(); if config.tls.certificates[index].covered_hosts == covered_hosts { - // A no-op does not pass through update_as(), so explicitly retain - // the same optimistic-concurrency guarantee as a real publish. if state.config.load().envelope.revision != expected_revision { return Err(ApiError( StatusCode::CONFLICT, @@ -423,7 +420,6 @@ async fn basic_hash( ) -> Result, ApiError> { crate::basic_auth::validate_username(&input.username) .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?; - // The semaphore is held by the blocking worker even if the HTTP client disconnects. let permit = state .basic_workers .clone() diff --git a/src/admin/dashboard_presets.rs b/src/admin/dashboard_presets.rs index e10cffe..379989a 100644 --- a/src/admin/dashboard_presets.rs +++ b/src/admin/dashboard_presets.rs @@ -1,4 +1,3 @@ -/// Account-scoped snapshots of Overview order, visibility, widths and display. use std::collections::BTreeMap; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -63,8 +62,6 @@ async fn dashboard_presets( tracing::warn!("skipped invalid saved Overview preset"); continue; }; - // Compatibility with future/retired widgets: use the same registry - // reconciliation logic as ordinary saved administrator preferences. let mut prefs = AdminPreferences::default(); prefs.dashboard_widgets = layout.dashboard_widgets; prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets; diff --git a/src/admin/documentation.rs b/src/admin/documentation.rs index 20fb542..86be8d8 100644 --- a/src/admin/documentation.rs +++ b/src/admin/documentation.rs @@ -1,5 +1,3 @@ -//! Authenticated documentation Reader and OpenAPI/Swagger UI on the admin listener. -//! Documentation and vendor assets are embedded at compile time; no runtime filesystem or CDN access. use axum::{ response::{Html, IntoResponse, Response}, Json, @@ -8,9 +6,6 @@ use http::StatusCode; use serde_json::{json, Value}; use std::sync::OnceLock; -// Keep Reader input independent from build-script output. `include_str!` makes every -// document a direct compiler input, so a clean or partially removed target directory -// cannot leave the crate pointing at a missing OUT_DIR artifact. mod documents; use documents::READER_DOCUMENTS; @@ -18,7 +13,9 @@ pub(super) async fn index() -> impl IntoResponse { Html(include_str!("web/docs.html").replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())) } -pub(super) async fn fingerprinted(axum::extract::Path(name): axum::extract::Path) -> Response { +pub(super) async fn fingerprinted( + axum::extract::Path(name): axum::extract::Path, +) -> Response { let full = format!("/docs/{name}"); match super::fingerprinted_path(&full, "/docs/").as_deref() { Some("/docs/init.js") => script().await.into_response(), @@ -93,8 +90,6 @@ pub(super) async fn schema() -> Json { } pub(super) fn asset(name: &'static str) -> Response { - // Bytes are embedded for debug AND release, and always served through the - // authenticated router. Never read a file or fetch a CDN on an HTTP request. let (content_type, bytes): (&'static str, &'static [u8]) = match name { "swagger-ui-bundle.js" => ( "text/javascript; charset=utf-8", diff --git a/src/admin/documentation/documents.rs b/src/admin/documentation/documents.rs index 1faefcf..1881b33 100644 --- a/src/admin/documentation/documents.rs +++ b/src/admin/documentation/documents.rs @@ -1,6 +1,3 @@ -//! Embedded current-version Markdown guides for the authenticated documentation Reader. -//! Keep this list in sync with docs/README.md. The embedded files must exist at build time. - macro_rules! reader_doc { ($path:literal, $category:literal) => { ( diff --git a/src/admin/file_logs_io.rs b/src/admin/file_logs_io.rs index a34b622..29b64d5 100644 --- a/src/admin/file_logs_io.rs +++ b/src/admin/file_logs_io.rs @@ -1,12 +1,11 @@ -// Bound candidate retention while scanning potentially enormous rotation folders. -// The heap's top is the least desirable result: oldest timestamp, then -// lexicographically last name. Final output preserves newest/name order. fn newest_log_files( candidates: impl Iterator, limit: usize, ) -> Vec<(String, std::path::PathBuf, u64)> { use std::{cmp::Reverse, collections::BinaryHeap}; - if limit == 0 { return Vec::new(); } + if limit == 0 { + return Vec::new(); + } let mut bounded = BinaryHeap::with_capacity(limit); for (name, path, modified_ms) in candidates { let item = (Reverse(modified_ms), name, path); @@ -17,14 +16,14 @@ fn newest_log_files( bounded.push(item); } } - let mut result = bounded.into_iter() + let mut result = bounded + .into_iter() .map(|(Reverse(modified), name, path)| (name, path, modified)) .collect::>(); result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); result } -// Bounded file-log inventory helpers and gzip tail reader. fn rotated_gzip_sources( active: &std::path::Path, id_prefix: &str, @@ -33,25 +32,52 @@ fn rotated_gzip_sources( enabled: bool, format: &'static str, ) -> Vec { - let Some(parent) = active.parent() else { return Vec::new(); }; - let Some(base) = active.file_name().and_then(|name| name.to_str()) else { return Vec::new(); }; - let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink()); - if !safe { return Vec::new(); } + let Some(parent) = active.parent() else { + return Vec::new(); + }; + let Some(base) = active.file_name().and_then(|name| name.to_str()) else { + return Vec::new(); + }; + let safe = std::fs::symlink_metadata(parent) + .ok() + .is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink()); + if !safe { + return Vec::new(); + } let prefix = format!("{base}."); - let entries = newest_log_files(std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| { - let name = entry.file_name().to_string_lossy().into_owned(); - if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; } - let file_type = entry.file_type().ok()?; - if !file_type.is_file() || file_type.is_symlink() { return None; } - let meta = entry.metadata().ok()?; - Some((name, entry.path(), file_modified_ms(&meta))) - }), 64); - entries.into_iter().map(|(name,path,_)| source_from_path( - format!("{id_prefix}:gz:{name}"), - format!("{label_prefix} · {name}"), - id_prefix.to_owned(), - kind, path, enabled, format - )).collect() + let entries = newest_log_files( + std::fs::read_dir(parent) + .ok() + .into_iter() + .flat_map(|iter| iter.filter_map(|entry| entry.ok())) + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { + return None; + } + let file_type = entry.file_type().ok()?; + if !file_type.is_file() || file_type.is_symlink() { + return None; + } + let meta = entry.metadata().ok()?; + Some((name, entry.path(), file_modified_ms(&meta))) + }), + 64, + ); + entries + .into_iter() + .map(|(name, path, _)| { + source_from_path( + format!("{id_prefix}:gz:{name}"), + format!("{label_prefix} · {name}"), + id_prefix.to_owned(), + kind, + path, + enabled, + format, + ) + }) + .collect() } fn open_file_log(path: &std::path::Path) -> std::io::Result { @@ -81,8 +107,6 @@ fn read_tail( let mut file = open_file_log(&source.path)?; let length = file.metadata()?.len(); if source.compressed { - // gzip streams are not seekable. Read a complete bounded rotation, then - // keep only the same 2 MiB decompressed tail used for plain files. if length > budget || length > FILE_LOG_GZIP_INPUT_MAX { return Ok((Vec::new(), length.min(budget), true)); } @@ -108,7 +132,11 @@ fn read_tail( } let text = String::from_utf8_lossy(&content); let mut iter = text.lines().rev(); - let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::>(); + let lines = iter + .by_ref() + .take(line_budget) + .map(str::to_owned) + .collect::>(); return Ok((lines, length, truncated || iter.next().is_some())); } let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE); @@ -126,6 +154,10 @@ fn read_tail( } let text = String::from_utf8_lossy(&content); let mut iter = text.lines().rev(); - let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::>(); + let lines = iter + .by_ref() + .take(line_budget) + .map(str::to_owned) + .collect::>(); Ok((lines, bytes, truncated || iter.next().is_some())) } diff --git a/src/admin/file_logs_query.rs b/src/admin/file_logs_query.rs index 7e7cd3d..dbd3e4c 100644 --- a/src/admin/file_logs_query.rs +++ b/src/admin/file_logs_query.rs @@ -50,8 +50,6 @@ fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchCl true } -// Order the entire bounded matching scan before applying offset/limit. Sorting -// only the current browser page would give misleading navigation results. #[derive(Debug)] enum FileLogSortKey { Number(serde_json::Number), @@ -59,29 +57,43 @@ enum FileLogSortKey { } fn stored_log_summary(value: &Value) -> String { - let field = |name| value.get(name).and_then(Value::as_str).filter(|text| !text.is_empty()); + let field = |name| { + value + .get(name) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + }; if let Some(reason) = field("reason").or_else(|| field("protocol_error")) { return reason.to_owned(); } if field("method").is_some() || field("path").is_some() { - return format!("{} {}{}", field("method").unwrap_or(""), + return format!( + "{} {}{}", + field("method").unwrap_or(""), field("host").or_else(|| field("authority")).unwrap_or(""), - field("path").unwrap_or("")).trim().to_owned(); + field("path").unwrap_or("") + ) + .trim() + .to_owned(); } for key in ["error", "error_detail", "message", "raw", "outcome"] { - if let Some(text) = field(key) { return text.to_owned(); } + if let Some(text) = field(key) { + return text.to_owned(); + } } String::new() } fn stored_log_sort_key(record: &Value, field: &str) -> Option { if field == "record" { - return Some(FileLogSortKey::Text(stored_log_summary(record).to_lowercase())); + return Some(FileLogSortKey::Text( + stored_log_summary(record).to_lowercase(), + )); } - // Prefer millisecond timestamps for numeric chronological sorting. RAW - // file timestamps use the existing ISO/text fallback. let value = if field == "time" { - record.get("time_ms").filter(|value| value.is_number()) + record + .get("time_ms") + .filter(|value| value.is_number()) .or_else(|| record.get("timestamp")) } else { file_log_field(record, field) @@ -91,39 +103,51 @@ fn stored_log_sort_key(record: &Value, field: &str) -> Option { Value::Number(number) => Some(FileLogSortKey::Number(number.clone())), Value::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())), Value::Bool(boolean) => Some(FileLogSortKey::Text(boolean.to_string())), - Value::Array(_) | Value::Object(_) => Some(FileLogSortKey::Text(value.to_string().to_lowercase())), + Value::Array(_) | Value::Object(_) => { + Some(FileLogSortKey::Text(value.to_string().to_lowercase())) + } _ => None, } } fn file_log_timestamp_cmp_desc(left: &Value, right: &Value) -> std::cmp::Ordering { - match (left.get("time_ms").and_then(Value::as_u64), right.get("time_ms").and_then(Value::as_u64)) { + match ( + left.get("time_ms").and_then(Value::as_u64), + right.get("time_ms").and_then(Value::as_u64), + ) { (Some(a), Some(b)) => b.cmp(&a), - _ => right.get("timestamp").and_then(Value::as_str).unwrap_or("") + _ => right + .get("timestamp") + .and_then(Value::as_str) + .unwrap_or("") .cmp(left.get("timestamp").and_then(Value::as_str).unwrap_or("")), } } fn sort_file_log_records(records: &mut Vec, field: &str, descending: bool) { use std::cmp::Ordering; - // Build one key per record; do not repeatedly allocate/parse strings in - // the O(n log n) comparison loop. The scan itself is limited to 20k lines. - let mut keyed = std::mem::take(records).into_iter().enumerate() + let mut keyed = std::mem::take(records) + .into_iter() + .enumerate() .map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record)) .collect::>(); keyed.sort_by(|left, right| { let order = match (&left.0, &right.0) { (None, None) => Ordering::Equal, - (None, Some(_)) => return Ordering::Greater, // missing values always last + (None, Some(_)) => return Ordering::Greater, (Some(_), None) => return Ordering::Less, - (Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) => + (Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) => { match (a.as_i64(), b.as_i64()) { (Some(left), Some(right)) => left.cmp(&right), _ => match (a.as_u64(), b.as_u64()) { (Some(left), Some(right)) => left.cmp(&right), - _ => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(Ordering::Equal), + _ => a + .as_f64() + .partial_cmp(&b.as_f64()) + .unwrap_or(Ordering::Equal), }, - }, + } + } (Some(FileLogSortKey::Text(a)), Some(FileLogSortKey::Text(b))) => a.cmp(b), (Some(FileLogSortKey::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less, (Some(FileLogSortKey::Text(_)), Some(FileLogSortKey::Number(_))) => Ordering::Greater, @@ -145,10 +169,20 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> { || !text_ok(&query.q, 512) || !text_ok(&query.client_ip, 128) || !text_ok(&query.vhost, 256) - || query.aggregate_field.as_ref().is_some_and(|field| !valid_raw_field_key(field)) - || query.sort_by.as_ref().is_some_and(|field| !valid_raw_field_key(field)) - || query.aggregate_limit.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS) - || query.aggregate_min_count.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES) + || query + .aggregate_field + .as_ref() + .is_some_and(|field| !valid_raw_field_key(field)) + || query + .sort_by + .as_ref() + .is_some_and(|field| !valid_raw_field_key(field)) + || query + .aggregate_limit + .is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS) + || query + .aggregate_min_count + .is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES) || query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET || query .limit @@ -187,8 +221,11 @@ async fn file_logs_query( let requested = query.source.as_deref().unwrap_or("all"); let mut truncated = false; if requested != "all" { - let consolidate_vhosts = query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:"); - sources.retain(|source| source.id == requested || (consolidate_vhosts && source.group_id == requested)); + let consolidate_vhosts = + query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:"); + sources.retain(|source| { + source.id == requested || (consolidate_vhosts && source.group_id == requested) + }); if sources.is_empty() { return Err(ApiError( StatusCode::BAD_REQUEST, @@ -210,9 +247,15 @@ async fn file_logs_query( let mut scanned_lines = 0usize; let mut records = Vec::new(); let mut fields = std::collections::BTreeSet::new(); - let aggregate_field = query.aggregate_field.as_deref().map(str::to_ascii_lowercase); + let aggregate_field = query + .aggregate_field + .as_deref() + .map(str::to_ascii_lowercase); let aggregate_min_count = query.aggregate_min_count.unwrap_or(2); - let aggregate_limit = query.aggregate_limit.unwrap_or(50).clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS); + let aggregate_limit = query + .aggregate_limit + .unwrap_or(50) + .clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS); let mut aggregate_counts = std::collections::HashMap::::new(); let mut aggregate_truncated = false; for (index, source) in sources.iter().enumerate() { @@ -241,8 +284,13 @@ async fn file_logs_query( let mut values = Vec::new(); file_log_aggregate_values(field_value, &mut values); for aggregate_value in values { - if aggregate_counts.contains_key(&aggregate_value) || aggregate_counts.len() < FILE_LOG_MAX_AGGREGATE_VALUES { - *aggregate_counts.entry(aggregate_value).or_insert(0) += 1; + if aggregate_counts.contains_key(&aggregate_value) + || aggregate_counts.len() + < FILE_LOG_MAX_AGGREGATE_VALUES + { + *aggregate_counts + .entry(aggregate_value) + .or_insert(0) += 1; } else { aggregate_truncated = true; } @@ -257,9 +305,13 @@ async fn file_logs_query( Err(_) => truncated = true, } } - sort_file_log_records(&mut records, query.sort_by.as_deref().unwrap_or("time"), - query.sort_descending.unwrap_or(true)); - let mut aggregation = aggregate_counts.into_iter() + sort_file_log_records( + &mut records, + query.sort_by.as_deref().unwrap_or("time"), + query.sort_descending.unwrap_or(true), + ); + let mut aggregation = aggregate_counts + .into_iter() .filter(|(_, count)| *count >= aggregate_min_count) .map(|(value, count)| json!({"value": value, "count": count})) .collect::>(); @@ -267,7 +319,9 @@ async fn file_logs_query( let left_count = left.get("count").and_then(Value::as_u64).unwrap_or(0); let right_count = right.get("count").and_then(Value::as_u64).unwrap_or(0); right_count.cmp(&left_count).then_with(|| { - left.get("value").and_then(Value::as_str).unwrap_or("") + left.get("value") + .and_then(Value::as_str) + .unwrap_or("") .cmp(right.get("value").and_then(Value::as_str).unwrap_or("")) }) }); diff --git a/src/admin/logging_api.rs b/src/admin/logging_api.rs index fa4c1ce..afa7d29 100644 --- a/src/admin/logging_api.rs +++ b/src/admin/logging_api.rs @@ -52,8 +52,6 @@ async fn update_section( config.logging = part.logging; config.error_log = part.error_log; } else { - // Backward compatibility for clients that still send only the - // historical access-logging object to /api/config/logging. config.logging = serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?; } @@ -334,8 +332,6 @@ async fn events( "events.too_many_viewers".into(), ) })?; - // One bounded snapshot, produced only when the consumer polls. No per-client queue. - // Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions. let stream = futures_util::stream::unfold( (state, secret, permit, false, interval), |(state, secret, permit, delayed, interval)| async move { diff --git a/src/admin/mod.rs b/src/admin/mod.rs index f083415..f92a09e 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,5 +1,3 @@ -//! Local control plane. Never mounted on the public proxy listener. - include!("router.rs"); include!("access.rs"); include!("api_token_access.rs"); diff --git a/src/admin/operations.rs b/src/admin/operations.rs index 650dfa7..2d61d04 100644 --- a/src/admin/operations.rs +++ b/src/admin/operations.rs @@ -79,8 +79,16 @@ async fn cache_purge( )); } } - let permit = state.cache_invalidation_slots.clone().try_acquire_owned() - .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?; + let permit = state + .cache_invalidation_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + ApiError( + StatusCode::TOO_MANY_REQUESTS, + "cache maintenance busy".into(), + ) + })?; let detail = format!( "scope={}; in-flight fills invalidated", scope.vhost.as_deref().unwrap_or("all") @@ -98,7 +106,9 @@ async fn cache_purge( Some(id) => cache.purge_scope(id), None => cache.purge(None), } - }).await.map_err(auth::internal)?; + }) + .await + .map_err(auth::internal)?; Ok(Json( json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}), )) @@ -126,8 +136,16 @@ async fn cache_invalidate( )); } } - let permit = state.cache_invalidation_slots.clone().try_acquire_owned() - .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?; + let permit = state + .cache_invalidation_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + ApiError( + StatusCode::TOO_MANY_REQUESTS, + "cache maintenance busy".into(), + ) + })?; let detail = format!( "operation={:?}; selector={:?}; vhost={}; host={}; value={:?}", request.operation, @@ -145,8 +163,10 @@ async fn cache_invalidate( let result = tokio::task::spawn_blocking(move || { let _permit = permit; cache.invalidate(request) - }).await.map_err(auth::internal)? - .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; + }) + .await + .map_err(auth::internal)? + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; Ok(Json(json!({ "result": result, "metrics": state.cache.view(), @@ -163,8 +183,6 @@ async fn cache_ban_delete( Extension(session): Extension, Path(id): Path, ) -> Result, ApiError> { - // This is a destructive operation: fail closed if the audit store is - // unavailable, rather than removing a ban and only then trying to log it. if !state.cache.bans().iter().any(|rule| rule.id == id) { return Err(ApiError( StatusCode::NOT_FOUND, @@ -181,7 +199,10 @@ async fn cache_ban_delete( .await .map_err(auth::internal)?; if !state.cache.remove_ban(id) { - return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into())); + return Err(ApiError( + StatusCode::NOT_FOUND, + "unknown cache BAN rule".into(), + )); } Ok(Json(json!({"removed": id, "bans": state.cache.bans()}))) } @@ -322,7 +343,6 @@ async fn certificate_upload( .audit(&session.username, "certificate.upload", &certificate.id) .await .map_err(auth::internal)?; - // Never serialize the PEM request or any key material, including on success. Ok(Json(json!({"certificate":certificate, "staged":true}))) } diff --git a/src/admin/panel_assets.rs b/src/admin/panel_assets.rs index 5d56b0c..00c320a 100644 --- a/src/admin/panel_assets.rs +++ b/src/admin/panel_assets.rs @@ -1,10 +1,14 @@ -// Fixed private admin assets, shared by all admins. The existing route/access -// allowlist decides which paths are reachable. Do not expand routes dynamically. use super::panel_content::PanelContent; use crate::runtime::App; -use axum::{extract::{Request, State}, response::{IntoResponse, Response}}; +use axum::{ + extract::{Request, State}, + response::{IntoResponse, Response}, +}; use http::StatusCode; -use std::{collections::HashMap, sync::{Arc, LazyLock, OnceLock}}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock, OnceLock}, +}; struct Asset { mime: &'static str, @@ -20,21 +24,57 @@ const SVG: &str = "image/svg+xml"; static ASSETS: LazyLock> = LazyLock::new(|| { let entries: [(&'static str, &'static str, &'static str); 22] = [ ("/assets/login.js", JS, include_str!("web/login.js")), - ("/assets/login-langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/login-langs.json"))), - ("/assets/langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/langs.json"))), + ( + "/assets/login-langs.json", + JSON, + include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")), + ), + ( + "/assets/langs.json", + JSON, + include_str!(concat!(env!("OUT_DIR"), "/langs.json")), + ), ("/assets/app.js", JS, super::admin_app_javascript()), - ("/assets/management.js", JS, include_str!("web/management.js")), + ( + "/assets/management.js", + JS, + include_str!("web/management.js"), + ), ("/assets/drafts.js", JS, include_str!("web/drafts.js")), - ("/assets/integrations.js", JS, include_str!("web/integrations.js")), - ("/assets/traffic-panel.js", JS, include_str!("web/traffic-panel.js")), - ("/assets/cache-panel.js", JS, include_str!("web/cache-panel.js")), - ("/assets/transform-panel.js", JS, include_str!("web/transform-panel.js")), + ( + "/assets/integrations.js", + JS, + include_str!("web/integrations.js"), + ), + ( + "/assets/traffic-panel.js", + JS, + include_str!("web/traffic-panel.js"), + ), + ( + "/assets/cache-panel.js", + JS, + include_str!("web/cache-panel.js"), + ), + ( + "/assets/transform-panel.js", + JS, + include_str!("web/transform-panel.js"), + ), ("/assets/file-logs.js", JS, include_str!("web/file-logs.js")), ("/assets/settings.js", JS, include_str!("web/settings.js")), ("/assets/acme.js", JS, include_str!("web/acme.js")), ("/assets/vhosts.js", JS, super::admin_vhosts_javascript()), - ("/assets/web-server-profiles.js", JS, include_str!("web/web-server-profiles.js")), - ("/assets/web-server.js", JS, include_str!("web/web-server.js")), + ( + "/assets/web-server-profiles.js", + JS, + include_str!("web/web-server-profiles.js"), + ), + ( + "/assets/web-server.js", + JS, + include_str!("web/web-server.js"), + ), ("/assets/docker.js", JS, include_str!("web/docker.js")), ("/assets/streams.js", JS, include_str!("web/streams.js")), ("/assets/redirects.js", JS, include_str!("web/redirects.js")), @@ -42,7 +82,18 @@ static ASSETS: LazyLock> = LazyLock::new(|| { ("/assets/style.css", CSS, include_str!("web/style.css")), ("/favicon.svg", SVG, include_str!("web/favicon.svg")), ]; - entries.into_iter().map(|(path, mime, raw)| (path, Asset { mime, raw, content: OnceLock::new() })) + entries + .into_iter() + .map(|(path, mime, raw)| { + ( + path, + Asset { + mime, + raw, + content: OnceLock::new(), + }, + ) + }) .collect() }); @@ -50,7 +101,13 @@ pub(super) async fn serve(State(state): State>, request: Request) -> Re let resolved = super::fingerprinted_path(request.uri().path(), "/assets/"); let path = match resolved.as_deref() { Some(path) => path, - None if matches!(request.uri().path(), "/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg") => request.uri().path(), + None if matches!( + request.uri().path(), + "/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg" + ) => + { + request.uri().path() + } None => return StatusCode::NOT_FOUND.into_response(), }; let Some(asset) = ASSETS.get(path) else { @@ -58,6 +115,8 @@ pub(super) async fn serve(State(state): State>, request: Request) -> Re }; let snapshot = state.config.load(); let codecs = &snapshot.envelope.config.administration.panel_compression; - asset.content.get_or_init(|| PanelContent::new(asset.raw)) + asset + .content + .get_or_init(|| PanelContent::new(asset.raw)) .response(request.headers(), codecs, asset.mime) } diff --git a/src/admin/panel_content.rs b/src/admin/panel_content.rs index ab0a4b4..5443b9a 100644 --- a/src/admin/panel_content.rs +++ b/src/admin/panel_content.rs @@ -1,5 +1,3 @@ -// Shared immutable precompressed responses for private panel HTML and assets. -// Each embedded document is encoded once on first request, never per visitor. use crate::settings::PanelCompression; use axum::{body::Body, response::Response}; use bytes::Bytes; @@ -22,16 +20,31 @@ impl PanelContent { let mut br = Vec::new(); { let mut writer = brotli::CompressorWriter::new(&mut br, 4096, 5, 22); - writer.write_all(raw.as_bytes()).expect("compress panel Brotli"); + writer + .write_all(raw.as_bytes()) + .expect("compress panel Brotli"); } let zstd = zstd::stream::encode_all(raw.as_bytes(), 3).expect("compress panel zstd"); - Self { plain: Bytes::from_static(raw.as_bytes()), gzip, brotli: Bytes::from(br), zstd: Bytes::from(zstd) } + Self { + plain: Bytes::from_static(raw.as_bytes()), + gzip, + brotli: Bytes::from(br), + zstd: Bytes::from(zstd), + } } - pub(super) fn response(&self, headers: &HeaderMap, codecs: &PanelCompression, mime: &'static str) -> Response { + pub(super) fn response( + &self, + headers: &HeaderMap, + codecs: &PanelCompression, + mime: &'static str, + ) -> Response { let settings = crate::compression::Settings { - gzip: codecs.gzip, br: codecs.br, zstd: codecs.zstd, - deflate: false, min_bytes: 1024, + gzip: codecs.gzip, + br: codecs.br, + zstd: codecs.zstd, + deflate: false, + min_bytes: 1024, }; let encoding = if crate::compression::no_transform(headers) { None @@ -45,12 +58,21 @@ impl PanelContent { _ => (self.plain.clone(), None), }; let mut response = Response::new(Body::from(body.clone())); - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(mime)); - response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from_str(&body.len().to_string()).expect("valid length")); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(mime)); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&body.len().to_string()).expect("valid length"), + ); if let Some(name) = name { - response.headers_mut().insert(header::CONTENT_ENCODING, HeaderValue::from_static(name)); + response + .headers_mut() + .insert(header::CONTENT_ENCODING, HeaderValue::from_static(name)); } - response.headers_mut().insert(header::VARY, HeaderValue::from_static("Accept-Encoding")); + response + .headers_mut() + .insert(header::VARY, HeaderValue::from_static("Accept-Encoding")); response } } diff --git a/src/admin/preferences.rs b/src/admin/preferences.rs index 8789d1b..93fc537 100644 --- a/src/admin/preferences.rs +++ b/src/admin/preferences.rs @@ -27,9 +27,6 @@ fn default_admin_table_density() -> String { fn default_admin_dashboard_density() -> String { DASHBOARD_DEFAULT_PRESET.dashboard_density.clone() } -// Preserve the user's saved order/visibility, drop identifiers unknown to this -// binary, and append new registry widgets according to their opt-in policy. -// Stored preferences from older/newer releases are normalized on read. fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) { let mut seen = std::collections::HashSet::new(); prefs.dashboard_widgets.retain(|id| { @@ -131,8 +128,6 @@ struct AdminPreferences { dashboard_widgets: Vec, #[serde(default = "default_hidden_dashboard_widgets")] dashboard_hidden_widgets: Vec, - // Null/absent: retain legacy visual grouping for existing installations. - // A map (including {}) means that order is fully user-controlled. #[serde(default)] dashboard_widget_sizes: Option>, #[serde(default = "default_file_log_columns")] @@ -473,8 +468,6 @@ async fn update_preferences( input: Result, axum::extract::rejection::JsonRejection>, ) -> Result, ApiError> { let Json(mut preferences) = input.map_err(public::invalid_json)?; - // Do not rewrite the caller's saved order on PUT; drop only stale IDs. - // Legacy partial lists are expanded on the next GET, as before. preferences .dashboard_widgets .retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id)); diff --git a/src/admin/preferences_columns.rs b/src/admin/preferences_columns.rs index 62ccd1f..acf969e 100644 --- a/src/admin/preferences_columns.rs +++ b/src/admin/preferences_columns.rs @@ -75,8 +75,6 @@ const STREAM_LIST_SORT_COLUMNS: &[&str] = &[ "maxConnections", "idleTimeout", ]; -// The same JSON registry is embedded into the authenticated admin JS bundle. -// No independent allowlist is maintained by the browser or backend. #[derive(serde::Deserialize)] struct DashboardWidgetDefinition { id: String, @@ -89,9 +87,6 @@ static DASHBOARD_WIDGETS: std::sync::LazyLock> = .expect("invalid bundled Overview Cards registry") }); -// A single operator-curated factory preset feeds both the Rust API defaults -// and the authenticated dashboard. New registry widgets remain opt-in unless -// explicitly added to the preset; saved user preferences are never rewritten. #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct DashboardDefaultPreset { @@ -147,8 +142,6 @@ static DASHBOARD_DEFAULT_PRESET: std::sync::LazyLock = preset }); -// None denotes the legacy grouped layout. Some({}) switches to freely -// ordered 12-column CSS Grid while retaining every historical card width. fn valid_dashboard_span(display_type: &str, span: u8) -> bool { match display_type { "table" => matches!(span, 6 | 12), diff --git a/src/admin/preferences_tests.rs b/src/admin/preferences_tests.rs index 7cfb325..6a743ed 100644 --- a/src/admin/preferences_tests.rs +++ b/src/admin/preferences_tests.rs @@ -123,7 +123,6 @@ fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() { .dashboard_hidden_widgets .contains(&"extra_process_cpu".into())); assert!(prefs.dashboard_widgets.contains(&"backend_table".into())); - // 0.33.0 WWW cards stay opt-in when upgrading an old saved custom layout. for id in [ "www_status", "www_kind", @@ -186,7 +185,6 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() { assert!(prefs .dashboard_hidden_widgets .contains(&"backend_traffic".to_owned())); - // WWW was absent from the saved document and is introduced only as opt-in. assert!(prefs .dashboard_hidden_widgets .contains(&"www_status".to_owned())); diff --git a/src/admin/public.rs b/src/admin/public/mod.rs similarity index 75% rename from src/admin/public.rs rename to src/admin/public/mod.rs index 128a998..3ea8d22 100644 --- a/src/admin/public.rs +++ b/src/admin/public/mod.rs @@ -1,4 +1,3 @@ -//! Deliberately small anonymous surface. Operational templates/assets are private. use crate::{ auth::Session, runtime::{ApiError, App}, @@ -18,22 +17,19 @@ static ADMIN_INDEX: OnceLock = OnceLock::new(); fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response { let content = ADMIN_INDEX.get_or_init(|| { - // Compile-time embedded HTML fragments are assembled once at startup. - // No additional browser requests, changed DOM nodes or script ordering. - let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html") + let (metrics, web_metrics) = include_str!("../web/partials/overview-metrics.html") .split_once("") .expect("invalid Overview metrics partial"); - let (charts, web_charts) = include_str!("web/partials/overview-charts.html") + let (charts, web_charts) = include_str!("../web/partials/overview-charts.html") .split_once("") .expect("invalid Overview charts partial"); - let html = include_str!("web/index.html") + let html = include_str!("../web/index.html") .replace("", metrics) .replace("", charts) .replace("", web_metrics) .replace("", web_charts) .replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()) .replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION")); - // Exactly one static panel document shared between all administrator accounts. PanelContent::new(Box::leak(html.into_boxed_str())) }); content.response(headers, compression, "text/html; charset=utf-8") @@ -45,7 +41,7 @@ pub(super) async fn index( headers: HeaderMap, ) -> Response { if session.is_none() { - let html = include_str!("web/login.html") + let html = include_str!("../web/login.html") .replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()); return Html(html).into_response(); } @@ -57,7 +53,6 @@ pub(super) async fn index( } pub(super) fn invalid_json(error: JsonRejection) -> ApiError { - // Keep useful protocol statuses without returning serde's input excerpts. let status = match error.status() { StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE, StatusCode::UNSUPPORTED_MEDIA_TYPE => StatusCode::UNSUPPORTED_MEDIA_TYPE, diff --git a/src/admin/refresh_preferences.rs b/src/admin/refresh_preferences.rs index 3cc3d9c..8418bab 100644 --- a/src/admin/refresh_preferences.rs +++ b/src/admin/refresh_preferences.rs @@ -1,4 +1,3 @@ -// Stored inside the existing per-account administration preferences document. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] struct RefreshPreferences { @@ -36,6 +35,8 @@ fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiE } fn default_live_log_columns() -> Vec { - LIVE_LOG_COLUMNS.iter().map(|column| (*column).to_owned()).collect() + LIVE_LOG_COLUMNS + .iter() + .map(|column| (*column).to_owned()) + .collect() } - diff --git a/src/admin/router.rs b/src/admin/router.rs index 0311e20..f6641e3 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -3,8 +3,6 @@ mod panel_assets; mod panel_content; mod public; -// Embedded asset URLs change with every build, independent of Cargo package version. -// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404. fn asset_build_id() -> &'static str { include_str!(concat!(env!("OUT_DIR"), "/asset-build-id.txt")) } @@ -57,8 +55,6 @@ fn admin_app_javascript() -> &'static str { "const ADMIN_DASHBOARD_DEFAULTS = Object.freeze(", include_str!("web/dashboard-defaults.json"), ");", - // The samples controller is needed by top-level listeners in core.js. - // Define it first; DOM initialization and translations happen later. include_str!("web/app/overview-samples.js"), include_str!("web/app/core.js"), include_str!("web/app/session-resume.js"), @@ -286,8 +282,6 @@ pub fn router(state: Arc) -> Router { .layer(middleware::from_fn_with_state(state.clone(), observe_admin)) .with_state(state) } -// Separate from proxy exchanges: measure only time to admin response headers, -// including access denials. SSE stream lifetime is intentionally not included. async fn observe_admin(State(state): State>, request: Request, next: Next) -> Response { use std::sync::atomic::Ordering; state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed); @@ -325,14 +319,10 @@ fn secure(mut response: Response) -> Response { "permissions-policy", HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), ); - // Do not let downstream middleware add implementation/version fingerprints. h.remove("server"); h.remove("x-powered-by"); response } -// This is a method-and-path allowlist, NOT a path prefix. Everything else, -// including unknown routes, schema, metrics, assets and method mismatches, -// requires an active administrator session before extractors read a body. fn panel_document_path(path: &str) -> bool { matches!( path, diff --git a/src/admin/session.rs b/src/admin/session.rs index 66b1539..77f79ea 100644 --- a/src/admin/session.rs +++ b/src/admin/session.rs @@ -1,9 +1,5 @@ -// Only log browser origin metadata: never cookies, CSRF tokens, Authorization, -// query strings, or untrusted forwarding data. Malformed origins are redacted. fn diagnostic_admin_authority(value: &str) -> &str { - if value.len() <= 255 && !value.contains('@') - && value.parse::().is_ok() - { + if value.len() <= 255 && !value.contains('@') && value.parse::().is_ok() { value } else { "(invalid)" @@ -47,7 +43,11 @@ fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted: return "(invalid)"; }; if name == "x-forwarded-proto" { - if matches!(value, "http" | "https") { value } else { "(invalid)" } + if matches!(value, "http" | "https") { + value + } else { + "(invalid)" + } } else { diagnostic_admin_authority(value) } @@ -63,14 +63,19 @@ fn invalid_admin_origin( ) -> Response { let headers = request.headers(); let path = request.uri().path(); - let path = if path.len() <= 160 { path } else { "(long_path)" }; + let path = if path.len() <= 160 { + path + } else { + "(long_path)" + }; let browser_host = single_header(headers, HOST.as_str()) .map(diagnostic_admin_authority) .unwrap_or("(absent_or_duplicate)"); let browser_fetch_site = match single_header(headers, "sec-fetch-site") { None => "(absent)", - Some("same-origin" | "same-site" | "cross-site" | "none") => - single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"), + Some("same-origin" | "same-site" | "cross-site" | "none") => { + single_header(headers, "sec-fetch-site").unwrap_or("(invalid)") + } _ => "(invalid)", }; tracing::warn!( @@ -88,12 +93,15 @@ fn invalid_admin_origin( forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto), "administration origin rejected" ); - // When a browser navigates to the dashboard document, JSON here would - // replace the entire page with a raw API error. Keep the 403 (no shell or - // protected data) but present a standalone recovery message instead. - let wants_html = request.headers().get(http::header::ACCEPT) + let wants_html = request + .headers() + .get(http::header::ACCEPT) .and_then(|header| header.to_str().ok()) - .is_some_and(|value| value.split(',').any(|entry| entry.trim_start().starts_with("text/html"))); + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim_start().starts_with("text/html")) + }); if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html { return secure(( StatusCode::FORBIDDEN, @@ -117,10 +125,8 @@ async fn guard(State(state): State>, mut request: Request, next: Next) let headers = request.headers(); let trusted_host = trusted_admin_proxy(&state, peer); let trusted_proto = trusted_admin_forwarded_proto(&state, peer); - // Pinned browser origin is panel-only. Certificate automation using API - // credentials keeps the original forwarding/TLS authorization semantics. - let automation_credentials = headers.contains_key(CERTIFICATE_API_KEY_HEADER) - || headers.contains_key(AUTHORIZATION); + let automation_credentials = + headers.contains_key(CERTIFICATE_API_KEY_HEADER) || headers.contains_key(AUTHORIZATION); let pinned_authority_result = if automation_credentials { Ok(None) } else { @@ -128,25 +134,42 @@ async fn guard(State(state): State>, mut request: Request, next: Next) }; let pinned_authority = match pinned_authority_result { Ok(value) => value, - Err(()) => return invalid_admin_origin(&request, "pinned_origin_mismatch", Some("https"), - state.active_administration.public_origin.as_deref().and_then(|value| value.strip_prefix("https://")), - trusted_host, trusted_proto), + Err(()) => { + return invalid_admin_origin( + &request, + "pinned_origin_mismatch", + Some("https"), + state + .active_administration + .public_origin + .as_deref() + .and_then(|value| value.strip_prefix("https://")), + trusted_host, + trusted_proto, + ) + } }; let (scheme, external_authority) = if let Some(authority) = pinned_authority { - // HTTPS is a trusted, administrator-pinned identity here. Do not select - // any list element of an ambiguous forwarded-proto chain. ("https", authority) } else { let scheme = match external_admin_scheme(&state, headers, peer, transport) { Ok(value) => value, Err(()) => { - return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto); + return invalid_admin_origin( + &request, + "external_scheme", + None, + None, + trusted_host, + trusted_proto, + ); } }; - let authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { - Ok(value) => value, - Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), - }; + let authority = + match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { + Ok(value) => value, + Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), + }; (scheme, authority) }; if headers.get_all(ORIGIN).iter().count() > 1 @@ -156,13 +179,27 @@ async fn guard(State(state): State>, mut request: Request, next: Next) .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) }) { - return invalid_admin_origin(&request, "origin_mismatch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto); + return invalid_admin_origin( + &request, + "origin_mismatch", + Some(scheme), + Some(&external_authority), + trusted_host, + trusted_proto, + ); } if headers .get("sec-fetch-site") .is_some_and(|h| h == "cross-site") { - return invalid_admin_origin(&request, "cross_site_fetch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto); + return invalid_admin_origin( + &request, + "cross_site_fetch", + Some(scheme), + Some(&external_authority), + trusted_host, + trusted_proto, + ); } let path = request.uri().path().to_owned(); let public = anonymous_allowed(request.method(), &path); @@ -174,7 +211,10 @@ async fn guard(State(state): State>, mut request: Request, next: Next) let mut certificate_api_access = None; let certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) { let Some(operation) = certificate_api_operation.as_ref() else { - return secure(reject(StatusCode::FORBIDDEN, "certificate_api.invalid_route")); + return secure(reject( + StatusCode::FORBIDDEN, + "certificate_api.invalid_route", + )); }; match certificate_api_credentials(headers) { Ok(None) => None, @@ -243,35 +283,47 @@ async fn guard(State(state): State>, mut request: Request, next: Next) } else { None }; - let general_api_session = if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) { - // No cookies are accepted as a fallback when a malformed Bearer header is supplied. - let bearer = single_header(headers, AUTHORIZATION.as_str()) - .and_then(|header| header.strip_prefix("Bearer ")); - let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else { - return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token")); + let general_api_session = + if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) { + let bearer = single_header(headers, AUTHORIZATION.as_str()) + .and_then(|header| header.strip_prefix("Bearer ")); + let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else { + return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token")); + }; + let Some(peer) = peer else { + return secure(reject(StatusCode::FORBIDDEN, "api_token.peer_required")); + }; + if !peer.0.ip().is_loopback() && scheme != "https" { + return secure(reject(StatusCode::FORBIDDEN, "api_token.https_required")); + } + let hash = auth::secret_hash(token); + let peer_ip = crate::vhost::canonical_ip(peer.0.ip()); + let grant = match state.store.verify_api_token(&hash, peer_ip).await { + Ok(Some(grant)) => grant, + Ok(None) => { + return secure(reject( + StatusCode::UNAUTHORIZED, + "api_token.invalid_token_or_ip", + )) + } + Err(error) => return secure(auth::internal(error).into_response()), + }; + if !general_api_token_allowed(request.method(), &path, &grant.permission) { + return secure(reject( + StatusCode::FORBIDDEN, + "api_token.insufficient_permission", + )); + } + Some(Session { + token_hash: hash, + csrf: String::new(), + username: format!("api-token:{}", grant.id), + expires_at: auth::now().saturating_add(60), + generation: 0, + }) + } else { + None }; - let Some(peer) = peer else { - return secure(reject(StatusCode::FORBIDDEN, "api_token.peer_required")); - }; - if !peer.0.ip().is_loopback() && scheme != "https" { - return secure(reject(StatusCode::FORBIDDEN, "api_token.https_required")); - } - let hash = auth::secret_hash(token); - let peer_ip = crate::vhost::canonical_ip(peer.0.ip()); - let grant = match state.store.verify_api_token(&hash, peer_ip).await { - Ok(Some(grant)) => grant, - Ok(None) => return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token_or_ip")), - Err(error) => return secure(auth::internal(error).into_response()), - }; - if !general_api_token_allowed(request.method(), &path, &grant.permission) { - return secure(reject(StatusCode::FORBIDDEN, "api_token.insufficient_permission")); - } - Some(Session { - token_hash: hash, csrf: String::new(), - username: format!("api-token:{}", grant.id), - expires_at: auth::now().saturating_add(60), generation: 0, - }) - } else { None }; let external_credential = certificate_api_session.is_some() || general_api_session.is_some(); let session_seconds = state .config @@ -280,9 +332,6 @@ async fn guard(State(state): State>, mut request: Request, next: Next) .config .administration .session_seconds(); - // Panel document routes optionally authenticate so a direct refresh can choose - // the application shell when the browser still has a valid cookie. Public static - // login resources and capability discovery never load operational state. let session = if certificate_api_session.is_some() { certificate_api_session } else if general_api_session.is_some() { @@ -352,16 +401,12 @@ async fn guard(State(state): State>, mut request: Request, next: Next) } } if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() { - // Swagger UI sets inline layout styles. This exception is limited to the - // authenticated documentation document; scripts remain self-only/no-eval. response.headers_mut().insert("content-security-policy", HeaderValue::from_static( "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")); } response } async fn auth_status(State(state): State>) -> Result, ApiError> { - // The one bit needed to render first-account setup; no version, paths, names, - // listeners, provider state, metrics or configuration before authentication. Ok(Json( json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}), )) diff --git a/src/admin/status.rs b/src/admin/status.rs index 94d371e..86864a7 100644 --- a/src/admin/status.rs +++ b/src/admin/status.rs @@ -228,8 +228,6 @@ fn status_payload(state: &App) -> Value { }); payload } -// SSE and emergency polling use only volatile telemetry. Never serialize the -// whole configuration, OS diagnostics or certificate metadata on each tick. include!("status_live.rs"); async fn backend_status(State(state): State>) -> Json { let snapshot = state.config.load_full(); @@ -275,9 +273,6 @@ async fn certificate_inventory(State(state): State>) -> Json { let config = &snapshot.envelope.config; let configured_certificates = config.tls.certificates.clone(); let certificate_directory = state.policy.cert_dir.clone(); - // Inspect only public certificate files on a blocking worker; never read keys. - // Live PEM coverage wins so in-place renewals are reflected immediately; stored - // metadata is only a fallback when the public certificate file cannot be read. let inspected_metadata = tokio::task::spawn_blocking(move || { configured_certificates .into_iter() @@ -313,8 +308,6 @@ async fn certificate_inventory(State(state): State>) -> Json { json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}), ) } -// Storage metadata belongs to GET /api/config, not to the editable `config` -// document or the revisioned envelopes returned by update/restore endpoints. #[derive(Serialize)] struct ConfigurationRead { #[serde(flatten)] diff --git a/src/admin/status_live.rs b/src/admin/status_live.rs index 490b68d..b7a8fe3 100644 --- a/src/admin/status_live.rs +++ b/src/admin/status_live.rs @@ -1,6 +1,3 @@ -// The OS collector already samples in the background. Expose just the -// chart inputs in compact SSE/fallback snapshots; the full, slower System -// information document remains behind /api/system-info. fn live_chart_system_payload(state: &App) -> Value { let view = state.diagnostics.view(); json!({ @@ -14,8 +11,6 @@ fn live_chart_system_payload(state: &App) -> Value { }) } -// Snapshot already holds bounded, configured Web Service IDs. Do not expose -// paths, FastCGI addresses or PHP INI parameters in a status poll. fn web_server_metrics_payload(snapshot: &crate::config::Snapshot) -> Value { let mut services = serde_json::Map::new(); for service in &snapshot.envelope.config.web_services { diff --git a/src/admin/status_system.rs b/src/admin/status_system.rs index f904411..18380ec 100644 --- a/src/admin/status_system.rs +++ b/src/admin/status_system.rs @@ -1,6 +1,3 @@ -// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The -// diagnostics endpoint must never spawn system commands or parse platform files. -// Host identification is cached because it changes only after a reboot. fn valid_host_text(value: String) -> Option { let value = value.trim(); (!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control)) @@ -9,30 +6,32 @@ fn valid_host_text(value: String) -> Option { fn os_release() -> Option<&'static str> { static VALUE: std::sync::OnceLock> = std::sync::OnceLock::new(); - VALUE.get_or_init(|| { - sysinfo::System::long_os_version() - .or_else(|| { - let name = sysinfo::System::name()?; - Some(match sysinfo::System::os_version() { - Some(version) if !version.is_empty() => format!("{name} {version}"), - _ => name, + VALUE + .get_or_init(|| { + sysinfo::System::long_os_version() + .or_else(|| { + let name = sysinfo::System::name()?; + Some(match sysinfo::System::os_version() { + Some(version) if !version.is_empty() => format!("{name} {version}"), + _ => name, + }) }) - }) - .and_then(valid_host_text) - }).as_deref() + .and_then(valid_host_text) + }) + .as_deref() } fn kernel_release() -> Option<&'static str> { static VALUE: std::sync::OnceLock> = std::sync::OnceLock::new(); - VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text)) + VALUE + .get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text)) .as_deref() } -// Cached diagnostics and ACME metadata are requested separately from high-rate SSE. -// Preserve the legacy full /api/status shape for existing API clients. fn system_info_payload(state: &App) -> Value { let cache_view = state.cache.view(); - let transform_resources = crate::transform::resource_usage(&state.config.load().envelope.config.transform); + let transform_resources = + crate::transform::resource_usage(&state.config.load().envelope.config.transform); let mut payload = json!({}); let system = state.diagnostics.view(); let cache_ram_bytes = diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 12c7674..265f07a 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -315,7 +315,6 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } - // Origin on an ordinary same-origin GET may be absent, or may match. for origin in [None, Some("http://127.0.0.1:9090")] { let mut request = Request::builder() .uri("/api/auth/session") @@ -902,7 +901,6 @@ mod tests { .await .unwrap(); if path == "/logs" { - // The operational log route is private even on browser refresh. assert_eq!(public.status(), StatusCode::UNAUTHORIZED); assert_eq!(json_body(public).await, json!({"error":"auth.required"})); } else { @@ -1243,7 +1241,6 @@ mod tests { .body(Body::from("{}")) .unwrap() }; - // Certificate API credentials cannot trigger an all-certificate reload. let response = router .clone() .oneshot(external( @@ -1254,8 +1251,6 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); - // The allowlisted ID passes the auth gate and reaches the handler; this - // fixture intentionally has no such configured certificate, so it is 404. let response = router .clone() .oneshot(external( @@ -1266,7 +1261,6 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); - // A reload-only token cannot use the deploy endpoint at all. let response = router .clone() .oneshot( @@ -1281,7 +1275,6 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); - // A different ID is rejected by the credential grant before the handler. let response = router .clone() .oneshot(external( @@ -1320,8 +1313,6 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); - // This fixture has no certificate directory. An out-of-scope credential - // must see only the grant error, never the storage availability error. assert_eq!( json_body(response).await, json!({"error":"certificate_api.certificate_forbidden"}) @@ -1482,7 +1473,6 @@ mod tests { assert!(detail["credential"].get("token_hash").is_none()); let created_at = detail["credential"]["created_at"].clone(); - // A bearer-only automation client cannot read or modify management records. let denied = router .clone() .oneshot( @@ -1526,7 +1516,6 @@ mod tests { .unwrap(); assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY); - // An edit changes only metadata and grants, not the current bearer secret. let edited = router.clone().oneshot(admin( Method::PATCH, "/api/certificate-api/certbot-prod", json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}), @@ -1895,8 +1884,6 @@ mod tests { "backend_table_sort":"requests", "backend_table_descending":true }); - // Older clients may omit fields added since their preferences were - // saved. The API returns and stores the typed, defaulted document. let normalized = serde_json::to_value( serde_json::from_value::(selected.clone()).unwrap(), ) @@ -1943,7 +1930,6 @@ mod tests { serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap()) .unwrap(); assert_eq!(saved, normalized); - // Preferences must also survive a subsequent authenticated read. let persisted = router .clone() .oneshot( @@ -1976,7 +1962,6 @@ mod tests { } } assert_eq!(json_body(persisted).await, upgraded); - // Wrong allowlist value is rejected even if the rest is valid. let mut invalid = selected.clone(); invalid["refresh"]["traffic_seconds"] = json!(4); let rejected = router @@ -2120,8 +2105,6 @@ mod tests { if valid_csrf { req = req.header("x-csrf-token", &csrf); } - // This fixture has no certificate directory: valid auth reaches the - // 409 precondition, never a child process, DNS update or CA request. let response = router .clone() .oneshot(req.body(Body::from("{}")).unwrap()) @@ -2898,7 +2881,6 @@ mod tests { .collect::>(), vec![Some(200), Some(404), Some(500), None] ); - // Page two must reflect the sorted complete scan, not a local page sort. assert_eq!(records[1]["status"], 404); sort_file_log_records(&mut records, "status", true); assert_eq!( @@ -3099,8 +3081,6 @@ mod tests { assert_eq!(live.status(), StatusCode::OK); let live = json_body(live).await; assert!(live.get("metrics").is_some()); - // Compact snapshots contain only already-collected data required for - // fast Overview charts, not the expensive System details document. assert!(live["live_system"].get("sampled_at_ms").is_some()); assert!(live["live_system"].get("process_cpu_percent").is_some()); assert!(live["live_system"].get("process_rss_bytes").is_some()); @@ -3170,7 +3150,7 @@ mod tests { } value.overview_seconds = 3; assert!(validate_refresh_preferences(&value).is_ok()); - value.overview_seconds = 2; // previous installations stay valid + value.overview_seconds = 2; assert!(validate_refresh_preferences(&value).is_ok()); value.overview_seconds = 1; for seconds in [1, 2, 4, 31] { @@ -3256,8 +3236,6 @@ mod tests { "CSRF {method} {path}" ); } - // The valid session and CSRF continue to work for a benign mutation, - // proving the failed requests above did not revoke the cookie. let response = router .oneshot( request("/api/auth/logout") @@ -3341,8 +3319,6 @@ mod tests { config.administration.public_origin = Some("https://panel.example.test".into()); let (router, store, _dir, secret, csrf) = fixture_with_config(config).await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); - // Without an explicit origin pin, 0.31.3 rejected this session GET as - // external_scheme although the browser address remained correct. let result = router .clone() .oneshot( @@ -3367,8 +3343,6 @@ mod tests { .unwrap(); assert!(session_cookie.contains("; Secure")); - // The incoming admin Host may itself be the pinned public DNS name. - // There is no separate allowed_hosts entry in the saved configuration. let pinned_host = Request::builder() .uri("/api/auth/session") .header("host", "panel.example.test") @@ -3383,8 +3357,6 @@ mod tests { StatusCode::OK ); - // Explicit Origin, CSRF token and app request marker remain mandatory - // for a state-changing request even when XFP contains multiple hops. let unauthorized = router .clone() .oneshot( @@ -3484,8 +3456,6 @@ mod tests { assert_eq!(result.status(), StatusCode::FORBIDDEN, "must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}"); } - // Credential-bearing certificate automation does not inherit the - // browser-origin override, even through the same trusted admin socket. let automation = router .clone() .oneshot( @@ -3582,10 +3552,8 @@ mod tests { assert!(payload["config"].get("storage").is_none()); assert_eq!(payload["storage"]["mode"], "sqlite"); assert!(payload["storage"]["config_path"].is_null()); - assert!(payload["storage"]["database_path"].is_null()); // fixture omits data_dir + assert!(payload["storage"]["database_path"].is_null()); - // The old GET endpoint is gone. A dynamic PUT-only section route may - // match the path, but it must not respond to GET. let old_route = router .oneshot( request("/api/config/storage") @@ -3669,15 +3637,38 @@ mod tests { async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() { let (router, store, _dir, secret, _) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); - let snapshot = router.clone().oneshot( - request("/api/metrics/json").header("cookie", &cookie) - .body(Body::empty()).unwrap() - ).await.unwrap(); + let snapshot = router + .clone() + .oneshot( + request("/api/metrics/json") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(snapshot.status(), StatusCode::OK); - assert!(snapshot.headers()["content-type"].to_str().unwrap().starts_with("application/json")); + assert!(snapshot.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("application/json")); let snapshot = json_body(snapshot).await; - for field in ["summary", "vhosts", "requests", "system", "cache", "backends", "web_server", - "streams", "docker", "traffic", "transform", "logging", "capacity", "prometheus"] { + for field in [ + "summary", + "vhosts", + "requests", + "system", + "cache", + "backends", + "web_server", + "streams", + "docker", + "traffic", + "transform", + "logging", + "capacity", + "prometheus", + ] { assert!(snapshot.get(field).is_some(), "missing: {field}"); } assert_eq!(snapshot["vhosts"]["total"], 0); @@ -3687,21 +3678,52 @@ mod tests { assert_eq!(snapshot["summary"]["backends"]["total"], 0); assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number()); assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array()); - assert_eq!(snapshot["prometheus"]["types"]["rust_proxy_requests_total"], "counter"); - assert!(general_api_token_allowed(&Method::GET, "/api/metrics/json", "ro")); - assert!(general_api_token_allowed(&Method::GET, "/api/metrics/summary", "ro")); - assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/json", "rw")); - assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/summary", "rw")); - let summary = router.clone().oneshot( - request("/api/metrics/summary").header("cookie", &cookie) - .body(Body::empty()).unwrap() - ).await.unwrap(); + assert_eq!( + snapshot["prometheus"]["types"]["rust_proxy_requests_total"], + "counter" + ); + assert!(general_api_token_allowed( + &Method::GET, + "/api/metrics/json", + "ro" + )); + assert!(general_api_token_allowed( + &Method::GET, + "/api/metrics/summary", + "ro" + )); + assert!(!general_api_token_allowed( + &Method::POST, + "/api/metrics/json", + "rw" + )); + assert!(!general_api_token_allowed( + &Method::POST, + "/api/metrics/summary", + "rw" + )); + let summary = router + .clone() + .oneshot( + request("/api/metrics/summary") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(summary.status(), StatusCode::OK); assert_eq!(summary.headers()["cache-control"], "no-store"); let summary = json_body(summary).await; assert!(summary.get("prometheus").is_none()); - assert_eq!(summary["vhosts"]["enabled"], snapshot["summary"]["vhosts"]["enabled"]); - assert_eq!(summary["backends"]["total"], snapshot["summary"]["backends"]["total"]); + assert_eq!( + summary["vhosts"]["enabled"], + snapshot["summary"]["vhosts"]["enabled"] + ); + assert_eq!( + summary["backends"]["total"], + snapshot["summary"]["backends"]["total"] + ); store.close().await; } @@ -3710,12 +3732,20 @@ mod tests { let (router, store, _dir, _, _) = fixture().await; let token = "T".repeat(64); let cidrs = vec!["127.0.0.1/32".to_string()]; - assert!(store.create_api_token( - "api-monitor", &auth::secret_hash(&token), "ro", "monitor test", &cidrs, "owner" - ).await.unwrap()); + assert!(store + .create_api_token( + "api-monitor", + &auth::secret_hash(&token), + "ro", + "monitor test", + &cidrs, + "owner" + ) + .await + .unwrap()); let build = |peer: Option<&str>| { - let mut request = request("/api/metrics/json") - .header("authorization", format!("Bearer {token}")); + let mut request = + request("/api/metrics/json").header("authorization", format!("Bearer {token}")); if let Some(peer) = peer { request = request.extension(PeerAddr(peer.parse().unwrap())); } @@ -3723,19 +3753,36 @@ mod tests { }; let response = router.clone().oneshot(build(None)).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); - let response = router.clone().oneshot(build(Some("127.0.0.1:12345"))).await.unwrap(); + let response = router + .clone() + .oneshot(build(Some("127.0.0.1:12345"))) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let response = json_body(response).await; assert!(response["prometheus"]["samples"].is_object()); assert!(response.get("config").is_none()); let summarize = |peer: Option<&str>| { - let mut req = request("/api/metrics/summary") - .header("authorization", format!("Bearer {token}")); - if let Some(peer) = peer { req = req.extension(PeerAddr(peer.parse().unwrap())); } + let mut req = + request("/api/metrics/summary").header("authorization", format!("Bearer {token}")); + if let Some(peer) = peer { + req = req.extension(PeerAddr(peer.parse().unwrap())); + } req.body(Body::empty()).unwrap() }; - assert_eq!(router.clone().oneshot(summarize(None)).await.unwrap().status(), StatusCode::FORBIDDEN); - let summary = router.oneshot(summarize(Some("127.0.0.1:12345"))).await.unwrap(); + assert_eq!( + router + .clone() + .oneshot(summarize(None)) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + let summary = router + .oneshot(summarize(Some("127.0.0.1:12345"))) + .await + .unwrap(); assert_eq!(summary.status(), StatusCode::OK); let summary = json_body(summary).await; assert!(summary["traffic"]["requests_total"].is_number()); diff --git a/src/auth/files.rs b/src/auth/files/mod.rs similarity index 93% rename from src/auth/files.rs rename to src/auth/files/mod.rs index edcfff2..c2b9af7 100644 --- a/src/auth/files.rs +++ b/src/auth/files/mod.rs @@ -1,4 +1,3 @@ -//! Local filesystem authority. The process owner can initialize or recover the sole admin. use anyhow::{ensure, Context, Result}; use fs2::FileExt; use rand::{rngs::SysRng, TryRng}; @@ -102,8 +101,6 @@ pub fn load_or_create_setup_token(dir: &Path) -> Result { Ok(token) } -/// Validate database/WAL paths before opening SQLite. The private parent directory is -/// the local CLI authorization boundary; files owned by the same OS user are trusted. pub fn prepare_database(path: &Path) -> Result<()> { if !path.try_exists()? { private_options().create_new(true).open(path)?.sync_all()?; @@ -134,4 +131,4 @@ pub fn prepare_database(path: &Path) -> Result<()> { Ok(()) } #[cfg(test)] -include!("files/tests.rs"); +include!("tests.rs"); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index d7d14ef..e6c13c1 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,5 +1,3 @@ -//! Password authentication is confined to the control plane. -//! Expensive Argon2 work runs on bounded blocking workers, not Tokio network workers. mod files; mod store; use crate::{db::Store, runtime::ApiError}; @@ -151,7 +149,6 @@ impl Auth { } else { None }; - // Generate valid dummy work for unknown usernames. This happens only at startup. let dummy_hash = hash_password_offline(random_secret()).await?; Ok(Arc::new(Self { store, diff --git a/src/auth/store.rs b/src/auth/store.rs index 57f98fb..e08b631 100644 --- a/src/auth/store.rs +++ b/src/auth/store.rs @@ -1,4 +1,3 @@ -//! Transactional administrator operations. Login/reset races are guarded by generation CAS. use super::{now, random_secret, secret_hash, Session, SessionToken, SESSION_SECONDS}; use crate::db::{audit_tx, Store}; use anyhow::{ensure, Result}; @@ -35,17 +34,12 @@ impl Store { tx.commit().await?; Ok(true) } - /// One persisted global bucket for the single administration listener. - /// Fixed upper bound: 10 attempts per 60-second window, including successful attempts. - /// Unknown usernames use the same bucket and password work as known usernames. pub async fn consume_auth_attempt(&self, time: i64) -> Result { let changed = sqlx::query("UPDATE auth_throttle SET attempts = CASE WHEN window_start <= ? THEN 1 ELSE attempts + 1 END, window_start = CASE WHEN window_start <= ? THEN ? ELSE window_start END WHERE id = 1 AND (window_start <= ? OR attempts < 10)") .bind(time - 60).bind(time - 60).bind(time).bind(time - 60) .execute(&self.pool).await?; Ok(changed.rows_affected() == 1) } - /// The first UPDATE obtains SQLite's write lock and validates the credential generation. - /// A reset committed during password verification prevents insertion of a stale session. pub async fn create_session( &self, generation: i64, @@ -115,7 +109,6 @@ impl Store { .execute(&mut *tx) .await?; } - // Independent caps avoid a local CLI burst evicting all browser sessions. let cap = if kind == "cli" { 8_i64 } else { 16_i64 }; sqlx::query("DELETE FROM admin_sessions WHERE token_hash IN (SELECT token_hash FROM admin_sessions WHERE kind = ? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?)") .bind(kind).bind(cap - 1).execute(&mut *tx).await?; @@ -150,12 +143,6 @@ impl Store { "browser session lifetime must be 7..30 days" ); let hash = secret_hash(secret); - // A browser session is bounded by the currently configured lifetime. If the - // setting was increased while an older still-valid session exists, extend its - // absolute expiry from its original creation time without turning it into a - // sliding/immortal session. CLI capabilities keep the fixed expiry chosen - // when they are created (60 seconds for one-shot admin commands, longer - // for the bounded local live-log viewer). let row = sqlx::query("UPDATE admin_sessions SET last_seen = ?, expires_at = CASE WHEN kind = 'browser' THEN created_at + ? ELSE expires_at END WHERE token_hash = ? AND expires_at > ? AND (kind <> 'browser' OR created_at + ? > ?) AND generation = (SELECT generation FROM administrator WHERE id = 1) RETURNING csrf_token, expires_at, generation, (SELECT username FROM administrator WHERE id = 1) AS username") .bind(time) .bind(browser_lifetime) @@ -215,8 +202,6 @@ impl Store { tx.commit().await?; Ok(()) } - /// expected_generation protects browser password changes against simultaneous resets. - /// CLI passes the generation it read, so even two offline recoveries cannot silently win. pub async fn reset_admin_password( &self, hash: &str, diff --git a/src/backup.rs b/src/backup.rs index 7bea7f7..4846c00 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,7 +1,3 @@ -//! Password-encrypted full backup/restore for RustProxy durable data. -//! The format is streaming, authenticated and deliberately excludes caches, -//! logs, sockets and temporary runtime state. - include!("backup/archive.rs"); include!("backup/ownership.rs"); include!("backup/snapshot.rs"); diff --git a/src/backup/ownership.rs b/src/backup/ownership.rs index dc333f5..270ae39 100644 --- a/src/backup/ownership.rs +++ b/src/backup/ownership.rs @@ -37,11 +37,11 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> { } let raw = CString::new(path.as_os_str().as_bytes()) .context("restore path contains an embedded NUL")?; - // lchown changes the link itself instead of following a restored symlink. let rc = unsafe { libc::lchown(raw.as_ptr(), owner.uid, owner.gid) }; if rc != 0 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("restore ownership update failed for {}", path.display())); + return Err(std::io::Error::last_os_error()).with_context(|| { + format!("restore ownership update failed for {}", path.display()) + }); } Ok(()) } @@ -53,4 +53,3 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> { fn set_tree_owner(_path: &Path, _owner: TreeOwner) -> Result<()> { Ok(()) } - diff --git a/src/backup/snapshot.rs b/src/backup/snapshot.rs index 989fae2..a4a90f6 100644 --- a/src/backup/snapshot.rs +++ b/src/backup/snapshot.rs @@ -263,9 +263,6 @@ fn validate_snapshot_config( &certificate.cert_file, &certificate.key_file, )? { - // External certificate material is intentionally outside the RustProxy - // data tree and is therefore referenced by configuration, not copied - // into a RustProxy backup. The operator must restore/mount it separately. continue; } for name in [&certificate.cert_file, &certificate.key_file] { diff --git a/src/backup/tests.rs b/src/backup/tests.rs index 90cf620..cb70042 100644 --- a/src/backup/tests.rs +++ b/src/backup/tests.rs @@ -19,7 +19,6 @@ mod tests { assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); let mut bytes = fs::read(&encrypted).unwrap(); - // Corrupt the last authentication-tag byte, before the four-byte EOF marker. let tag_byte = bytes.len() - 5; bytes[tag_byte] ^= 1; fs::write(&tampered, bytes).unwrap(); diff --git a/src/balance.rs b/src/balance.rs index b32558f..14daca5 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -1,6 +1,3 @@ -//! Bounded backend pools: active health checks, passive failures, weighted -//! balancing, opaque affinity and leases held through the complete exchange. - include!("balance/model.rs"); include!("balance/health.rs"); include!("balance/lease_runtime.rs"); diff --git a/src/balance/health.rs b/src/balance/health.rs index d2bb7d5..da030bd 100644 --- a/src/balance/health.rs +++ b/src/balance/health.rs @@ -112,14 +112,10 @@ impl BackendState { } } pub fn healthy(&self) -> bool { - // No lock/clock acquisition for a healthy backend. Only an unhealthy - // backend enters the quarantine/cooldown recovery path below. if self.healthy_cached.load(Ordering::Acquire) { return true; } let mut h = lock(&self.health); - // Passive-only monitoring has no active probes to recover an ejected - // backend. After a bounded quarantine, allow one new traffic attempt. if !h.passive_healthy && h.active_healthy && !h.checked { if h.passive_ejected_at .is_some_and(|at| at.elapsed() >= h.passive_cooldown) @@ -173,8 +169,6 @@ impl BackendState { .saturating_add(self.bytes_out.load(Ordering::Relaxed)); let delta = total.saturating_sub(sample.total); if delta == 0 { - // No bytes moved in this sampling window: report an actual idle backend - // instead of keeping a decaying EWMA tail that looks like live traffic. sample.rate = 0.0; } else { let rate = delta as f64 / elapsed; @@ -367,8 +361,6 @@ impl BackendState { self.observe_passive_with_detail(active, passive, reason, reason, reason, elapsed); } - /// Returns the first failure in a consecutive counted streak only when - /// this observation actually removes the backend from the traffic pool. fn observe_passive_with_detail( &self, active: &HealthCheck, @@ -400,8 +392,6 @@ impl BackendState { if !passive.counts(reason) { return None; } - // A fan-out of failures from one expired H2 connection must not count - // as independent failed backend observations in a single instant. if reason == "io_error" { let now = Instant::now(); if h.last_io_counted_at @@ -459,8 +449,6 @@ pub struct Member { pub backend: Arc, pub definition: PoolMember, pub state: Arc, - // Immutable effective per-backend policies are compiled once per pool - // generation, not deep-cloned for every request/lease. health_settings: Arc, passive_settings: Arc, token: String, diff --git a/src/balance/lease_runtime.rs b/src/balance/lease_runtime.rs index c7298ae..ea875ba 100644 --- a/src/balance/lease_runtime.rs +++ b/src/balance/lease_runtime.rs @@ -76,8 +76,6 @@ impl Lease { self.member.state.errors.fetch_add(1, Ordering::Relaxed); } self.member.state.record_failure(class, detail); - // GOAWAY(NO_ERROR) is a graceful H2 connection shutdown, not an - // origin failure. It can still be surfaced in access diagnostics. if matches!(class, "h2_graceful_goaway" | "h2_graceful_close") { return; } @@ -368,8 +366,6 @@ async fn probe(member: Arc, settings: Arc) { tracing::info!(backend = %member.definition.id, healthy = after, reason = result, "backend health changed"); } } -/// One supervisor, at most 16 probes, at most one outstanding probe per member. -/// DNS workers have their own shared bound and never resolve arbitrary URLs. pub async fn health_loop(app: Weak, shutdown: CancellationToken) { let mut interval = tokio::time::interval(Duration::from_millis(250)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -383,24 +379,14 @@ pub async fn health_loop(app: Weak, shutdown: CancellationT _ = interval.tick() => { let Some(app) = app.upgrade() else { break; }; let snapshot = app.config.load_full(); - // Own an Arc for each candidate. JoinSet tasks require an - // owned, 'static member rather than a reference into snapshot. - // Health settings were already compiled into each member. - // Reuse the immutable Arc instead of cloning each HealthCheck - // and recalculating per-member overrides on every 250ms tick. let members: Vec<_> = snapshot.pools.iter().flat_map(|pool| pool.members.iter().map(|member| (Arc::clone(member), Arc::clone(&member.health_settings)))).collect(); for (member, _) in &members { member.state.sample_traffic(); - // Time-based pool recycling must not depend on receiving a - // request; otherwise idle backends can stay on generation 0 - // indefinitely after the configured recycle age elapses. member.backend.client.maintain_rotation(); member.backend.websocket_client.maintain_rotation(); } - // Rotate the bounded probe window: slow first members must not - // starve later pools when all intervals equal their timeouts. let start = cursor; for offset in 0..members.len() { if checks.len() >= 16 { break; } diff --git a/src/balance/model.rs b/src/balance/model.rs index bfc14fd..cafa27e 100644 --- a/src/balance/model.rs +++ b/src/balance/model.rs @@ -1,4 +1,3 @@ -// Core pool model and policies. use crate::{config::CompiledBackend, vhost::PoolMember}; use anyhow::{ensure, Result}; use axum::body::Body; @@ -54,15 +53,8 @@ pub struct Keepalive { pub idle_timeout_seconds: u64, pub max_idle_per_host: usize, pub tcp_keepalive_seconds: u64, - /// Rebuild a backend Hyper client after this many requests. The counter is - /// per backend/client generation, not per TCP connection. Zero disables - /// request-count based recycling. pub pool_recycle_requests: u64, - /// Maximum age of one backend Hyper client generation. Zero disables - /// time-based recycling. pub pool_recycle_seconds: u64, - /// Bound Hyper's adaptive HTTP/1 read buffer per connection. Hyper requires - /// at least 8192 bytes. This affects upstream HTTP/1 only. pub http1_max_buffer_bytes: usize, } impl Default for Keepalive { @@ -70,9 +62,6 @@ impl Default for Keepalive { Self { enabled: true, idle_timeout_seconds: 30, - // Retain enough reusable H1 upstream sockets under concurrent - // small-response traffic. The existing 1..=64 bound, idle - // timeout and pool recycling still limit resource retention. max_idle_per_host: 64, tcp_keepalive_seconds: 30, pool_recycle_requests: 25_000, @@ -219,7 +208,6 @@ impl Default for Settings { } } impl Settings { - /// Resolve active health per member; a disabled monitor never probes. pub fn health_for(&self, member: &PoolMember) -> HealthCheck { let mut health = member .health_check @@ -233,7 +221,6 @@ impl Settings { } health } - /// Monitoring off also excludes passive ejection; failures remain logged. pub fn passive_for(&self, member: &PoolMember) -> PassiveHealth { let mut passive = self.passive_health.clone(); if !member.health_check_enabled { @@ -389,8 +376,6 @@ impl Default for FailureObservation { } #[derive(Debug)] pub struct BackendState { - // Lock-free healthy lookups on the hot request-selection path. The mutex - // remains authoritative for transitions, probes and quarantine expiry. healthy_cached: AtomicBool, health: Mutex, traffic: Mutex, diff --git a/src/balance/pool_core.rs b/src/balance/pool_core.rs index 7d1bf4f..063a694 100644 --- a/src/balance/pool_core.rs +++ b/src/balance/pool_core.rs @@ -72,13 +72,8 @@ impl Pool { .backend .websocket_client .reusable_with(&old.backend.websocket_client); - // This pool has not been published or cloned into routes yet. if let Some(member) = Arc::get_mut(member) { if same_clients { - // Preserve the live Hyper pools and, with them, generation - // age/request/rotation state across unrelated snapshot - // rebuilds. Changed origins, address policy, keepalive or - // connect timeout still receive newly compiled clients. member.backend = old.backend.clone(); } if same_origin @@ -215,9 +210,6 @@ impl Pool { tls: bool, excluded: &[String], ) -> Option { - // Most deployments have one healthy backend. For non-cookie modes it - // needs neither candidate Vecs, weight scoring nor the shared scheduler - // mutex. Still use the same atomic capacity lease and health gate. if self.members.len() == 1 && !self.cookie_affinity() { let member = &self.members[0]; if !member.definition.pool_eligible @@ -247,8 +239,6 @@ impl Pool { } else { None }; - // Drain stops new assignments while existing cookie-affine clients may - // finish their session. Disabled/unhealthy members never receive traffic. let matched = existing.and_then(|token| { self.members.iter().find(|m| { m.token == token @@ -269,8 +259,6 @@ impl Pool { if self.settings.algorithm == Algorithm::RoundRobin && self.settings.sticky.mode != StickyMode::IpHash { - // Smooth weighted round-robin keeps its historical state and - // global ordering. Only that algorithm takes the mutex. let mut scheduling = lock(&self.scheduling); let mut chosen = members[0]; let mut best = f64::NEG_INFINITY; @@ -279,13 +267,14 @@ impl Pool { let weight = member .state .weight(member.definition.weight, self.settings.slow_start_seconds); - let current = if let Some(current) = - scheduling.get_mut(member.definition.id.as_str()) - { - current - } else { - scheduling.entry(member.definition.id.clone()).or_insert(0.0) - }; + let current = + if let Some(current) = scheduling.get_mut(member.definition.id.as_str()) { + current + } else { + scheduling + .entry(member.definition.id.clone()) + .or_insert(0.0) + }; *current += weight; total += weight; if *current > best { @@ -305,8 +294,6 @@ impl Pool { } else { self.settings.algorithm }; - // Canonicalize the address once, not for every candidate. The - // SHA-256 input and rendezvous scoring remain bit-for-bit equal. let hash_ip = (algorithm == Algorithm::IpHash) .then(|| crate::vhost::canonical_ip(peer).to_string()); for offset in 0..members.len() { @@ -330,16 +317,18 @@ impl Pool { -unit.max(f64::MIN_POSITIVE).ln() / member.definition.weight as f64 } _ => { - let weight = member.state.weight( - member.definition.weight, - self.settings.slow_start_seconds, - ); + let weight = member + .state + .weight(member.definition.weight, self.settings.slow_start_seconds); match algorithm { - Algorithm::LeastTraffic => - (member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight, - Algorithm::Adaptive => + Algorithm::LeastTraffic => { + (member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight + } + Algorithm::Adaptive => { ((active + 1.0) * member.state.latency() - + member.state.traffic_rate() / 16_384.0) / weight, + + member.state.traffic_rate() / 16_384.0) + / weight + } _ => (active + 1.0) / weight, } } diff --git a/src/balance/pool_view.rs b/src/balance/pool_view.rs index 480fdc2..e568136 100644 --- a/src/balance/pool_view.rs +++ b/src/balance/pool_view.rs @@ -19,10 +19,6 @@ impl Pool { "down" }; - // Keep this as explicit Map construction instead of one very large - // json! invocation. serde_json's json_internal! macro is recursive - // and the diagnostics payload is large enough to hit rustc's default - // macro recursion limit on supported stable toolchains. let mut item = serde_json::Map::new(); item.insert("id".into(), json!(m.definition.id)); item.insert("url".into(), json!(m.definition.url)); @@ -85,13 +81,23 @@ impl Pool { "upstream_client_rotations_total".into(), json!(client_rotation.rotations), ); - // Negotiated HTTP version from real backend response headers, - // never from the configured Auto/HTTP2 policy or health probes. let observed = m.backend.client.protocol_stats(); - item.insert("upstream_protocol_observed".into(), json!(observed.last_version)); - item.insert("upstream_protocol_last_seen_ms".into(), json!(observed.observed_at_ms)); - item.insert("upstream_protocol_http1_total".into(), json!(observed.http1_responses)); - item.insert("upstream_protocol_http2_total".into(), json!(observed.http2_responses)); + item.insert( + "upstream_protocol_observed".into(), + json!(observed.last_version), + ); + item.insert( + "upstream_protocol_last_seen_ms".into(), + json!(observed.observed_at_ms), + ); + item.insert( + "upstream_protocol_http1_total".into(), + json!(observed.http1_responses), + ); + item.insert( + "upstream_protocol_http2_total".into(), + json!(observed.http2_responses), + ); item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0)); item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0)); item.insert("last_probe_ms".into(), json!(h.last_probe_ms)); diff --git a/src/balance/tests.rs b/src/balance/tests.rs index 1379a41..c80d491 100644 --- a/src/balance/tests.rs +++ b/src/balance/tests.rs @@ -66,7 +66,7 @@ mod tests { defaults.health_check_enabled = false; assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off); assert!(!settings.passive_for(&defaults).enabled); - assert!(!defaults.pool_eligible); // routing intent is independent + assert!(!defaults.pool_eligible); defaults.health_check_enabled = true; defaults.health_check = Some(HealthCheck { mode: HealthMode::Http, @@ -105,7 +105,8 @@ mod tests { assert_eq!(parsed.keepalive.max_idle_per_host, 64); let explicit: Settings = serde_json::from_value(serde_json::json!({ "keepalive": { "max_idle_per_host": 4 } - })).unwrap(); + })) + .unwrap(); assert_eq!(explicit.keepalive.max_idle_per_host, 4); assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000); assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600); @@ -356,7 +357,7 @@ mod tests { } assert_eq!(counts, [30, 10]); assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0); - assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); // Mere cache selection isn't origin traffic. + assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); } #[tokio::test] async fn least_connections_counts_a_lease_until_its_last_owner_drops() { @@ -769,7 +770,6 @@ mod tests { assert!(state.healthy()); } - #[test] fn single_backend_fast_path_observes_capacity_and_health_without_affinity_cookie() { let pool = capacity_pool(1, 0); @@ -792,7 +792,10 @@ mod tests { let first = select(&pool); let second = select(&pool); assert!(Arc::ptr_eq(&first.lease.health, &second.lease.health)); - assert!(Arc::ptr_eq(&first.lease.passive_health, &second.lease.passive_health)); + assert!(Arc::ptr_eq( + &first.lease.passive_health, + &second.lease.passive_health + )); } #[test] @@ -800,10 +803,14 @@ mod tests { let state = BackendState::new(HealthMode::Off); assert!(state.healthy()); let active = settings(HealthMode::Off); - let passive = PassiveHealth { enabled: true, fall: Some(1), rise: Some(1), ..Default::default() }; + let passive = PassiveHealth { + enabled: true, + fall: Some(1), + rise: Some(1), + ..Default::default() + }; state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(2)); assert!(!state.healthy()); - // Preserve quarantine rules; an active probe is not invented here. state.observe_passive(&active, &passive, "ok", Duration::from_millis(2)); assert!(state.healthy()); } @@ -812,14 +819,17 @@ mod tests { fn successful_lease_response_keeps_passive_observability_and_failure_detail() { let pool = capacity_pool(0, 0); let selection = select(&pool); - selection.lease.response(200, Duration::from_millis(5), true); + selection + .lease + .response(200, Duration::from_millis(5), true); let state = &selection.lease.member.state; assert_eq!(state.last_status.load(Ordering::Relaxed), 200); assert_eq!(lock(&state.health).passive_result, "ok"); assert!(lock(&state.health).latency_us > 0); - selection.lease.response(503, Duration::from_millis(5), true); + selection + .lease + .response(503, Duration::from_millis(5), true); assert_eq!(state.last_status.load(Ordering::Relaxed), 503); assert_eq!(lock(&state.last_failure).detail, "status=503"); } - -} // mod tests +} diff --git a/src/basic_auth.rs b/src/basic_auth/mod.rs similarity index 92% rename from src/basic_auth.rs rename to src/basic_auth/mod.rs index a1bfd2d..4c1a8e0 100644 --- a/src/basic_auth.rs +++ b/src/basic_auth/mod.rs @@ -1,5 +1,3 @@ -//! Per-vhost RFC 7617 gate. Only password hashes enter desired configuration. -//! ACLs run first; credentials are never cached with content or written to logs. use anyhow::{ensure, Result}; use argon2::{ password_hash::{phc::PasswordHash, PasswordVerifier}, @@ -78,7 +76,6 @@ impl Settings { for user in &self.users { validate_username(&user.username)?; ensure!(names.insert(&user.username), "basic.duplicate_username"); - // Bound KDF cost before accepting imported hashes. No arbitrary PHC parameters. ensure!( user.password_hash.len() <= 256 && user @@ -88,8 +85,6 @@ impl Settings { ); let hash = PasswordHash::new(&user.password_hash) .map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?; - // Match the former 16-character minimum for the PHC-encoded salt. - // phc 0.6 Salt::len() now measures decoded bytes instead. ensure!( hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16) && hash.hash.is_some_and(|h| h.len() == 32), @@ -157,7 +152,6 @@ fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response { .headers_mut() .insert("retry-after", HeaderValue::from_static("60")); } - // Custom error pages must not turn an authentication failure into success. response .extensions_mut() .insert(crate::tinyauth::AuthenticationFailure); @@ -214,7 +208,6 @@ impl Compiled { return Err(denied(StatusCode::TOO_MANY_REQUESTS, None)); } let Some((name, password, original)) = input else { - // A browser's first request normally has no Authorization; do not count that as a bad password. return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge))); }; let found = self.settings.users.iter().find(|u| u.username == name); @@ -254,4 +247,4 @@ impl Compiled { } #[cfg(test)] -include!("basic_auth/tests.rs"); +include!("tests.rs"); diff --git a/src/cache/tests/tests.rs b/src/cache/cache_regression_tests.rs similarity index 87% rename from src/cache/tests/tests.rs rename to src/cache/cache_regression_tests.rs index d8f18be..60b86f2 100644 --- a/src/cache/tests/tests.rs +++ b/src/cache/cache_regression_tests.rs @@ -75,13 +75,25 @@ mod tests { let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::from("zero-freshness")); *response.headers_mut() = headers(control); - let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO) - .expect("valid zero freshness is distinct from a no-store response"); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .expect("valid zero freshness is distinct from a no-store response"); assert_eq!(plan.ttl, 0, "{control}"); - cache.wrap(response, ticket.clone(), plan, &settings) - .into_body().collect().await.unwrap(); + cache + .wrap(response, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); assert_eq!(cache.view().entries, 1, "{control}"); - assert!(cache.get(&ticket).is_none(), "zero-TTL {control} cannot be an unconditional HIT"); + assert!( + cache.get(&ticket).is_none(), + "zero-TTL {control} cannot be an unconditional HIT" + ); } } #[test] @@ -180,7 +192,14 @@ mod tests { let mut single_range = HeaderMap::new(); single_range.insert("range", HeaderValue::from_static("bytes=0-3")); assert_eq!( - request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), + request_policy( + &Method::GET, + &single_range, + true, + "/asset.bin", + false, + &settings() + ), RequestPolicy::Use ); assert!(request_cacheable( @@ -193,7 +212,14 @@ mod tests { )); single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9")); assert_eq!( - request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), + request_policy( + &Method::GET, + &single_range, + true, + "/asset.bin", + false, + &settings() + ), RequestPolicy::Bypass("request_range_unsupported") ); } @@ -218,11 +244,23 @@ mod tests { fn request_max_stale_is_bounded_and_respects_revalidation_flags() { let unbounded = headers("max-stale"); let bounded = headers("max-stale=15"); - assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false)); - assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false)); + assert_eq!( + stale_mode(120, 60, 0, false, false, false, &unbounded), + Some(false) + ); + assert_eq!( + stale_mode(70, 60, 0, false, false, false, &bounded), + Some(false) + ); assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None); - assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true)); - assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None); + assert_eq!( + stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), + Some(true) + ); + assert_eq!( + stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), + None + ); assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None); assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None); } @@ -268,9 +306,21 @@ mod tests { origin_last_modified: None, }; assert!(base.can_stale_if_error()); - assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error()); - assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error()); - assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error()); + assert!(!Revalidation { + age: 91, + ..base.clone() + } + .can_stale_if_error()); + assert!(!Revalidation { + must_revalidate: true, + ..base.clone() + } + .can_stale_if_error()); + assert!(!Revalidation { + proxy_revalidate: true, + ..base + } + .can_stale_if_error()); } #[test] @@ -378,14 +428,10 @@ mod tests { json: true, ..Default::default() }; - let transformed = crate::transform::apply( - response, - "/data.json", - &transform_settings, - false, - ) - .await - .unwrap(); + let transformed = + crate::transform::apply(response, "/data.json", &transform_settings, false) + .await + .unwrap(); let transformed_plan = plan( StatusCode::OK, transformed.headers(), @@ -403,7 +449,9 @@ mod tests { let first = wrapped.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&first[..], br#"{"message":"hello"}"#); - let hit = cache.get(&ticket).expect("transformed representation should be cached"); + let hit = cache + .get(&ticket) + .expect("transformed representation should be cached"); assert_eq!(hit.response.headers()["etag"], expected_etag); assert_eq!( hit.response.headers()["content-length"].to_str().unwrap(), @@ -460,10 +508,9 @@ mod tests { "cache-control", HeaderValue::from_static("public, max-age=60"), ); - response.headers_mut().insert( - "content-type", - HeaderValue::from_str(content_type).unwrap(), - ); + response + .headers_mut() + .insert("content-type", HeaderValue::from_str(content_type).unwrap()); response.headers_mut().insert( "content-length", HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), @@ -496,14 +543,9 @@ mod tests { "json" => transform_settings.json = true, _ => unreachable!(), } - let transformed = crate::transform::apply( - response, - path, - &transform_settings, - false, - ) - .await - .unwrap(); + let transformed = crate::transform::apply(response, path, &transform_settings, false) + .await + .unwrap(); let transformed_plan = plan( StatusCode::OK, transformed.headers(), @@ -521,9 +563,15 @@ mod tests { ); let first = wrapped.into_body().collect().await.unwrap().to_bytes(); assert!(!first.is_empty(), "{kind}"); - assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture"); + assert_ne!( + first.as_ref(), + origin_body, + "{kind} should change the chosen fixture" + ); - let hit = cache.get(&ticket).expect("transformed representation should be cached"); + let hit = cache + .get(&ticket) + .expect("transformed representation should be cached"); assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}"); assert_eq!( hit.response.headers()["content-length"], @@ -701,7 +749,9 @@ mod tests { let mut s = Settings::default(); s.transient_max_bytes = 128 * 1024; s.cold_queue_max_bytes = 64 * 1024; - let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation"); + let first = cache + .try_reserve_transient(96 * 1024, &s) + .expect("first reservation"); assert!(cache.try_reserve_transient(40 * 1024, &s).is_none()); assert_eq!(cache.view().transient_bytes, 96 * 1024); drop(first); @@ -756,9 +806,7 @@ mod tests { must_revalidate: false, proxy_revalidate: false, origin_etag: Some(HeaderValue::from_static("W/\"origin\"")), - origin_last_modified: Some(HeaderValue::from_static( - "Mon, 26 Jan 2026 23:11:40 GMT", - )), + origin_last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")), }; let mut matching = HeaderMap::new(); matching.insert("if-none-match", HeaderValue::from_static("\"origin\"")); @@ -781,13 +829,7 @@ mod tests { let mut response_headers = headers("public, max-age=60"); response_headers.insert("etag", public_etag.clone()); response_headers.insert("last-modified", modified.clone()); - let plan = plan( - StatusCode::OK, - &response_headers, - &settings, - Duration::ZERO, - ) - .unwrap(); + let plan = plan(StatusCode::OK, &response_headers, &settings, Duration::ZERO).unwrap(); let mut response = Response::new(Body::from("transformed")); *response.headers_mut() = response_headers; response @@ -818,9 +860,15 @@ mod tests { assert_eq!(client_conditional["if-modified-since"], modified); let mut validation = HeaderMap::new(); - validation.insert("cache-control", HeaderValue::from_static("public, max-age=60")); + validation.insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); validation.insert("etag", HeaderValue::from_static("\"origin-new\"")); - validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT")); + validation.insert( + "last-modified", + HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"), + ); let merged = cached.merge_validation_headers(&validation); assert_eq!(merged["etag"], public_etag); } @@ -915,18 +963,13 @@ mod tests { let mut origin_headers = headers("public, max-age=60"); origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding")); origin_headers.insert("content-length", HeaderValue::from_static("7")); - let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO) - .expect("origin plan"); + let origin_plan = + plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan"); let mut transformed_headers = headers("public, max-age=60"); transformed_headers.insert("content-length", HeaderValue::from_static("7")); - let transformed_plan = plan( - StatusCode::OK, - &transformed_headers, - &s, - Duration::ZERO, - ) - .expect("transformed plan"); + let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO) + .expect("transformed plan"); let cache_plan = origin_plan .without_vary("accept-encoding") .restrict(transformed_plan); @@ -962,21 +1005,13 @@ mod tests { dark.insert("x-theme", HeaderValue::from_static("dark")); let mut origin_headers = headers("public, max-age=60"); - origin_headers.insert( - "vary", - HeaderValue::from_static("Accept-Encoding, X-Theme"), - ); - let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO) - .expect("origin plan"); + origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme")); + let origin_plan = + plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan"); let mut transformed_headers = headers("public, max-age=60"); transformed_headers.insert("vary", HeaderValue::from_static("X-Theme")); - let transformed_plan = plan( - StatusCode::OK, - &transformed_headers, - &s, - Duration::ZERO, - ) - .expect("transformed plan"); + let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO) + .expect("transformed plan"); let cache_plan = origin_plan .without_vary("accept-encoding") .restrict(transformed_plan); @@ -1067,11 +1102,20 @@ mod tests { let entry = store.entries.values_mut().next().expect("stored object"); entry.received = Instant::now() - Duration::from_secs(2); } - assert!(cache.get(&initial).is_none(), "normal request must not receive stale data"); - assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object"); + assert!( + cache.get(&initial).is_none(), + "normal request must not receive stale data" + ); + assert_eq!( + cache.view().entries, + 1, + "freshness expiry must not physically evict the object" + ); let stale = ticket(&cache, &headers("max-stale")); - let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data"); + let hit = cache + .get(&stale) + .expect("explicit max-stale may reuse retained stale data"); assert!(hit.stale); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), @@ -1107,7 +1151,14 @@ mod tests { validation.insert("etag", HeaderValue::from_static("\"origin\"")); let merged = candidate.merge_validation_headers(&validation); let hit = cache - .publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO) + .publish_revalidated( + t.clone(), + &candidate, + merged, + &validation, + &s, + Duration::ZERO, + ) .expect("validated body can be delivered"); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), @@ -1150,7 +1201,14 @@ mod tests { validation.insert("etag", HeaderValue::from_static("\"origin\"")); let merged = candidate.merge_validation_headers(&validation); let hit = cache - .publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2)) + .publish_revalidated( + t, + &candidate, + merged, + &validation, + &s, + Duration::from_secs(2), + ) .expect("validated object"); assert!(hit.resident_age_seconds >= 32); } @@ -1262,7 +1320,10 @@ mod tests { fn aged_upstream_freshness_caps_remaining_not_total_age() { let settings = settings(); let mut headers = headers("public, max-age=2592000"); - headers.insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap()); + headers.insert( + "date", + httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(), + ); headers.insert("age", HeaderValue::from_static("1553483")); headers.insert("etag", HeaderValue::from_static("\"origin-etag\"")); let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap(); @@ -1270,20 +1331,29 @@ mod tests { assert_eq!(original.remaining_ttl(), settings.max_ttl_seconds); assert_eq!(original.ttl, 1553483 + settings.max_ttl_seconds); - // Local cap never overrides the remaining origin lifetime. headers.insert("age", HeaderValue::from_static("2591990")); let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap(); assert_eq!(almost_expired.remaining_ttl(), 10); headers.insert("age", HeaderValue::from_static("2592010")); assert_eq!( plan(StatusCode::OK, &headers, &settings, Duration::ZERO) - .unwrap().remaining_ttl(), 0 + .unwrap() + .remaining_ttl(), + 0 + ); + headers.insert( + "cache-control", + HeaderValue::from_static("public, no-cache, max-age=2592000"), + ); + assert!( + plan(StatusCode::OK, &headers, &settings, Duration::ZERO) + .unwrap() + .requires_revalidation + ); + headers.insert( + "cache-control", + HeaderValue::from_static("private, max-age=2592000"), ); - // No-cache and private/no-store restrictions must not be relaxed. - headers.insert("cache-control", HeaderValue::from_static("public, no-cache, max-age=2592000")); - assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO) - .unwrap().requires_revalidation); - headers.insert("cache-control", HeaderValue::from_static("private, max-age=2592000")); assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO).is_none()); } @@ -1291,8 +1361,12 @@ mod tests { fn age_uses_rfc9111_max_of_apparent_and_corrected_age() { let s = settings(); let mut h = headers("public, max-age=3600"); - h.insert("date", httpdate::fmt_http_date(SystemTime::now() - - Duration::from_secs(10)).parse().unwrap()); + h.insert( + "date", + httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10)) + .parse() + .unwrap(), + ); h.insert("age", HeaderValue::from_static("3")); let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap(); assert!(p.initial_age < Duration::from_secs(12)); @@ -1310,30 +1384,76 @@ mod tests { let original_body = b"body { color: red; margin: 0px; }"; let mut upstream = Response::new(Body::from(Bytes::from_static(original_body))); *upstream.headers_mut() = headers("public, max-age=2592000"); - upstream.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap()); - upstream.headers_mut().insert("age", HeaderValue::from_static("1553483")); - upstream.headers_mut().insert("content-type", HeaderValue::from_static("text/css")); - upstream.headers_mut().insert("content-length", - HeaderValue::from_str(&original_body.len().to_string()).unwrap()); - upstream.headers_mut().insert("etag", HeaderValue::from_static("\"upstream\"")); - let origin_plan = plan(StatusCode::OK, upstream.headers(), &settings, Duration::ZERO).unwrap(); + upstream.headers_mut().insert( + "date", + httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(), + ); + upstream + .headers_mut() + .insert("age", HeaderValue::from_static("1553483")); + upstream + .headers_mut() + .insert("content-type", HeaderValue::from_static("text/css")); + upstream.headers_mut().insert( + "content-length", + HeaderValue::from_str(&original_body.len().to_string()).unwrap(), + ); + upstream + .headers_mut() + .insert("etag", HeaderValue::from_static("\"upstream\"")); + let origin_plan = plan( + StatusCode::OK, + upstream.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); let transformed = crate::transform::apply( - upstream, "/theme.css", &crate::transform::Settings { - enabled: true, css: true, ..Default::default() - }, false - ).await.unwrap(); - assert!(transformed.extensions().get::().is_some()); + upstream, + "/theme.css", + &crate::transform::Settings { + enabled: true, + css: true, + ..Default::default() + }, + false, + ) + .await + .unwrap(); + assert!(transformed + .extensions() + .get::() + .is_some()); assert_eq!(transformed.headers()["age"], "1553483"); - let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings, - Duration::ZERO).unwrap(); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); let combined = origin_plan.restrict(transformed_plan); assert!(combined.remaining_ttl() > 86_000); - let first = cache.wrap(transformed, ticket.clone(), combined, &settings) - .into_body().collect().await.unwrap().to_bytes(); - let hit = cache.get(&ticket).expect("aged, fresh CSS should be cached after transform"); + let first = cache + .wrap(transformed, ticket.clone(), combined, &settings) + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let hit = cache + .get(&ticket) + .expect("aged, fresh CSS should be cached after transform"); assert!(hit.transformed); assert!(hit.remaining_ttl >= 86_000); - assert!(hit.response.headers()["age"].to_str().unwrap().parse::().unwrap() >= 1553483); + assert!( + hit.response.headers()["age"] + .to_str() + .unwrap() + .parse::() + .unwrap() + >= 1553483 + ); let second = hit.response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(first, second); let dash = cache.dashboard(None, 0, 10); @@ -1379,18 +1499,34 @@ mod tests { #[tokio::test] async fn hot_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() { let cache = Cache::new(1); - let settings = Settings { max_entries: 2, ..settings() }; + let settings = Settings { + max_entries: 2, + ..settings() + }; let h = HeaderMap::new(); - let a = cache.ticket(1, "http", "example.test", "app", "/a", &h).unwrap(); - let b = cache.ticket(1, "http", "example.test", "app", "/b", &h).unwrap(); - let c = cache.ticket(1, "http", "example.test", "app", "/c", &h).unwrap(); + let a = cache + .ticket(1, "http", "example.test", "app", "/a", &h) + .unwrap(); + let b = cache + .ticket(1, "http", "example.test", "app", "/b", &h) + .unwrap(); + let c = cache + .ticket(1, "http", "example.test", "app", "/c", &h) + .unwrap(); fill(&cache, a.clone(), "payload-a", &settings).await; fill(&cache, b.clone(), "payload-b", &settings).await; assert_eq!(cache.get(&a).unwrap().hits, 1); assert_eq!(cache.get(&a).unwrap().hits, 2); fill(&cache, c.clone(), "payload-c", &settings).await; - assert!(cache.get(&b).is_none(), "the least recently used object is evicted"); - assert_eq!(cache.get(&a).unwrap().hits, 3, "HOT hits do not reset on LRU refresh"); + assert!( + cache.get(&b).is_none(), + "the least recently used object is evicted" + ); + assert_eq!( + cache.get(&a).unwrap().hits, + 3, + "HOT hits do not reset on LRU refresh" + ); assert_eq!(cache.get(&c).unwrap().hits, 1); assert_eq!(cache.view().evictions, 1); assert_eq!(cache.view().entries, 2); @@ -1423,7 +1559,10 @@ mod tests { assert!(after.hot_allocated_bytes <= s.max_bytes); assert_eq!(after.fill_bytes_active, 0); assert_eq!(after.transient_bytes, 0); - assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation"); + assert!( + cache.get(&first).is_none(), + "LRU entry should make room before the next fill allocation" + ); assert!(cache.get(&second).is_some()); assert!(after.evictions >= 1); } @@ -1477,8 +1616,6 @@ mod tests { &a, ) .unwrap(); - // Compare unrelated headers/member choice within the same frontend - // scheme. HTTP and HTTPS intentionally have distinct cache identities. let other_member = cache .ticket( 1, @@ -1559,12 +1696,17 @@ mod tests { assert_eq!(first, Bytes::from_static(b"first-")); drop(body); - assert!(cache.get(&t).is_none(), "partial body must never become a HIT"); + assert!( + cache.get(&t).is_none(), + "partial body must never become a HIT" + ); assert_eq!(cache.view().fills_active, 0); assert_eq!(cache.view().rejected_fills, 1); fill(&cache, t.clone(), "complete", &s).await; - let hit = cache.get(&t).expect("a later complete GET can fill the cache"); + let hit = cache + .get(&t) + .expect("a later complete GET can fill the cache"); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"complete") @@ -1604,11 +1746,18 @@ mod tests { let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::empty()); *response.headers_mut() = headers("public, max-age=600"); - response.headers_mut().insert("content-length", HeaderValue::from_static("0")); - let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("0")); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); let response = cache.wrap(response, ticket.clone(), plan, &settings); assert!(response.body().is_end_stream()); - // HTTP/1.1 Content-Length: 0 does not ask the response body for frames. drop(response); assert!(cache.get(&ticket).is_some()); assert_eq!(cache.view().entries, 1); @@ -1623,8 +1772,16 @@ mod tests { let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::empty()); *response.headers_mut() = headers("public, max-age=600"); - response.headers_mut().insert("content-length", HeaderValue::from_static("6")); - let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("6")); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); drop(cache.wrap(response, ticket.clone(), plan, &settings)); assert!(cache.get(&ticket).is_none()); assert_eq!(cache.view().fills_active, 0); @@ -1638,8 +1795,16 @@ mod tests { let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::empty()); *response.headers_mut() = headers("public, max-age=600"); - response.headers_mut().insert("content-length", HeaderValue::from_static("0")); - let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("0")); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); cache.purge(None); drop(cache.wrap(response, ticket.clone(), plan, &settings)); assert!(cache.get(&ticket).is_none()); diff --git a/src/cache/cold/cleanup.rs b/src/cache/cold/cleanup.rs index 43e7c9e..80337ed 100644 --- a/src/cache/cold/cleanup.rs +++ b/src/cache/cold/cleanup.rs @@ -14,10 +14,6 @@ impl Cache { } } } - // Do not create an unbounded number of detached delete tasks when the - // writer queue is saturated. A tiny fallback pool absorbs short bursts; - // sustained saturation falls back to synchronous removal so stale files - // cannot be resurrected on restart at the cost of bounded backpressure. if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() { tokio::spawn(async move { let _permit = permit; @@ -51,8 +47,6 @@ impl Cache { let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1)); let mut after_sequence = 0u64; - // Copy candidate metadata in bounded chunks. Request-path lookups only - // contend with this mutex for a small snapshot, never for sorting or I/O. loop { let batch: Vec<_> = { let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); @@ -152,8 +146,6 @@ impl Cache { } } } - // Keep pending deletion metadata bounded to one selection batch; a - // 10k-entry manual cleanup must not build one large Vec. self.enqueue_cold_delete(std::mem::take(&mut delete)); tokio::task::yield_now().await; } diff --git a/src/cache/cold/cold_tier_tests.rs b/src/cache/cold/cold_tier_tests.rs new file mode 100644 index 0000000..e80c1a6 --- /dev/null +++ b/src/cache/cold/cold_tier_tests.rs @@ -0,0 +1,955 @@ +mod cold_tier_tests { + use super::*; + use http_body_util::BodyExt; + + fn cold_settings() -> Settings { + Settings { + enabled: true, + max_entries: 1, + max_bytes: 256 * 1024, + max_object_bytes: 64 * 1024, + cold_enabled: true, + cold_path: "cache/cold-test".into(), + cold_max_bytes: 2 * 1024 * 1024, + cold_max_entries: 16, + cold_after_seconds: 300, + ..Settings::default() + } + } + + async fn fill( + cache: &Arc, + settings: &Settings, + path: &str, + body: &'static str, + ) -> Ticket { + let ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + path, + &HeaderMap::new(), + ) + .unwrap(); + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120"), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"cold-v1\"")); + let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap(); + cache + .wrap(response, ticket.clone(), plan, settings) + .into_body() + .collect() + .await + .unwrap(); + ticket + } + + async fn wait_for_cold(cache: &Cache, expected: usize) { + for _ in 0..100 { + if cache.view().cold_entries >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cold tier did not persist in time"); + } + + #[tokio::test] + async fn selective_purge_removes_matching_cold_object() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let first = fill(&cache, &settings, "/one?v=1", "first-payload").await; + let _second = fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().cold_entries, 1); + let result = cache + .invalidate(InvalidationRequest { + operation: InvalidationOperation::Purge, + selector: InvalidationSelector::Exact, + value: "/one?v=1".into(), + host: Some("cold.example".into()), + vhost: Some("cold-test".into()), + ttl_seconds: None, + confirm_wide: false, + }) + .unwrap(); + assert_eq!(result.affected_entries, 1); + assert_eq!(cache.view().cold_entries, 0); + assert!(cache.get_tiered(&first, &settings).await.is_none()); + shutdown.cancel(); + } + + #[tokio::test] + async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + "/aged-theme.css", + &HeaderMap::new(), + ) + .unwrap(); + let body = b"body { color: red; margin: 0px; }"; + let mut origin = Response::new(Body::from(Bytes::from_static(body))); + origin.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=2592000"), + ); + origin + .headers_mut() + .insert("age", HeaderValue::from_static("1553483")); + origin.headers_mut().insert( + "date", + httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(), + ); + origin + .headers_mut() + .insert("content-type", HeaderValue::from_static("text/css")); + origin.headers_mut().insert( + "content-length", + HeaderValue::from_str(&body.len().to_string()).unwrap(), + ); + origin + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + let origin_plan = + plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap(); + let transformed = crate::transform::apply( + origin, + "/aged-theme.css", + &crate::transform::Settings { + enabled: true, + css: true, + ..Default::default() + }, + false, + ) + .await + .unwrap(); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap( + transformed, + ticket.clone(), + origin_plan.restrict(transformed_plan), + &settings, + ) + .into_body() + .collect() + .await + .unwrap(); + assert_eq!(cache.view().entries, 1); + let _another = fill(&cache, &settings, "/another", "another-payload").await; + wait_for_cold(&cache, 1).await; + let row = cache + .dashboard(Some("cold-test"), 0, 20) + .objects + .into_iter() + .find(|v| v.path == "/aged-theme.css") + .unwrap(); + assert_eq!(row.tier, "cold"); + assert!(row.transformed); + assert!(row.remaining_ttl_seconds >= 86_000); + shutdown.cancel(); + drop(cache); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let hit = cache + .get_tiered(&ticket, &settings) + .await + .expect("aged transformed COLD should HIT after restart"); + assert!(hit.transformed); + assert!(hit.remaining_ttl >= 86_000); + let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_ne!(transformed_body.as_ref(), &body[..]); + shutdown.cancel(); + } + + #[tokio::test] + async fn lru_demotion_persists_and_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + settings.validate().unwrap(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let first = fill(&cache, &settings, "/one", "first-payload").await; + let _second = fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 1); + let cold = cache.dashboard(Some("cold-test"), 0, 50); + assert!(cold + .objects + .iter() + .any(|row| row.path == "/one" && row.tier == "cold")); + + shutdown.cancel(); + drop(cache); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 1); + let hit = restarted + .get_tiered(&first, &settings) + .await + .expect("cold object should be readable after restart"); + let payload = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(payload, Bytes::from_static(b"first-payload")); + assert_eq!(restarted.view().cold_hits, 1); + assert_eq!(restarted.view().cold_promotions, 1); + restart_shutdown.cancel(); + } + + #[tokio::test] + async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let first = fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + { + let mut cold = cache.cold.lock().unwrap(); + for entry in cold.entries.values_mut() { + entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60); + } + } + let dashboard = cache.dashboard(Some("cold-test"), 0, 50); + assert_eq!(dashboard.matching_objects, 2); + let row = dashboard + .objects + .iter() + .find(|row| row.path == "/one") + .unwrap(); + assert_eq!(row.tier, "cold"); + assert_eq!(row.remaining_ttl_seconds, 0); + assert!(row.age_seconds >= 2 * 60 * 60); + assert_eq!(row.storage_bytes, cache.view().cold_bytes); + assert!(cache.get_tiered(&first, &settings).await.is_none()); + let mut stale = first; + stale + .request_headers + .insert("cache-control", HeaderValue::from_static("max-stale")); + let hit = cache + .get_tiered(&stale, &settings) + .await + .expect("explicit max-stale can reuse the retained disk object"); + assert!(hit.stale); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + "first-payload" + ); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + + let transformed_ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + "/transformed.json", + &HeaderMap::new(), + ) + .unwrap(); + let mut transformed = Response::new(Body::from(r#"{"a":1}"#)); + transformed.headers_mut().insert( + "cache-control", + HeaderValue::from_static( + "public, max-age=120, stale-while-revalidate=30, stale-if-error=90", + ), + ); + transformed.headers_mut().insert( + "etag", + HeaderValue::from_static("\"rustproxy-representation\""), + ); + transformed + .headers_mut() + .insert("content-length", HeaderValue::from_static("7")); + transformed + .extensions_mut() + .insert(crate::transform::OriginValidators { + etag: Some(HeaderValue::from_static("\"origin-v1\"")), + last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")), + }); + transformed + .extensions_mut() + .insert(crate::transform::Applied { + kind: crate::transform::Kind::Json, + original_bytes: 9, + transformed_bytes: 7, + }); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap( + transformed, + transformed_ticket.clone(), + transformed_plan, + &settings, + ) + .into_body() + .collect() + .await + .unwrap(); + + let protected_ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + "/protected.css", + &HeaderMap::new(), + ) + .unwrap(); + let original_body = "body { color: red; }"; + let mut protected = Response::new(Body::from(original_body)); + protected.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120, immutable, no-transform"), + ); + protected + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin-css\"")); + protected.extensions_mut().insert(crate::compression::Skip); + let protected_plan = plan( + StatusCode::OK, + protected.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap( + protected, + protected_ticket.clone(), + protected_plan, + &settings, + ) + .into_body() + .collect() + .await + .unwrap(); + + assert_eq!(cache.flush_hot_to_cold().await, 2); + wait_for_cold(&cache, 2).await; + shutdown.cancel(); + drop(cache); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 2); + let (key, transformed) = { + let cold = restarted.cold.lock().unwrap(); + cold.entries + .iter() + .find(|(_, entry)| entry.path == "/transformed.json") + .map(|(key, entry)| (*key, entry.clone())) + .expect("transformed cold entry") + }; + assert_eq!(transformed.stale_while_revalidate, 30); + assert_eq!(transformed.stale_if_error, 90); + assert!(transformed.has_validator); + assert!(transformed.transformed); + let metadata = read_cold_metadata(&key, &transformed) + .await + .expect("persisted transformed metadata"); + assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\""); + assert_eq!( + metadata.origin_last_modified.as_ref().unwrap(), + "Mon, 26 Jan 2026 23:11:40 GMT" + ); + assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\""); + + let transformed_hit = restarted + .get_tiered(&transformed_ticket, &settings) + .await + .expect("transformed object after restart"); + assert_eq!( + transformed_hit.response.headers()["etag"], + "\"rustproxy-representation\"" + ); + assert!(transformed_hit.transformed); + assert!(transformed_hit + .response + .extensions() + .get::() + .is_some()); + assert_eq!( + transformed_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(br#"{"a":1}"#) + ); + + let protected_hit = restarted + .get_tiered(&protected_ticket, &settings) + .await + .expect("no-transform object after restart"); + assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\""); + assert!(crate::transform::no_transform( + protected_hit.response.headers() + )); + assert!(protected_hit + .response + .extensions() + .get::() + .is_some()); + assert_eq!( + protected_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(b"body { color: red; }") + ); + restart_shutdown.cancel(); + } + + #[tokio::test] + async fn cold_policy_can_disable_lru_demotion() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.cold_on_lru = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + tokio::time::sleep(Duration::from_millis(30)).await; + let view = cache.view(); + assert_eq!(view.entries, 1); + assert_eq!(view.cold_entries, 0); + assert_eq!(view.evictions, 1); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_policy_can_disable_idle_demotion() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_idle = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/idle", "idle-payload").await; + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.last_access = Instant::now() - Duration::from_secs(600); + } + } + cache.demote_idle(); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 0); + + settings.cold_on_idle = true; + cache.configure(settings.clone()); + cache.demote_idle(); + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 0); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_policy_can_demote_by_total_hot_residency_age() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_idle = false; + settings.cold_on_age = true; + settings.cold_max_hot_seconds = 5; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/aged", "aged-payload").await; + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.resident_since = Instant::now() - Duration::from_secs(60); + entry.last_access = Instant::now(); + } + } + cache.demote_idle(); + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 0); + assert_eq!(cache.view().cold_entries, 1); + shutdown.cancel(); + } + + #[tokio::test] + async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + assert_eq!(cache.view().entries, 2); + assert!(cache.view().resident_bytes > 0); + assert_eq!(cache.view().cold_entries, 0); + + assert_eq!(cache.flush_hot_to_cold().await, 2); + let view = cache.view(); + assert_eq!(view.entries, 0); + assert_eq!(view.resident_bytes, 0); + assert_eq!(view.hot_logical_bytes, 0); + assert_eq!( + view.transient_bytes, 0, + "persisted ColdEntry must not retain the body allocation" + ); + assert_eq!(view.cold_entries, 2); + shutdown.cancel(); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 2); + restart_shutdown.cancel(); + } + + #[tokio::test] + async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_shutdown = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/update", "update-payload").await; + assert_eq!(cache.flush_hot_to_cold().await, 0); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.flush_hot_to_cold_for_update().await, 1); + let view = cache.view(); + assert_eq!(view.entries, 0); + assert_eq!(view.resident_bytes, 0); + assert_eq!(view.cold_entries, 1); + shutdown.cancel(); + } + + #[tokio::test] + async fn graceful_shutdown_flush_can_be_disabled() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_shutdown = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + assert_eq!(cache.flush_hot_to_cold().await, 0); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 0); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_cache_uses_two_hex_character_shards() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + let root = dir.path().join(&settings.cold_path); + let mut shards = Vec::new(); + for item in std::fs::read_dir(&root).unwrap() { + let item = item.unwrap(); + let name = item.file_name().to_string_lossy().to_string(); + assert!( + item.path().is_dir(), + "cold root must contain shard directories only" + ); + assert_eq!(name.len(), 2); + assert!(name.bytes().all(|b| b.is_ascii_hexdigit())); + shards.push(item.path()); + } + assert!(!shards.is_empty()); + assert!(shards.iter().any(|shard| { + std::fs::read_dir(shard) + .unwrap() + .filter_map(|entry| entry.ok()) + .any(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + name.ends_with(".json") || name.ends_with(".body") + }) + })); + shutdown.cancel(); + } + + #[tokio::test] + async fn legacy_flat_cold_cache_is_not_migrated() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let root = dir.path().join(&settings.cold_path); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("legacy.body"), b"old").unwrap(); + std::fs::write(root.join("legacy.json"), b"{}").unwrap(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings, + shutdown.clone(), + ); + assert_eq!(cache.view().cold_entries, 0); + assert!(!root.join("legacy.body").exists()); + assert!(!root.join("legacy.json").exists()); + shutdown.cancel(); + } + + #[tokio::test] + async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 1; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + fill(&cache, &settings, "/three", "third-payload").await; + wait_for_cold(&cache, 2).await; + let job = cache + .start_cold_cleanup(ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }) + .unwrap(); + for _ in 0..100 { + let current = cache.cold_cleanup_job(job.id).unwrap(); + if current.status == "done" { + assert_eq!(current.removed_entries, 1); + assert!(current.freed_bytes > 0); + assert_eq!(cache.view().cold_entries, 1); + shutdown.cancel(); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cold cleanup did not finish"); + } + + #[tokio::test] + async fn manual_cold_cleanup_is_single_flight() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings, + shutdown.clone(), + ); + let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap(); + let result = cache.start_cold_cleanup(ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }); + assert!(result.is_err()); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 1; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + + let key = { + let mut cold = cache.cold.lock().unwrap(); + let key = *cold.entries.keys().next().unwrap(); + cold.entries.get_mut(&key).unwrap().pinned_reads = 1; + key + }; + let request = ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }; + let result = cache + .remove_cold_candidates(Some(&request), None, None, 1) + .await; + assert_eq!(result.removed_entries, 0); + assert!(cache.cold.lock().unwrap().entries.contains_key(&key)); + + cache + .cold + .lock() + .unwrap() + .entries + .get_mut(&key) + .unwrap() + .pinned_reads = 0; + let result = cache + .remove_cold_candidates(Some(&request), None, None, 1) + .await; + assert_eq!(result.removed_entries, 1); + assert!(!cache.cold.lock().unwrap().entries.contains_key(&key)); + shutdown.cancel(); + } + + #[test] + fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() { + let cache = Cache::new(1); + let key = [7_u8; 32]; + let template = ColdEntry { + base_key: key, + vary: Vec::new(), + revision: 1, + scope: "app".to_owned(), + scheme: "https".to_owned(), + authority: "example.test".to_owned(), + domain: "example.test".to_owned(), + path: "/asset".to_owned(), + target: "https://example.test/asset".to_owned(), + tags: Vec::new(), + created_at: SystemTime::now(), + body_path: PathBuf::from("old.body"), + meta_path: PathBuf::from("old.json"), + body_bytes: 1, + body_sha256: [0u8; 32], + disk_bytes: 2, + stored_at: SystemTime::now(), + last_access: SystemTime::now(), + age_at_store_seconds: 0, + resident_age_at_store_seconds: 0, + ttl: 60, + stale_while_revalidate: 0, + stale_if_error: 0, + must_revalidate: false, + proxy_revalidate: false, + has_validator: false, + hits: 0, + transformed: false, + sequence: 1, + generation: 1, + skip_compression: false, + requires_revalidation: false, + pinned_reads: 1, + }; + cache + .cold + .lock() + .unwrap() + .entries + .insert(key, template.clone()); + let old_guard = ColdReadGuard { + cache: &cache, + key, + generation: 1, + }; + + let mut replacement = template; + replacement.sequence = 2; + replacement.generation = 2; + replacement.pinned_reads = 1; + cache.cold.lock().unwrap().entries.insert(key, replacement); + drop(old_guard); + + let cold = cache.cold.lock().unwrap(); + let replacement = cold.entries.get(&key).unwrap(); + assert_eq!(replacement.generation, 2); + assert_eq!(replacement.pinned_reads, 1); + } + + #[test] + fn cold_path_cannot_escape_data_directory() { + let mut settings = Settings::default(); + settings.cold_path = "../outside".into(); + assert!(settings.validate().is_err()); + settings.cold_path = "/tmp/outside".into(); + assert!(settings.validate().is_err()); + settings.cold_path = "cache/cold".into(); + assert!(settings.validate().is_ok()); + } + + #[tokio::test] + async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let mut fingerprints = HashMap::new(); + fingerprints.insert("cold-test".to_owned(), 77); + let cache = Cache::start_with_scope_revisions( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + fingerprints.clone(), + ); + fill(&cache, &settings, "/one", "stable-contents").await; + fill(&cache, &settings, "/two", "second-contents").await; + wait_for_cold(&cache, 1).await; + shutdown.cancel(); + drop(cache); + + let restart = CancellationToken::new(); + let same = Cache::start_with_scope_revisions( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart.clone(), + fingerprints.clone(), + ); + assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD"); + restart.cancel(); + drop(same); + + fingerprints.insert("cold-test".to_owned(), 78); + let restart = CancellationToken::new(); + let changed = Cache::start_with_scope_revisions( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart.clone(), + fingerprints, + ); + assert_eq!( + changed.view().cold_entries, + 0, + "old COLD must never survive a content revision" + ); + restart.cancel(); + } +} diff --git a/src/cache/cold/demotion.rs b/src/cache/cold/demotion.rs index a15c063..d94832f 100644 --- a/src/cache/cold/demotion.rs +++ b/src/cache/cold/demotion.rs @@ -51,9 +51,13 @@ impl Cache { .fetch_add(1, Ordering::Relaxed); return false; }; - let scope_revision = self.scope_revisions - .lock().unwrap_or_else(|e| e.into_inner()) - .get(&entry.scope).copied().unwrap_or(0); + let scope_revision = self + .scope_revisions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&entry.scope) + .copied() + .unwrap_or(0); self.cold_queue_items.fetch_add(1, Ordering::Relaxed); if tx .try_send(ColdCommand::Store(ColdDraft { @@ -138,9 +142,6 @@ impl Cache { drop(store); release_allocator_memory(); } - /// Best-effort graceful shutdown handoff from RAM to the persistent cold - /// tier. The cold worker shares the process shutdown token, so this path - /// writes directly instead of depending on the background queue. async fn flush_hot_to_cold_inner(&self, force_shutdown_policy: bool) -> usize { let settings = self .tier_settings @@ -190,9 +191,13 @@ impl Cache { }), } }); - let scope_revision = self.scope_revisions - .lock().unwrap_or_else(|e| e.into_inner()) - .get(&entry.scope).copied().unwrap_or(0); + let scope_revision = self + .scope_revisions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&entry.scope) + .copied() + .unwrap_or(0); ColdDraft { key, revision, @@ -215,9 +220,6 @@ impl Cache { pub async fn flush_hot_to_cold(&self) -> usize { self.flush_hot_to_cold_inner(false).await } - /// Managed software updates explicitly request a hot-to-cold handoff. The - /// cold tier must still be enabled, but the ordinary cold_on_shutdown toggle - /// does not disable update preservation. pub async fn flush_hot_to_cold_for_update(&self) -> usize { self.flush_hot_to_cold_inner(true).await } diff --git a/src/cache/cold/startup.rs b/src/cache/cold/startup.rs index 5a2923c..976ec14 100644 --- a/src/cache/cold/startup.rs +++ b/src/cache/cold/startup.rs @@ -27,8 +27,6 @@ impl Cache { return; }; - // v0.21 intentionally does not migrate the legacy flat cold-cache layout. - // Remove only known cache artifacts from the old root and rebuild naturally. let mut shard_dirs = Vec::new(); for item in read_dir.flatten() { let path = item.path(); diff --git a/src/cache/cold/worker.rs b/src/cache/cold/worker.rs index 04d9914..4550a7f 100644 --- a/src/cache/cold/worker.rs +++ b/src/cache/cold/worker.rs @@ -29,9 +29,6 @@ impl Cache { loop { let command = tokio::select! { _ = shutdown.cancelled() => { - // The listener shutdown token is shared with the cache. Drain - // already accepted demotions before exiting so a restart does - // not discard buffers that were already handed to cold storage. rx.close(); while let Ok(command) = rx.try_recv() { let Some(cache) = weak.upgrade() else { return; }; @@ -58,8 +55,6 @@ impl Cache { self.cold_write_bytes_active .fetch_sub(bytes, Ordering::Relaxed); self.cold_gc_pass().await; - // The draft body is out of scope before trimming. `Bytes` has released its - // reference and glibc is explicitly asked to return releasable pages. release_allocator_memory(); } async fn persist_cold_inner(&self, draft: ColdDraft) -> bool { diff --git a/src/cache/collapse.rs b/src/cache/collapse.rs index 5c73b92..4bad2b7 100644 --- a/src/cache/collapse.rs +++ b/src/cache/collapse.rs @@ -36,10 +36,7 @@ impl Drop for CollapseWaiterGuard { impl Drop for FillLeaderGuard { fn drop(&mut self) { if let Some(cache) = self.cache.upgrade() { - let mut flights = cache - .fill_flights - .lock() - .unwrap_or_else(|e| e.into_inner()); + let mut flights = cache.fill_flights.lock().unwrap_or_else(|e| e.into_inner()); let current = flights .get(&self.key) .is_some_and(|flight| flight.id == self.id); @@ -47,25 +44,17 @@ impl Drop for FillLeaderGuard { flights.remove(&self.key); } } - // Publish completion even when a purge already removed this flight from the map. self.done.send_replace(true); } } impl Cache { - pub async fn claim_fill( - self: &Arc, - ticket: &Ticket, - settings: &Settings, - ) -> FillClaim { + pub async fn claim_fill(self: &Arc, ticket: &Ticket, settings: &Settings) -> FillClaim { if !settings.collapse_enabled { return FillClaim::Disabled; } let existing = { - let mut flights = self - .fill_flights - .lock() - .unwrap_or_else(|e| e.into_inner()); + let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner()); if let Some(flight) = flights.get(&ticket.key) { Some(flight.clone()) } else { @@ -96,8 +85,6 @@ impl Cache { let Some(flight) = existing else { unreachable!("existing fill resolved above") }; - // A watch channel retains the completed state, so a leader finishing between - // map lookup and follower polling cannot lose the wakeup. let mut done = flight.done.subscribe(); self.collapse_waiters.fetch_add(1, Ordering::Relaxed); let _waiter = CollapseWaiterGuard { @@ -131,11 +118,11 @@ impl Cache { fn cancel_all_fill_flights(&self) { let flights = { - let mut flights = self - .fill_flights - .lock() - .unwrap_or_else(|e| e.into_inner()); - flights.drain().map(|(_, flight)| flight).collect::>() + let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner()); + flights + .drain() + .map(|(_, flight)| flight) + .collect::>() }; for flight in flights { flight.done.send_replace(true); @@ -144,10 +131,7 @@ impl Cache { fn cancel_scope_fill_flights(&self, scope: &str) { let removed = { - let mut flights = self - .fill_flights - .lock() - .unwrap_or_else(|e| e.into_inner()); + let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner()); let keys = flights .iter() .filter(|(_, flight)| flight.scope == scope) @@ -210,9 +194,6 @@ impl Cache { .proactive_scheduled_keys .lock() .unwrap_or_else(|e| e.into_inner()); - // Each scheduled key owns a sleeping Tokio task plus refresh context. - // Bound the pending set independently from the cache entry count so a - // large cache with long TTLs cannot turn into thousands of resident tasks. let pending_limit = settings .proactive_refresh_max_inflight .saturating_mul(64) @@ -250,12 +231,7 @@ impl Cache { } if self .proactive_inflight - .compare_exchange_weak( - current, - current + 1, - Ordering::AcqRel, - Ordering::Relaxed, - ) + .compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed) .is_ok() { self.counters diff --git a/src/cache/dashboard.rs b/src/cache/dashboard.rs index 57d620d..ce00649 100644 --- a/src/cache/dashboard.rs +++ b/src/cache/dashboard.rs @@ -99,14 +99,22 @@ impl Cache { ("bans_removed_total", "counter", view.bans_removed), ("ban_matches_total", "counter", view.ban_matches), ("active_bans", "gauge", view.active_bans as u64), - ("proactive_scheduled_total", "counter", view.proactive_scheduled), + ( + "proactive_scheduled_total", + "counter", + view.proactive_scheduled, + ), ("proactive_started_total", "counter", view.proactive_started), ("proactive_skipped_total", "counter", view.proactive_skipped), ("proactive_inflight", "gauge", view.proactive_inflight), ("entries", "gauge", view.entries as u64), ("resident_bytes", "gauge", view.resident_bytes as u64), ("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64), - ("hot_allocated_bytes", "gauge", view.hot_allocated_bytes as u64), + ( + "hot_allocated_bytes", + "gauge", + view.hot_allocated_bytes as u64, + ), ("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64), ( "transient_limit_bytes", @@ -121,7 +129,11 @@ impl Cache { ("cold_entries", "gauge", view.cold_entries as u64), ("cold_bytes", "gauge", view.cold_bytes), ("cold_limit_bytes", "gauge", view.cold_limit_bytes), - ("cold_limit_entries", "gauge", view.cold_limit_entries as u64), + ( + "cold_limit_entries", + "gauge", + view.cold_limit_entries as u64, + ), ("cold_hits_total", "counter", view.cold_hits), ("cold_promotions_total", "counter", view.cold_promotions), ("hot_demotions_total", "counter", view.hot_demotions), @@ -161,7 +173,6 @@ impl Cache { pub fn bypass(&self) { self.counters.bypasses.fetch_add(1, Ordering::Relaxed); } - /// Clear all keys and cancel publication of older in-flight fills. pub fn purge(&self, revision: Option) -> usize { let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let mut count = store.entries.len(); @@ -198,11 +209,11 @@ impl Cache { let _ = tx.try_send(ColdCommand::PurgeDirectory(root)); } self.counters.purges.fetch_add(1, Ordering::Relaxed); - self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); + self.counters + .purge_entries + .fetch_add(count as u64, Ordering::Relaxed); count } - /// Purge one vhost without evicting other hosts. Generation bumps also stop - /// an already streaming MISS from repopulating the just-purged scope. pub fn purge_scope(&self, scope: &str) -> usize { let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let keys: Vec<_> = store @@ -239,7 +250,9 @@ impl Cache { let _ = tx.try_send(ColdCommand::Delete(delete)); } self.counters.purges.fetch_add(1, Ordering::Relaxed); - self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); + self.counters + .purge_entries + .fetch_add(count as u64, Ordering::Relaxed); count } pub fn scope_view(&self, scope: &str) -> ScopeView { @@ -303,8 +316,6 @@ impl Cache { continue; }; let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs(); - // This is an inventory of retained objects, not a freshness lookup. - // Expired representations still occupy RAM and may be revalidated. if !scope.is_none_or(|s| s == e.scope) { continue; } diff --git a/src/cache/tests/dashboard_tests.rs b/src/cache/dashboard_tests.rs similarity index 89% rename from src/cache/tests/dashboard_tests.rs rename to src/cache/dashboard_tests.rs index 1ab4f1f..638b114 100644 --- a/src/cache/tests/dashboard_tests.rs +++ b/src/cache/dashboard_tests.rs @@ -91,9 +91,14 @@ mod dashboard_tests { assert!(dashboard.objects[0].age_seconds >= 2 * 60 * 60); assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().expired, 0); - assert!(cache.get(&ticket).is_none(), "inventory visibility cannot permit unconditional stale delivery"); + assert!( + cache.get(&ticket).is_none(), + "inventory visibility cannot permit unconditional stale delivery" + ); let mut stale = ticket; - stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale")); + stale + .request_headers + .insert("cache-control", HeaderValue::from_static("max-stale")); assert!(cache.get(&stale).unwrap().stale); } #[test] @@ -144,13 +149,22 @@ mod dashboard_tests { }; let mut headers = HeaderMap::new(); headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream")); - headers.insert("cache-status", HeaderValue::from_static("untrusted-upstream")); + headers.insert( + "cache-status", + HeaderValue::from_static("untrusted-upstream"), + ); global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy"); global.annotate_transformed(&mut headers, true); - for name in ["x-cache", "x-cache-hits", "cache-status", "x-cache-reason", "x-cache-resident-age"] { + for name in [ + "x-cache", + "x-cache-hits", + "cache-status", + "x-cache-reason", + "x-cache-resident-age", + ] { assert!(!headers.contains_key(name), "{name} leaked on MISS"); } - assert_eq!(headers["via"], "RustProxy"); // independent proxy policy + assert_eq!(headers["via"], "RustProxy"); global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie"); assert!(!headers.contains_key("cache-status")); global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit"); @@ -159,11 +173,19 @@ mod dashboard_tests { assert_eq!(headers["x-cache-hits"], "4"); assert!(headers["cache-status"].to_str().unwrap().contains("; hit;")); assert_eq!(headers["x-cache-resident-age"], "10"); - let mut override_on = Overrides { expose_only_hits: Some(false), ..Default::default() }; + let mut override_on = Overrides { + expose_only_hits: Some(false), + ..Default::default() + }; let normal = override_on.resolve(&global).unwrap(); assert!(!normal.expose_only_hits); override_on.expose_only_hits = Some(true); - assert!(override_on.resolve(&Settings::default()).unwrap().expose_only_hits); + assert!( + override_on + .resolve(&Settings::default()) + .unwrap() + .expose_only_hits + ); } #[test] fn via_header_is_configurable_and_preserves_upstream_chain() { @@ -178,7 +200,10 @@ mod dashboard_tests { .collect(); assert_eq!(values, vec!["1.1 upstream", "RustProxy"]); - let disabled = Settings { via_header: None, ..Settings::default() }; + let disabled = Settings { + via_header: None, + ..Settings::default() + }; let mut headers = HeaderMap::new(); disabled.annotate(&mut headers, "MISS", 0, 0); assert!(!headers.contains_key("via")); @@ -218,7 +243,6 @@ mod dashboard_tests { #[test] fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() { let cache = Cache::new(7); - // A BYPASS must be recordable even while the HOT/LRU mutex is held. let store = cache.store.lock().unwrap(); cache.record("alpha", "a.example", "BYPASS", "cache_disabled"); cache.record_reason("alpha", "a.example", "background_refresh_failure"); @@ -247,7 +271,12 @@ mod dashboard_tests { assert_eq!(counters.reasons["cache_hit"], workers * each); assert_eq!(counters.reasons["background_refresh_failure"], 1); assert_eq!(cache.purge_scope("alpha"), 0); - assert_eq!(cache.dashboard(Some("alpha"), 0, 1).domains[0].counters.hits, workers * each); + assert_eq!( + cache.dashboard(Some("alpha"), 0, 1).domains[0] + .counters + .hits, + workers * each + ); cache.purge(Some(8)); assert!(cache.dashboard(None, 0, 1).domains.is_empty()); cache.record("alpha", "a.example", "MISS", "cache_miss"); @@ -260,7 +289,9 @@ mod dashboard_tests { #[test] fn first_hit_saved_bytes_registers_domain_even_without_prior_record() { let cache = Cache::new(7); - cache.domain_stats.saved_body_hit("cold", "restored.example", 1040); + cache + .domain_stats + .saved_body_hit("cold", "restored.example", 1040); let result = cache.dashboard(Some("cold"), 0, 1); assert_eq!(result.domains.len(), 1); assert_eq!(result.domains[0].counters.saved_body_bytes, 1040); diff --git a/src/cache/fill.rs b/src/cache/fill.rs index da3a884..531f096 100644 --- a/src/cache/fill.rs +++ b/src/cache/fill.rs @@ -63,8 +63,6 @@ struct Candidate { plan: Plan, headers: HeaderMap, data: Vec, - // A transformed response with one uniquely owned Bytes frame can share its - // allocation with the downstream response, avoiding an extra Vec copy. shared: Option, shared_allocated: usize, fill_gauge: FillGauge, @@ -78,11 +76,9 @@ struct Candidate { } impl Candidate { fn finish(mut self) { - if self - .plan - .expected_length - .is_some_and(|length| self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length) - { + if self.plan.expected_length.is_some_and(|length| { + self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length + }) { let reason = if self.transformed { "transform_length_mismatch" } else { @@ -101,7 +97,6 @@ impl Candidate { self.headers.insert("date", value); } } - // Each downstream exchange receives a new ID, never a cached origin ID. for name in [ "x-request-id", "x-cache", @@ -113,9 +108,6 @@ impl Candidate { self.headers.remove(name); } let vary = std::mem::take(&mut self.plan.vary); - // Keep the actual backing capacity as the HOT charge. Vec::reserve_exact - // avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers - // the allocation without copying. let (body, body_allocation) = if let Some(body) = self.shared.take() { (body, self.shared_allocated) } else { @@ -201,10 +193,6 @@ impl HttpBody for FillBody { match Pin::new(&mut this.inner).poll_frame(cx) { Poll::Ready(Some(Ok(frame))) => { let mut frame = frame; - // Only the transformed single-frame case is eligible. Taking - // unique ownership lets Bytes reveal its true allocation - // capacity; we must never undercharge an arbitrary shared - // Bytes slice that could retain a much larger parent buffer. if this.inner.is_end_stream() { if let Some(candidate) = &mut this.candidate { let single_frame = candidate.transformed @@ -216,26 +204,26 @@ impl HttpBody for FillBody { }); if single_frame { match frame.into_data() { - Ok(bytes) => { - match bytes.try_into_mut() { - Ok(mutable) => { - let allocation = mutable.capacity(); - let bytes = mutable.freeze(); - // A very oversized backing Vec is cheaper to - // copy into a smaller HOT allocation instead. - let acceptable_spare = bytes.len().saturating_div(8).min(64 * 1024); - if allocation <= bytes.len().saturating_add(acceptable_spare) - && allocation <= candidate.settings.max_object_bytes - && candidate.fill_gauge.grow(allocation, &candidate.settings) - { - candidate.shared = Some(bytes.clone()); - candidate.shared_allocated = allocation; - } - frame = Frame::data(bytes); + Ok(bytes) => match bytes.try_into_mut() { + Ok(mutable) => { + let allocation = mutable.capacity(); + let bytes = mutable.freeze(); + let acceptable_spare = + bytes.len().saturating_div(8).min(64 * 1024); + if allocation + <= bytes.len().saturating_add(acceptable_spare) + && allocation <= candidate.settings.max_object_bytes + && candidate + .fill_gauge + .grow(allocation, &candidate.settings) + { + candidate.shared = Some(bytes.clone()); + candidate.shared_allocated = allocation; } - Err(bytes) => frame = Frame::data(bytes), + frame = Frame::data(bytes); } - } + Err(bytes) => frame = Frame::data(bytes), + }, Err(original) => frame = original, } } @@ -243,7 +231,6 @@ impl HttpBody for FillBody { } if let Some(candidate) = &mut this.candidate { if candidate.shared.is_some() { - // The cache now owns the very same backing allocation. } else if let Some(data) = frame.data_ref() { let needed = candidate.data.len().saturating_add(data.len()); if needed <= candidate.settings.max_object_bytes { @@ -284,7 +271,8 @@ impl HttpBody for FillBody { this.candidate = None; } else { let after = candidate.data.capacity(); - let unbudgeted = after.saturating_sub(before.saturating_add(required_growth)); + let unbudgeted = + after.saturating_sub(before.saturating_add(required_growth)); if unbudgeted > 0 && !candidate.fill_gauge.grow(unbudgeted, &candidate.settings) { @@ -328,7 +316,7 @@ impl HttpBody for FillBody { .rejected_fills .fetch_add(1, Ordering::Relaxed); this.candidate = None; - } // Do not cache responses with trailers. + } } if this.inner.is_end_stream() { this.done = true; @@ -391,4 +379,3 @@ impl Drop for FillBody { } } } - diff --git a/src/cache/tests/http_invalidation_tests.rs b/src/cache/invalidation/http_invalidation_tests.rs similarity index 87% rename from src/cache/tests/http_invalidation_tests.rs rename to src/cache/invalidation/http_invalidation_tests.rs index 28c5d4e..aee14e5 100644 --- a/src/cache/tests/http_invalidation_tests.rs +++ b/src/cache/invalidation/http_invalidation_tests.rs @@ -4,13 +4,20 @@ mod http_invalidation_tests { fn default_deny_and_strict_ipv4_ipv6_cidr_validation() { let mut cache = Settings::default(); assert!(!cache.permits_http_invalidation("127.0.0.1".parse().unwrap())); - cache.http_invalidation_allowed_cidrs = vec!["192.0.2.42".into(), "2001:db8:10::/64".into()]; + cache.http_invalidation_allowed_cidrs = + vec!["192.0.2.42".into(), "2001:db8:10::/64".into()]; cache.validate().unwrap(); assert!(cache.permits_http_invalidation("192.0.2.42".parse().unwrap())); assert!(!cache.permits_http_invalidation("192.0.2.43".parse().unwrap())); assert!(cache.permits_http_invalidation("2001:db8:10::1".parse().unwrap())); assert!(!cache.permits_http_invalidation("2001:db8:11::1".parse().unwrap())); - for invalid in ["0.0.0.0/0", "::/0", "127.0.0.1; 8.8.8.8", "not-an-ip", "10.0.0.0/33"] { + for invalid in [ + "0.0.0.0/0", + "::/0", + "127.0.0.1; 8.8.8.8", + "not-an-ip", + "10.0.0.0/33", + ] { cache.http_invalidation_allowed_cidrs = vec![invalid.into()]; assert!(cache.validate().is_err(), "must reject {invalid}"); } @@ -37,5 +44,4 @@ mod http_invalidation_tests { assert!(!host_matches(&matcher, "http", "a.example", "a.example")); assert!(!host_matches(&matcher, "https", "b.example", "b.example")); } - } diff --git a/src/cache/invalidation/invalidate.rs b/src/cache/invalidation/invalidate.rs index ed32449..a2a67a7 100644 --- a/src/cache/invalidation/invalidate.rs +++ b/src/cache/invalidation/invalidate.rs @@ -6,7 +6,8 @@ impl Cache { let now = SystemTime::now(); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); bans.retain(|rule| !rule.expired(now)); - self.ban_rule_count.store(bans.len() as u64, Ordering::Release); + self.ban_rule_count + .store(bans.len() as u64, Ordering::Release); bans.clone() } @@ -17,7 +18,8 @@ impl Cache { let now = SystemTime::now(); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); bans.retain(|rule| !rule.expired(now)); - self.ban_rule_count.store(bans.len() as u64, Ordering::Release); + self.ban_rule_count + .store(bans.len() as u64, Ordering::Release); let banned = bans.iter().any(|rule| rule.matches_hot(key, entry, now)); if banned { self.counters.ban_matches.fetch_add(1, Ordering::Relaxed); @@ -32,7 +34,8 @@ impl Cache { let now = SystemTime::now(); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); bans.retain(|rule| !rule.expired(now)); - self.ban_rule_count.store(bans.len() as u64, Ordering::Release); + self.ban_rule_count + .store(bans.len() as u64, Ordering::Release); let banned = bans.iter().any(|rule| rule.matches_cold(key, entry, now)); if banned { self.counters.ban_matches.fetch_add(1, Ordering::Relaxed); @@ -47,7 +50,8 @@ impl Cache { let now = SystemTime::now(); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); bans.retain(|rule| !rule.expired(now)); - self.ban_rule_count.store(bans.len() as u64, Ordering::Release); + self.ban_rule_count + .store(bans.len() as u64, Ordering::Release); bans.iter().any(|rule| rule.matches_hot(key, entry, now)) } @@ -60,7 +64,8 @@ impl Cache { let before = bans.len(); bans.retain(|rule| rule.id != id); let removed = bans.len() != before; - self.ban_rule_count.store(bans.len() as u64, Ordering::Release); + self.ban_rule_count + .store(bans.len() as u64, Ordering::Release); if removed { self.counters.bans_removed.fetch_add(1, Ordering::Relaxed); } @@ -97,7 +102,8 @@ impl Cache { created_at: now, expires_at: now.checked_add(Duration::from_secs(ttl)).unwrap_or(now), }); - self.ban_rule_count.store(bans.len() as u64, Ordering::Release); + self.ban_rule_count + .store(bans.len() as u64, Ordering::Release); self.counters.bans_created.fetch_add(1, Ordering::Relaxed); Ok(InvalidationResult { operation: request.operation, @@ -112,22 +118,9 @@ impl Cache { }) } InvalidationOperation::Purge => { - // A selective purge is also a publication barrier for every fill that - // started before this operation. Existing unrelated objects remain - // reusable, but an old in-flight response cannot repopulate a target - // that was not yet identifiable (for example Cache-Tag before headers). { let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); store.generation = store.generation.wrapping_add(1); - // This global publication barrier invalidates every older - // ticket and pending COLD demotion. Per-key generations only - // need to cover the current purge, not every key ever - // purged over the lifetime of the process. Without clearing - // them, repeated PURGEs of distinct URLs grow this map - // indefinitely even after the objects have been evicted. - // Release an unusually large index's backing allocation - // after mass invalidations instead of retaining its peak - // bucket capacity for the rest of the process lifetime. if store.key_generations.capacity() > 256 { store.key_generations = Default::default(); } else { diff --git a/src/cache/lifecycle.rs b/src/cache/lifecycle.rs index b94db52..563f09f 100644 --- a/src/cache/lifecycle.rs +++ b/src/cache/lifecycle.rs @@ -111,7 +111,9 @@ impl Cache { let weak = Arc::downgrade(&cache); tokio::spawn(async move { loop { - let Some(cache) = weak.upgrade() else { break; }; + let Some(cache) = weak.upgrade() else { + break; + }; let interval = cache .tier_settings .lock() @@ -130,9 +132,6 @@ impl Cache { cache } fn fill_headroom_bytes(max_bytes: usize) -> usize { - // Allocation rounding is allowed a small global cushion, never a whole - // max_object allocation. Use roughly 1/64 of HOT, cap it at 1 MiB and - // never let the cushion itself exceed 1/8 of a very small HOT budget. let proportional = (max_bytes / 64).clamp(4 * 1024, 1024 * 1024); proportional.min((max_bytes / 8).max(1)) } @@ -164,17 +163,14 @@ impl Cache { } } - /// Reserve real allocation for an in-progress fill before growing its buffer. - /// Existing HOT objects are LRU-evicted/demoted first so HOT + active fill - /// allocation cannot race arbitrarily above the configured HOT budget. fn reserve_fill_allocation(&self, bytes: usize, settings: &Settings) -> Option { if bytes == 0 { return self.try_reserve_transient(0, settings); } let lease = self.try_reserve_transient(bytes, settings)?; let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); - let fill_now = usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)) - .unwrap_or(usize::MAX); + let fill_now = + usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)).unwrap_or(usize::MAX); let allowed = settings .max_bytes .saturating_add(Self::fill_headroom_bytes(settings.max_bytes)); @@ -182,20 +178,14 @@ impl Cache { let generation = store.generation; let mut removed = 0usize; let mut released_hot = false; - while store - .bytes - .saturating_add(fill_now) - .saturating_add(bytes) - > allowed - { + while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed { if removed >= settings.hot_eviction_max_objects { self.counters .hot_eviction_limited .fetch_add(1, Ordering::Relaxed); break; } - let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) - else { + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else { break; }; let Some(old) = store.remove(&key) else { @@ -227,11 +217,7 @@ impl Cache { self.counters.evictions.fetch_add(1, Ordering::Relaxed); } } - let fits = store - .bytes - .saturating_add(fill_now) - .saturating_add(bytes) - <= allowed; + let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed; if fits { self.fill_bytes_active .fetch_add(bytes as u64, Ordering::Relaxed); @@ -251,10 +237,6 @@ impl Cache { } } - - /// Bound long-lived HOT RSS without flushing the working set. Only entries - /// idle for the configured age are considered, and only while HOT usage is - /// above the configured target waterline. fn trim_hot_idle(&self) { let settings = self .tier_settings @@ -285,17 +267,31 @@ impl Cache { .get(&key) .is_some_and(|entry| entry.last_access.elapsed() >= idle_cutoff); if !idle { - // LRU order is also last-access order, so newer entries cannot qualify. break; } - let Some(entry) = store.remove(&key) else { continue; }; + let Some(entry) = store.remove(&key) else { + continue; + }; removed += 1; - let scope_generation = store.scope_generations.get(&entry.scope).copied().unwrap_or(0); - let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0); + let scope_generation = store + .scope_generations + .get(&entry.scope) + .copied() + .unwrap_or(0); + let key_generation = store + .key_generations + .get(&entry.base_key) + .copied() + .unwrap_or(0); if !(settings.cold_enabled && settings.cold_on_lru && self.enqueue_demotion( - key, entry, revision, generation, scope_generation, key_generation, + key, + entry, + revision, + generation, + scope_generation, + key_generation, )) { self.counters.evictions.fetch_add(1, Ordering::Relaxed); @@ -307,29 +303,28 @@ impl Cache { } } - /// Block requests bound to an old immutable routing snapshot from creating - /// cache tickets during a concurrent configuration publication. This does - /// not clear HOT or COLD and does not modify the representation revision. pub fn set_control_revision(&self, revision: u64) { - self.store.lock().unwrap_or_else(|e| e.into_inner()).control_revision = revision; + self.store + .lock() + .unwrap_or_else(|e| e.into_inner()) + .control_revision = revision; } pub fn replace_scope_revisions(&self, scopes: HashMap) { - *self.scope_revisions.lock().unwrap_or_else(|e| e.into_inner()) = scopes; + *self + .scope_revisions + .lock() + .unwrap_or_else(|e| e.into_inner()) = scopes; } pub fn configure(&self, settings: Settings) { *self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone(); - // Hot-applied limit reductions take effect immediately for published HOT - // objects. Demotion is best effort and remains bounded by transient/COLD - // queue budgets; otherwise the LRU object is dropped. let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let revision = store.revision; let generation = store.generation; let mut released_hot = false; while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes { - let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) - else { + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else { break; }; let Some(old) = store.remove(&key) else { diff --git a/src/cache/lookup/hot.rs b/src/cache/lookup/hot.rs index ef0909c..ed79fc9 100644 --- a/src/cache/lookup/hot.rs +++ b/src/cache/lookup/hot.rs @@ -1,20 +1,22 @@ impl Cache { fn get_hot(&self, ticket: &Ticket, count_miss: bool) -> Option { - // Only metadata, hit counters and exact LRU ordering require the store - // mutex. Materializing an HTTP response may allocate (headers, Body, - // Age formatting) and must be done *after* dropping that mutex. let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); if store.revision != ticket.revision || store.generation != ticket.generation - || store.scope_generations.get(&ticket.scope).copied().unwrap_or(0) + || store + .scope_generations + .get(&ticket.scope) + .copied() + .unwrap_or(0) != ticket.scope_generation - || store.key_generations.get(&ticket.key).copied().unwrap_or(0) - != ticket.key_generation + || store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation { return None; } if let Some(storage_key) = matching_hot_key(&store, ticket) { - let banned = store.entries.get(&storage_key) + let banned = store + .entries + .get(&storage_key) .is_some_and(|entry| self.hot_entry_banned(&storage_key, entry)); if banned { store.remove(&storage_key); @@ -27,7 +29,10 @@ impl Cache { } let entry = store.entries.get(&storage_key)?; let resident_age_seconds = hot_resident_age(entry).as_secs(); - let age = entry.initial_age.saturating_add(entry.received.elapsed()).as_secs(); + let age = entry + .initial_age + .saturating_add(entry.received.elapsed()) + .as_secs(); let state = cache_state( age, entry.ttl, @@ -39,8 +44,16 @@ impl Cache { ); if state.servable() { let sequence = store.next_sequence(); - let (old_sequence, headers, body, hits, transformed, skip_compression, - remaining_ttl, body_bytes) = { + let ( + old_sequence, + headers, + body, + hits, + transformed, + skip_compression, + remaining_ttl, + body_bytes, + ) = { let entry = store.entries.get_mut(&storage_key)?; let old_sequence = entry.sequence; entry.hits = entry.hits.saturating_add(1); @@ -59,15 +72,15 @@ impl Cache { }; store.lru.remove(&old_sequence); store.lru.insert(sequence, storage_key); - // Preserve the exact LRU and domain-byte counters, without - // allocating a replacement variant index or String keys. self.counters.hits.fetch_add(1, Ordering::Relaxed); drop(store); let mut response = Response::new(Body::from(body.clone())); *response.headers_mut() = headers.as_ref().clone(); if transformed { - response.extensions_mut().insert(crate::transform::CachedTransformed); + response + .extensions_mut() + .insert(crate::transform::CachedTransformed); } if skip_compression { response.extensions_mut().insert(crate::compression::Skip); @@ -76,7 +89,8 @@ impl Cache { "age", HeaderValue::from_str(&age.to_string()).expect("integer header"), ); - self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes); + self.domain_stats + .saved_body_hit(&ticket.scope, &ticket.domain, body_bytes); return Some(Hit { response, body, @@ -274,7 +288,8 @@ impl Cache { "age", HeaderValue::from_str(&age.to_string()).expect("integer header"), ); - self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes); + self.domain_stats + .saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes); self.counters.hits.fetch_add(1, Ordering::Relaxed); self.counters.cold_hits.fetch_add(1, Ordering::Relaxed); self.counters diff --git a/src/cache/tests/hot_hit_034_tests.rs b/src/cache/lookup/hot_hit_034_tests.rs similarity index 66% rename from src/cache/tests/hot_hit_034_tests.rs rename to src/cache/lookup/hot_hit_034_tests.rs index f6d1f41..887149d 100644 --- a/src/cache/tests/hot_hit_034_tests.rs +++ b/src/cache/lookup/hot_hit_034_tests.rs @@ -3,7 +3,10 @@ mod hot_hit_034_tests { use http_body_util::BodyExt; fn settings() -> Settings { - Settings { enabled: true, ..Settings::default() } + Settings { + enabled: true, + ..Settings::default() + } } async fn publish( @@ -13,16 +16,25 @@ mod hot_hit_034_tests { body: &'static str, transformed: bool, ) -> Ticket { - let ticket = cache.ticket_scoped( - 34, "app", "http", "cache.example", "origin@127.0.0.1:8080", - path, &HeaderMap::new(), - ).expect("cache ticket"); + let ticket = cache + .ticket_scoped( + 34, + "app", + "http", + "cache.example", + "origin@127.0.0.1:8080", + path, + &HeaderMap::new(), + ) + .expect("cache ticket"); let mut response = Response::new(Body::from(body)); response.headers_mut().insert( - "cache-control", HeaderValue::from_static("public, max-age=31536000"), + "cache-control", + HeaderValue::from_static("public, max-age=31536000"), ); response.headers_mut().insert( - "etag", HeaderValue::from_static("\"cached-representation\""), + "etag", + HeaderValue::from_static("\"cached-representation\""), ); if transformed { response.extensions_mut().insert(crate::transform::Applied { @@ -33,8 +45,12 @@ mod hot_hit_034_tests { } let policy = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO) .expect("public static object cacheable"); - cache.wrap(response, ticket.clone(), policy, settings) - .into_body().collect().await.unwrap(); + cache + .wrap(response, ticket.clone(), policy, settings) + .into_body() + .collect() + .await + .unwrap(); ticket } @@ -52,12 +68,21 @@ mod hot_hit_034_tests { let hit = cache.get(&ticket).expect("HOT cache hit"); assert_eq!(hit.hits, i); assert!(hit.transformed); - assert!(hit.response.extensions().get::().is_some()); + assert!(hit + .response + .extensions() + .get::() + .is_some()); assert_eq!(hit.response.headers()["etag"], "\"cached-representation\""); let mut independent = hit.response; - independent.headers_mut().insert("etag", HeaderValue::from_static("\"client-only\"")); + independent + .headers_mut() + .insert("etag", HeaderValue::from_static("\"client-only\"")); if i == 500 { - assert_eq!(independent.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"body{color:red}")); + assert_eq!( + independent.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"body{color:red}") + ); } } let after = cache.view(); @@ -73,7 +98,10 @@ mod hot_hit_034_tests { let dashboard = cache.dashboard(Some("app"), 0, 10); assert_eq!(dashboard.objects[0].hits, 500); assert_eq!(dashboard.domains[0].counters.saved_body_bytes, 500 * 15); - assert_eq!(cache.get(&ticket).unwrap().response.headers()["etag"], "\"cached-representation\""); + assert_eq!( + cache.get(&ticket).unwrap().response.headers()["etag"], + "\"cached-representation\"" + ); } #[tokio::test] @@ -96,9 +124,15 @@ mod hot_hit_034_tests { hits })); } - let mut counts: Vec<_> = threads.into_iter().flat_map(|t| t.join().unwrap()).collect(); + let mut counts: Vec<_> = threads + .into_iter() + .flat_map(|t| t.join().unwrap()) + .collect(); counts.sort_unstable(); - assert_eq!(counts, (1..=(workers * per_worker) as u64).collect::>()); + assert_eq!( + counts, + (1..=(workers * per_worker) as u64).collect::>() + ); assert_eq!(cache.view().hits, (workers * per_worker) as u64); let store = cache.store.lock().unwrap(); assert_eq!(store.lru.len(), 1); @@ -108,7 +142,10 @@ mod hot_hit_034_tests { #[tokio::test] async fn hot_lru_eviction_and_ban_are_preserved() { let cache = Cache::new(34); - let settings = Settings { max_entries: 2, ..settings() }; + let settings = Settings { + max_entries: 2, + ..settings() + }; let a = publish(&cache, &settings, "/a.css", "AAAA", false).await; let b = publish(&cache, &settings, "/b.css", "BBBB", false).await; assert!(cache.get(&a).is_some(), "A becomes most recently used"); @@ -117,15 +154,17 @@ mod hot_hit_034_tests { assert!(cache.get(&b).is_none(), "B is least recently used"); assert!(cache.get(&c).is_some()); - let ban = cache.invalidate(InvalidationRequest { - operation: InvalidationOperation::Ban, - selector: InvalidationSelector::Exact, - value: "/a.css".into(), - host: Some("cache.example".into()), - vhost: Some("app".into()), - ttl_seconds: Some(3600), - confirm_wide: false, - }).unwrap(); + let ban = cache + .invalidate(InvalidationRequest { + operation: InvalidationOperation::Ban, + selector: InvalidationSelector::Exact, + value: "/a.css".into(), + host: Some("cache.example".into()), + vhost: Some("app".into()), + ttl_seconds: Some(3600), + confirm_wide: false, + }) + .unwrap(); assert_eq!(cache.ban_rule_count.load(Ordering::Acquire), 1); assert!(cache.get(&a).is_none(), "BAN must block cached A"); assert!(cache.remove_ban(ban.ban_id.unwrap())); @@ -147,8 +186,11 @@ mod hot_hit_034_tests { std::hint::black_box(cache.get(&ticket).unwrap()); } let duration = start.elapsed(); - eprintln!("HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS", - duration.as_secs_f64() * 1e9 / iterations as f64, iterations); + eprintln!( + "HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS", + duration.as_secs_f64() * 1e9 / iterations as f64, + iterations + ); assert_eq!(cache.view().hits, iterations + 1_000); } @@ -161,12 +203,19 @@ mod hot_hit_034_tests { store.entries.values_mut().next().unwrap().received = Instant::now() - Duration::from_secs(700_000); } - assert!(cache.get(&ticket).is_none(), "expired freshness is not an unconditional HIT"); - assert_eq!(cache.view().entries, 1, "stale representation must survive for revalidation"); - let mut stale_ticket = ticket; - stale_ticket.request_headers.insert( - "cache-control", HeaderValue::from_static("max-stale"), + assert!( + cache.get(&ticket).is_none(), + "expired freshness is not an unconditional HIT" ); + assert_eq!( + cache.view().entries, + 1, + "stale representation must survive for revalidation" + ); + let mut stale_ticket = ticket; + stale_ticket + .request_headers + .insert("cache-control", HeaderValue::from_static("max-stale")); assert!(cache.get(&stale_ticket).unwrap().stale); } } diff --git a/src/cache/lookup/insert.rs b/src/cache/lookup/insert.rs index e9efeba..f08799d 100644 --- a/src/cache/lookup/insert.rs +++ b/src/cache/lookup/insert.rs @@ -44,9 +44,6 @@ impl Cache { { return false; } - // Do not keep objects for incompatible Vary schemas under the same - // Varnish-style host+URL hash. A newer representation policy replaces - // the older schema while preserving sibling values of the same Vary set. for key in incompatible_hot_variants(&store, &ticket.key, &vary_schema) { store.remove(&key); } diff --git a/src/cache/lookup/tiered.rs b/src/cache/lookup/tiered.rs index 969ca81..caff118 100644 --- a/src/cache/lookup/tiered.rs +++ b/src/cache/lookup/tiered.rs @@ -170,10 +170,15 @@ impl Cache { Some(validators) => validators.last_modified.clone(), None => headers.get("last-modified").cloned(), }; - let transformed = parts.extensions.get::().is_some(); - // A transformed, fully buffered body normally arrives as one owned - // frame. Do not allocate a second fill buffer before inspecting it. - let initial_capacity = if transformed { 0 } else { fill_size.min(64 * 1024) }; + let transformed = parts + .extensions + .get::() + .is_some(); + let initial_capacity = if transformed { + 0 + } else { + fill_size.min(64 * 1024) + }; let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else { self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed); self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure"); @@ -205,9 +210,6 @@ impl Cache { _permit: permit, _fill_leader: fill_leader, }; - // A genuinely finished body (including an empty local file) may never - // be polled by Hyper. EOS is the proof here, not Content-Length alone; - // finish still enforces expected length, privacy and ticket validity. if body.is_end_stream() { candidate.finish(); return Response::from_parts(parts, body); diff --git a/src/cache/mod.rs b/src/cache/mod.rs index d0f685e..4ab8823 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,9 +1,3 @@ -//! Bounded, conservative shared two-tier cache. Fresh objects live in a fast RAM -//! hot tier; optionally, idle/LRU objects are persisted in a bounded cold tier. -//! Bodies are streamed while a bounded copy is collected; only complete successful -//! bodies are published. The implementation is split by responsibility so storage, -//! policy and streaming-fill code can be reviewed independently. - include!("model.rs"); include!("stats.rs"); include!("lifecycle.rs"); diff --git a/src/cache/model/cache.rs b/src/cache/model/cache.rs index 50413a4..bba9dd5 100644 --- a/src/cache/model/cache.rs +++ b/src/cache/model/cache.rs @@ -28,9 +28,7 @@ pub struct ObjectView { pub storage_bytes: u64, pub tier: &'static str, pub hits: u64, - /// RFC 9111 Age, including time before the object entered this cache. pub age_seconds: u64, - /// Total time held by RustProxy HOT/COLD; excludes any upstream CDN Age. pub resident_age_seconds: u64, pub remaining_ttl_seconds: u64, } @@ -50,11 +48,8 @@ pub struct Dashboard { #[derive(Debug, Clone, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ColdCleanupRequest { - /// Remove objects not used for at least this many seconds. pub unused_for_seconds: Option, - /// Additionally remove up to N oldest matching objects. pub oldest: Option, - /// Limit the selection to objects which were never served. pub hits_zero: bool, } impl Default for ColdCleanupRequest { @@ -113,7 +108,6 @@ struct Entry { created_at: SystemTime, headers: Arc, body: Bytes, - /// Backing allocation retained by `body`; can exceed `body.len()`. body_allocated: usize, received: Instant, last_access: Instant, @@ -135,7 +129,7 @@ struct Entry { requires_revalidation: bool, } -#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting. +#[allow(clippy::ptr_arg)] fn hot_entry_cost( body_allocated: usize, headers: &HeaderMap, @@ -150,11 +144,6 @@ fn hot_entry_cost( origin_etag: Option<&HeaderValue>, origin_last_modified: Option<&HeaderValue>, ) -> usize { - // The public containers do not expose an exact per-entry allocator charge. - // Account their retained index memory conservatively: HOT HashMap key/control, - // the LRU BTreeMap item/node share and the variants index (including spare - // Vec capacity). This keeps `max_bytes` closer to the real retained - // allocation instead of pretending Entry/body are the only HOT owners. let index_heap = std::mem::size_of::() .saturating_add(std::mem::size_of::<(u64, Key)>()) .saturating_add(32) @@ -171,8 +160,6 @@ fn hot_entry_cost( let header_heap = headers .iter() .map(|(name, value)| { - // HeaderMap does not expose its bucket capacity. Charge a conservative - // per-field bucket/node allowance in addition to visible name/value bytes. name.as_str() .len() .saturating_add(value.as_bytes().len()) @@ -181,7 +168,6 @@ fn hot_entry_cost( .sum::(); body_allocated .saturating_add(std::mem::size_of::()) - // Arc owns the actual map on a separate allocation. .saturating_add(std::mem::size_of::()) .saturating_add(2 * std::mem::size_of::()) .saturating_add(index_heap) @@ -207,7 +193,6 @@ struct ColdFile { #[serde(default)] vary: Vec, revision: u64, - /// 0 denotes legacy/unscoped entries; production vhosts require an exact match. #[serde(default)] scope_revision: u64, scope: String, @@ -284,9 +269,6 @@ struct ColdEntry { hits: u64, transformed: bool, sequence: u64, - // Stable identity of this in-memory cold entry. Unlike `sequence`, this - // never changes on access, so an old async reader cannot unpin a newer - // replacement stored under the same cache key. generation: u64, skip_compression: bool, requires_revalidation: bool, @@ -311,8 +293,6 @@ struct ColdDraft { key_generation: u64, entry: Entry, root: PathBuf, - /// Keeps this full in-memory object charged to the bounded transient pool - /// until the COLD write completes and the payload is dropped. _memory_lease: TransientLease, } @@ -331,8 +311,6 @@ struct Store { sequence: u64, generation: u64, revision: u64, - /// Current persisted administrative config revision. Distinct from cache - /// representation revision: operational saves do not evict data. control_revision: u64, scope_generations: HashMap, key_generations: HashMap, @@ -361,7 +339,6 @@ impl Store { Some(entry) } fn next_sequence(&mut self) -> u64 { - // No practical wrapping, but a wrap must not corrupt LRU bookkeeping. if self.sequence == u64::MAX { self.entries.clear(); self.variants.clear(); diff --git a/src/cache/model/entry.rs b/src/cache/model/entry.rs index a96bc0f..f2cf9fc 100644 --- a/src/cache/model/entry.rs +++ b/src/cache/model/entry.rs @@ -167,7 +167,6 @@ impl Settings { .any(|item| item.eq_ignore_ascii_case(extension)) } pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) { - // Origin values must never masquerade as this proxy's diagnostics. for name in [ "x-cache", "x-cache-hits", @@ -178,15 +177,15 @@ impl Settings { ] { headers.remove(name); } - // A vhost that explicitly disables cache must look like a normal reverse proxy. - // Keep counters internally, but never expose cache diagnostics for that response. if !self.enabled { return; } if let Some(value) = &self.via_header { headers.append( http::header::VIA, - value.parse::().expect("validated cache Via header"), + value + .parse::() + .expect("validated cache Via header"), ); } if self.expose_only_hits && outcome != "HIT" { @@ -226,10 +225,15 @@ impl Settings { } } pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) { - if !self.enabled || !self.expose_cache_status || !transformed - || (self.expose_only_hits && !headers.get("cache-status") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.starts_with("RustProxy; hit"))) { + if !self.enabled + || !self.expose_cache_status + || !transformed + || (self.expose_only_hits + && !headers + .get("cache-status") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("RustProxy; hit"))) + { return; } let base = headers @@ -246,7 +250,6 @@ impl Settings { headers.insert("cache-status", value); } } - /// Reasons are fixed internal tokens: never echo request values or secrets. pub fn annotate_reason( &self, headers: &mut HeaderMap, @@ -284,8 +287,6 @@ fn valid_relative_cache_path(value: &str) -> bool { .all(|part| matches!(part, Component::Normal(_))) } -/// Explicit, per-vhost-only request-side cache override. It never weakens the -/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules). #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct UnsafeForce { @@ -294,7 +295,10 @@ pub struct UnsafeForce { } impl UnsafeForce { pub fn validate(&self) -> Result<()> { - ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes"); + ensure!( + self.targets.len() <= 32, + "forced cache supports at most 32 target prefixes" + ); for target in &self.targets { ensure!( !target.is_empty() @@ -305,7 +309,10 @@ impl UnsafeForce { "forced cache targets must be 1..2048 byte origin-form path/query prefixes" ); let path = target.split('?').next().unwrap_or(target); - ensure!(crate::acl::routing_path(path)? == path, "forced cache target path must be normalized"); + ensure!( + crate::acl::routing_path(path)? == path, + "forced cache target path must be normalized" + ); } Ok(()) } @@ -323,8 +330,6 @@ impl UnsafeForce { } } -/// Per-vhost behavior, not independent memory pools. None inherits the global -/// default; the global resident-byte/entry/object ceilings remain hard limits. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Overrides { @@ -341,15 +346,12 @@ pub struct Overrides { pub expose_cache_status: Option, pub expose_resident_age: Option, pub expose_only_hits: Option, - /// Deliberately unavailable in the global cache configuration. pub unsafe_force: UnsafeForce, } impl Overrides { pub fn resolve(&self, global: &Settings) -> Result { self.unsafe_force.validate()?; let mut result = global.clone(); - // A vhost may narrow the global policy, but can never re-enable a cache - // that the administrator disabled globally. result.enabled = global.enabled && self.enabled.unwrap_or(true); if let Some(value) = self.default_ttl_seconds { result.default_ttl_seconds = value; @@ -444,7 +446,6 @@ pub struct View { pub hot_eviction_limited: u64, pub purges: u64, pub entries: usize, - /// Compatibility alias for HOT allocated/charged bytes. pub resident_bytes: usize, pub hot_logical_bytes: usize, pub hot_allocated_bytes: usize, @@ -474,16 +475,11 @@ pub struct View { pub proactive_skipped: u64, pub proactive_inflight: u64, pub fills_active: usize, - /// Bytes currently allocated by in-progress response cache fills. pub fill_bytes_active: u64, - /// Demoted objects accepted by the bounded cold-writer queue. pub cold_queue_items: u64, pub cold_queue_bytes: u64, - /// Body bytes currently owned by the cold writer after leaving the queue. pub cold_write_bytes_active: u64, - /// Full cold bodies currently read into RAM for promotion. pub cold_read_bytes_active: u64, - /// RustProxy-accounted cache RAM outside the published hot store. pub transient_bytes: u64, pub hit_ratio: f64, } diff --git a/src/cache/model/http_invalidation.rs b/src/cache/model/http_invalidation.rs index 450891b..15351da 100644 --- a/src/cache/model/http_invalidation.rs +++ b/src/cache/model/http_invalidation.rs @@ -1,11 +1,11 @@ -// A strict, human-editable IP/CIDR policy. The caller supplies only a client IP -// produced by the existing forwarding trust boundary, never an arbitrary header. fn parse_invalidation_cidr(value: &str) -> anyhow::Result { let value = value.trim(); if let Ok(ip) = value.parse::() { return Ok(ipnet::IpNet::from(ip)); } - value.parse::().map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}")) + value + .parse::() + .map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}")) } impl Settings { fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> { @@ -17,7 +17,10 @@ impl Settings { for input in &self.http_invalidation_allowed_cidrs { ensure!(input.len() <= 64, "HTTP invalidation address too long"); let net = parse_invalidation_cidr(input)?; - ensure!(net.prefix_len() > 0, "HTTP invalidation cannot authorize the entire Internet"); + ensure!( + net.prefix_len() > 0, + "HTTP invalidation cannot authorize the entire Internet" + ); ensure!(networks.insert(net), "duplicate HTTP invalidation IP/CIDR"); } Ok(()) @@ -25,7 +28,9 @@ impl Settings { pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool { let client = crate::vhost::canonical_ip(client); self.http_invalidation_allowed_cidrs.iter().any(|value| { - parse_invalidation_cidr(value).ok().is_some_and(|network| network.contains(&client)) + parse_invalidation_cidr(value) + .ok() + .is_some_and(|network| network.contains(&client)) }) } } diff --git a/src/cache/model/maintenance.rs b/src/cache/model/maintenance.rs index b130bc9..eacc32b 100644 --- a/src/cache/model/maintenance.rs +++ b/src/cache/model/maintenance.rs @@ -7,8 +7,6 @@ impl ColdStore { } fn next_sequence(&mut self) -> u64 { if self.sequence == u64::MAX { - // Practically unreachable. Prefer dropping the in-memory index over - // risking ambiguous LRU ordering after wrap. self.lru.clear(); self.entries.clear(); self.variants.clear(); @@ -56,7 +54,6 @@ pub struct Cache { revision: AtomicU64, cold: Arc>, tier_settings: Mutex, - /// Per-vhost content compatibility used by persisted COLD metadata. scope_revisions: Mutex>, data_root: Option, cold_tx: Option>, @@ -143,8 +140,6 @@ pub struct Revalidation { headers: HeaderMap, body: Bytes, body_allocated: usize, - // COLD revalidation bodies remain charged to transient RAM until every - // cloned revalidation handle is dropped or promoted. HOT revalidations use none. _transient_leases: Vec, hits: u64, transformed: bool, @@ -171,9 +166,6 @@ impl Revalidation { &self.headers } pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool { - // Client validators refer to the representation RustProxy served. If that - // representation was transformed, translate a matching validator back to - // the origin validator rather than leaking the generated proxy ETag upstream. if headers.contains_key("if-none-match") { let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else { return false; @@ -349,7 +341,11 @@ fn hot_resident_age(entry: &Entry) -> Duration { } fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option { - if store.entries.get(&ticket.key).is_some_and(|entry| entry.vary.is_empty()) { + if store + .entries + .get(&ticket.key) + .is_some_and(|entry| entry.vary.is_empty()) + { return Some(ticket.key); } store diff --git a/src/cache/model/settings.rs b/src/cache/model/settings.rs index 7cc0abc..1c3eedc 100644 --- a/src/cache/model/settings.rs +++ b/src/cache/model/settings.rs @@ -24,8 +24,6 @@ const FILL_SLOTS: usize = 8; const COLD_DELETE_FALLBACKS: usize = 2; const COLD_CLEANUP_SLOTS: usize = 1; const BACKGROUND_REVALIDATION_LIMIT: usize = 8; -// Cold writes can contain complete response bodies. Keep the queue deliberately -// small so demotion cannot become a second, hidden RAM cache while disk is slow. const COLD_QUEUE: usize = 16; const COLD_FORMAT_VERSION: u8 = 3; type Key = [u8; 32]; @@ -43,10 +41,6 @@ struct CleanupCandidate { key: Key, } -/// `Bytes` releases its allocation when the last reference is dropped, but -/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges, -/// return releasable pages to the OS instead of letting RSS stay artificially -/// high. This is intentionally a no-op on other allocators/platforms. fn release_allocator_memory() { #[cfg(all(target_os = "linux", target_env = "gnu"))] unsafe { @@ -93,81 +87,44 @@ pub enum ColdPreset { #[serde(default, deny_unknown_fields)] pub struct Settings { pub enabled: bool, - /// UI sizing profile last explicitly selected by the administrator. The - /// actual numeric limits remain authoritative; this only preserves intent - /// across reloads so the dashboard can show which preset was applied. pub memory_preset: Option, - /// Hard RAM budget for published HOT entries, charged by backing allocation - /// plus directly owned metadata rather than only logical body length. pub max_bytes: usize, - /// Maximum logical size of a single cacheable object body. pub max_object_bytes: usize, - /// Hot tier object count. pub max_entries: usize, - /// Bounded Varnish-style transient RAM used by in-progress fills and full - /// bodies moving between HOT and COLD. This is independent of `max_bytes`. pub transient_max_bytes: usize, - /// Byte ceiling for demoted objects waiting in the COLD writer queue. pub cold_queue_max_bytes: usize, - /// Maximum number of LRU objects removed during one admission attempt. - /// Reaching the limit makes the new object pass through uncached. pub hot_eviction_max_objects: usize, - /// Automatically release idle HOT objects once the tier is above the target waterline. pub hot_auto_trim_enabled: bool, - /// Minimum idle age before automatic HOT trimming can evict an object. pub hot_auto_trim_after_seconds: u64, - /// Target HOT usage percentage reached by each automatic trim pass. pub hot_auto_trim_target_percent: u8, - /// Persistent cold tier. Disabled by default to avoid implicit disk writes. pub cold_enabled: bool, - /// Last explicitly selected COLD policy profile, persisted with the cache - /// configuration for the same reason as `memory_preset`. pub cold_preset: Option, - /// Relative to the RustProxy data directory. pub cold_path: String, pub cold_max_bytes: u64, pub cold_max_entries: usize, - /// Interval between asynchronous cold-cache GC checks. pub cold_gc_interval_seconds: u64, - /// Maximum number of objects removed by one GC pass. pub cold_gc_max_delete: usize, - /// Hot objects not touched for this long are demoted to disk. pub cold_after_seconds: u64, - /// Demote objects which have been idle for `cold_after_seconds`. pub cold_on_idle: bool, - /// Demote objects after this much continuous residency in the hot tier - /// when `cold_on_age` is enabled, even when they are still being read. pub cold_max_hot_seconds: u64, - /// Apply `cold_max_hot_seconds` as a separate hot-tier age policy. pub cold_on_age: bool, - /// Preserve objects evicted from the hot LRU in the cold tier. pub cold_on_lru: bool, - /// Best-effort flush of hot objects to cold storage on graceful shutdown. pub cold_on_shutdown: bool, - /// Only used for responses explicitly marked public and without origin TTL. pub default_ttl_seconds: u64, pub max_ttl_seconds: u64, pub bypass_query: bool, pub bypass_prefixes: Vec, - /// Actual client IP/CIDR allowlist for HTTP BAN/PURGE. Empty disables these - /// public-listener methods (independent of authenticated admin API). pub http_invalidation_allowed_cidrs: Vec, - /// For explicitly selected static extensions, ignore request Cookie when - /// deciding cache eligibility and when building the shared cache key. pub static_ignore_cookies: bool, pub static_extensions: Vec, - /// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key. pub collapse_enabled: bool, - /// Maximum time a follower waits for the current fill before applying the configured fallback. pub collapse_wait_timeout_ms: u64, pub collapse_timeout_fallback: CollapseTimeoutFallback, - /// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior. pub stale_if_error_timeout: bool, pub stale_if_error_connection: bool, pub stale_if_error_dns: bool, pub stale_if_error_tls: bool, pub stale_if_error_statuses: Vec, - /// Schedule bounded validator refresh before normal freshness expires. pub proactive_refresh_enabled: bool, pub proactive_refresh_before_expiry_seconds: u64, pub proactive_refresh_percent: u8, @@ -176,10 +133,7 @@ pub struct Settings { pub expose_hits: bool, pub expose_cache_status: bool, pub expose_resident_age: bool, - /// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS. pub expose_only_hits: bool, - /// Optional response Via field value added by RustProxy cache handling. - /// `None` disables the header; the default is `Via: RustProxy`. pub via_header: Option, } impl Default for Settings { @@ -214,21 +168,13 @@ impl Default for Settings { bypass_query: false, bypass_prefixes: Vec::new(), http_invalidation_allowed_cidrs: Vec::new(), - // Static assets are normally representation-identical for logged-in and - // anonymous visitors. Keep this enabled by default once the shared cache - // itself is enabled; response-side privacy guards still reject Set-Cookie, - // private/no-store and Vary: Cookie before anything is stored. static_ignore_cookies: true, static_extensions: vec![ - // styles / scripts - "css", "js", "mjs", "cjs", "wasm", // images - "png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico", - "cur", "bmp", "tif", "tiff", "heic", "heif", // fonts - "woff", "woff2", "ttf", "otf", "eot", // video - "mp4", "webm", "ogv", "mov", "m4v", // audio - "mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus", - // common static downloads - "pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar", + "css", "js", "mjs", "cjs", "wasm", "png", "jpg", "jpeg", "jfif", "jxl", "gif", + "webp", "avif", "apng", "svg", "ico", "cur", "bmp", "tif", "tiff", "heic", "heif", + "woff", "woff2", "ttf", "otf", "eot", "mp4", "webm", "ogv", "mov", "m4v", "mp3", + "ogg", "oga", "wav", "m4a", "aac", "flac", "opus", "pdf", "txt", "zip", "gz", + "tgz", "bz2", "xz", "7z", "rar", "tar", ] .into_iter() .map(str::to_owned) diff --git a/src/cache/model/views.rs b/src/cache/model/views.rs index 061bd7d..297c6e9 100644 --- a/src/cache/model/views.rs +++ b/src/cache/model/views.rs @@ -73,11 +73,6 @@ fn retention_deadline( _stale_if_error: u64, _has_validator: bool, ) -> u64 { - // Freshness and storage retention are deliberately separate. Expiry only - // changes whether an object can be served without validation; it must not - // physically evict the representation because a later request may carry - // Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the - // mechanisms that decide how long an otherwise valid object is retained. u64::MAX } diff --git a/src/cache/policy/request.rs b/src/cache/policy/request.rs index e4734e0..0e88308 100644 --- a/src/cache/policy/request.rs +++ b/src/cache/policy/request.rs @@ -1,4 +1,3 @@ -/// Reject malformed/duplicate directives instead of guessing their meaning. fn directives(headers: &HeaderMap) -> Option>> { let mut result = HashMap::new(); for header in headers.get_all("cache-control") { @@ -11,8 +10,6 @@ fn directives(headers: &HeaderMap) -> Option>> { Some((k, v)) => { let v = v.trim(); let value = if v.starts_with('"') { - // Fail closed on truncated/escaped/compound values. Quoted - // commas need not be supported by this conservative cache. let inner = v.strip_prefix('"')?.strip_suffix('"')?; if inner.contains(['"', '\\']) { return None; @@ -39,10 +36,6 @@ fn directives(headers: &HeaderMap) -> Option>> { return None; } if key == "immutable" { - // RFC 8246: immutable has no meaningful argument and duplicate - // occurrences are equivalent to one occurrence. Preserve only - // the presence of the directive instead of invalidating the - // complete Cache-Control field. result.entry(key).or_insert(None); } else if result.insert(key, value).is_some() { return None; @@ -111,7 +104,11 @@ pub fn forced_request_policy( } for key in ["if-match", "if-unmodified-since", "upgrade"] { if headers.contains_key(key) { - return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" }); + return RequestPolicy::Bypass(if key == "upgrade" { + "request_upgrade" + } else { + "request_precondition" + }); } } RequestPolicy::Use @@ -150,8 +147,6 @@ pub fn request_policy( for (key, reason) in [ ("authorization", "request_authorization"), ("cookie", "request_cookie"), - // Preconditions other than cache validators can produce 412 and must be - // evaluated by the origin until RustProxy implements the full precondition set. ("if-match", "request_precondition"), ("if-unmodified-since", "request_precondition"), ("upgrade", "request_upgrade"), @@ -166,15 +161,9 @@ pub fn request_policy( let Some(control) = directives(headers) else { return RequestPolicy::Bypass("request_cache_control_invalid"); }; - // RFC 9111: request no-store forbids using a stored response and storing this - // exchange. It is the hard client-side cache bypass. if control.contains_key("no-store") { return RequestPolicy::Bypass("request_no_store"); } - // no-cache does not mean no-store. It requires successful validation before a - // stored response can be reused. max-age/min-fresh are evaluated against the - // cached representation during lookup; only max-age=0 is an unconditional - // request-side validation signal. if control.contains_key("no-cache") { return RequestPolicy::Revalidate("request_no_cache"); } @@ -200,8 +189,6 @@ pub fn request_policy( return RequestPolicy::Bypass("request_cache_control_invalid"); } } - // HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did - // not already define the policy. Other Pragma values do not disable caching. if !headers.contains_key("cache-control") && pragma_no_cache(headers) { return RequestPolicy::Revalidate("request_pragma_no_cache"); } @@ -295,8 +282,6 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool { && token != "*" && weak_etag(token).is_some_and(|candidate| candidate == target) } -/// Varnish-like client-side conditional delivery for a selected cached response. -/// If-None-Match takes precedence over If-Modified-Since. pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool { if request.contains_key("if-none-match") { return response @@ -326,11 +311,8 @@ pub fn make_not_modified(mut response: Response) -> Response { } response } -/// Merge a successful backend 304 into the stored representation metadata. pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap { let mut merged = cached.clone(); - // A new validation starts a new age calculation. Preserve origin Age only if - // it is explicitly supplied by the 304 response. if !validation.contains_key("age") { merged.remove("age"); } @@ -364,7 +346,6 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool { }) } -/// Explains rejected storage; it never relaxes origin or transformed policies. pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str { if status != StatusCode::OK { return "response_status"; diff --git a/src/cache/policy/response.rs b/src/cache/policy/response.rs index 098b726..be32377 100644 --- a/src/cache/policy/response.rs +++ b/src/cache/policy/response.rs @@ -4,7 +4,11 @@ impl Plan { } pub fn with_vary(mut self, name: &str) -> Self { - if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) { + if !self + .vary + .iter() + .any(|value| value.eq_ignore_ascii_case(name)) + { self.vary.push(name.to_ascii_lowercase()); self.vary.sort(); } @@ -28,14 +32,7 @@ impl Plan { self.vary.extend(transformed.vary); self.vary.sort(); self.vary.dedup(); - // The transformed response is the representation that is actually - // written to cache. Its Content-Length therefore replaces the origin - // length; comparing transformed bytes with the origin length rejects a - // valid fill and causes a permanent MISS -> MISS loop. self.expected_length = transformed.expected_length; - // Express both age clocks at the later observation point before taking - // the stricter value. This avoids resetting or double-counting Age when - // origin headers are transformed inside the proxy. if transformed.received >= self.received { let carried = self .initial_age @@ -87,9 +84,6 @@ pub fn plan( { return None; } - // Cache-policy and validator fields govern reuse rather than representation - // identity. Response Vary drives a secondary key, matching Varnish-style - // variants without fragmenting on unrelated request headers. let vary = vary_names(headers)?; let control = directives(headers)?; if ["no-store", "private", "must-understand"] @@ -141,22 +135,16 @@ pub fn plan( Some(v) => v.to_str().ok()?.parse::().ok()?, None => 0, }; - // RFC 9111 Section 4.2.3: the corrected initial age is the greater of - // the Date-derived apparent age and the received Age plus response delay. - // An upstream/CDN Age must be preserved on both transformed and normal HITs. let apparent_age = now.duration_since(date).unwrap_or_default(); let corrected_age_value = Duration::from_secs(age).saturating_add(response_delay); let initial_age = apparent_age.max(corrected_age_value); - // `ttl` is an absolute freshness lifetime (measured from the origin's - // Age clock), not the time since insertion into our HOT/COLD cache. - // Capping it directly to max_ttl_seconds makes an otherwise fresh 30-day - // upstream response with Age=18 days look expired when our own cap is 1 day. - // Limit the REMAINING freshness from admission, but preserve the Age clock. - // Origin freshness still wins: an origin response that has already expired - // must never become fresh here. `no-cache` continues to require validation. let ttl = match declared_ttl { - Some(value) => value.min(initial_age.as_secs().saturating_add(settings.max_ttl_seconds)), + Some(value) => value.min( + initial_age + .as_secs() + .saturating_add(settings.max_ttl_seconds), + ), None if requires_revalidation => 0, None => return None, }; diff --git a/src/cache/tests/scope_tests.rs b/src/cache/policy/scope_tests.rs similarity index 97% rename from src/cache/tests/scope_tests.rs rename to src/cache/policy/scope_tests.rs index 93b794d..844509e 100644 --- a/src/cache/tests/scope_tests.rs +++ b/src/cache/policy/scope_tests.rs @@ -62,8 +62,6 @@ mod scope_tests { revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new() ); assert!(ticket(7).is_some(), "the current snapshot can cache"); - // An operational save only advances the config publication barrier. - // It must not change the representation revision or clear any objects. cache.set_control_revision(8); assert_eq!(cache.revision(), 1); assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass"); diff --git a/src/cache/range.rs b/src/cache/range.rs index f64efbf..75c1052 100644 --- a/src/cache/range.rs +++ b/src/cache/range.rs @@ -16,13 +16,9 @@ pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection { let Some(value) = values.next() else { return RangeSelection::None; }; - // Multiple field lines are equivalent to a combined range-set; RustProxy - // intentionally delegates those to the origin with multipart ranges. if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 { return RangeSelection::Unsupported; } - // RustProxy serves one byte range itself. HEAD, unknown units, malformed - // syntax and multipart requests remain transparent origin pass-through. if method != Method::GET { return RangeSelection::Unsupported; } @@ -132,7 +128,9 @@ impl Hit { self.response .headers_mut() .insert("accept-ranges", HeaderValue::from_static("bytes")); - self.response.extensions_mut().insert(crate::compression::Skip); + self.response + .extensions_mut() + .insert(crate::compression::Skip); let Some((start, end)) = resolve_byte_range(range, full_len) else { *self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; *self.response.body_mut() = Body::empty(); diff --git a/src/cache/stats.rs b/src/cache/stats.rs index 7930c9a..48a322f 100644 --- a/src/cache/stats.rs +++ b/src/cache/stats.rs @@ -1,14 +1,12 @@ -// Lock-free read path for bounded, per-domain cache dashboard counters. -// -// Cache-disabled proxy requests still contribute to BYPASS and reason totals, -// but must never lock the HOT object/LRU store. Published domain/reason indexes -// are immutable; individual counters are atomics. Only first sight of a new -// domain/reason acquires the registration mutex. - use arc_swap::ArcSwap; const DOMAIN_STATS_LIMIT: usize = 4096; -const FAST_REASONS: [&str; 4] = ["cache_disabled", "cache_hit", "cache_miss", "request_cookie"]; +const FAST_REASONS: [&str; 4] = [ + "cache_disabled", + "cache_hit", + "cache_miss", + "request_cookie", +]; type ReasonIndex = BTreeMap<&'static str, Arc>; type DomainIndex = BTreeMap>>; @@ -51,13 +49,12 @@ impl DomainStats { "HIT" => &self.hits, "MISS" => &self.misses, _ => &self.bypasses, - }.fetch_add(1, Ordering::Relaxed); + } + .fetch_add(1, Ordering::Relaxed); self.record_reason(reason); } fn record_reason(&self, reason: &'static str) { - // Cache OFF is the highest-volume case; avoid reason map lookup and - // any shared lock/allocator activity for its dashboard reason. if reason == "cache_disabled" { self.fast_reasons[0].fetch_add(1, Ordering::Relaxed); return; @@ -72,7 +69,10 @@ impl DomainStats { return; } drop(published); - let _guard = self.register_reason.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = self + .register_reason + .lock() + .unwrap_or_else(|e| e.into_inner()); let published = self.reasons.load_full(); if let Some(counter) = published.get(reason) { counter.fetch_add(1, Ordering::Relaxed); @@ -123,16 +123,22 @@ impl Default for DomainStatsStore { impl DomainStatsStore { fn with(&self, scope: &str, domain: &str, action: impl FnOnce(&DomainStats)) { let published = self.domains.load(); - if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) { + if let Some(counters) = published + .index + .get(scope) + .and_then(|hosts| hosts.get(domain)) + { action(counters); return; } drop(published); - // Rare path: register only 4096 distinct (scope, domain) pairs. The - // published map is cloned on first insertion, never on a request hit. let _guard = self.register.lock().unwrap_or_else(|e| e.into_inner()); let published = self.domains.load_full(); - if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) { + if let Some(counters) = published + .index + .get(scope) + .and_then(|hosts| hosts.get(domain)) + { action(counters); return; } @@ -141,7 +147,9 @@ impl DomainStatsStore { } let mut next = (*published).clone(); let counters = Arc::new(DomainStats::default()); - next.index.entry(scope.to_owned()).or_default() + next.index + .entry(scope.to_owned()) + .or_default() .insert(domain.to_owned(), Arc::clone(&counters)); next.count += 1; self.domains.store(Arc::new(next)); @@ -163,10 +171,10 @@ impl DomainStatsStore { } fn saved_body_hit(&self, scope: &str, domain: &str, bytes: usize) { - // COLD entries loaded from persisted metadata may be served before a - // domain is registered. Preserve first-hit byte accounting as well. self.with(scope, domain, |counters| { - counters.saved_body_bytes.fetch_add(bytes as u64, Ordering::Relaxed); + counters + .saved_body_bytes + .fetch_add(bytes as u64, Ordering::Relaxed); }); } diff --git a/src/cache/tests.rs b/src/cache/tests.rs index 8c66460..6e69217 100644 --- a/src/cache/tests.rs +++ b/src/cache/tests.rs @@ -1,24 +1,20 @@ #[cfg(test)] -include!("tests/tests.rs"); +include!("cache_regression_tests.rs"); #[cfg(test)] -include!("tests/scope_tests.rs"); - +include!("policy/scope_tests.rs"); #[cfg(test)] -include!("tests/dashboard_tests.rs"); - +include!("dashboard_tests.rs"); #[cfg(test)] -include!("tests/v022_cache_tests.rs"); - +include!("v022_cache_tests.rs"); #[cfg(test)] -include!("tests/cold_tier_tests.rs"); - +include!("cold/cold_tier_tests.rs"); #[cfg(test)] -include!("tests/http_invalidation_tests.rs"); +include!("invalidation/http_invalidation_tests.rs"); #[cfg(test)] -include!("tests/hot_hit_034_tests.rs"); +include!("lookup/hot_hit_034_tests.rs"); diff --git a/src/cache/tests/cold_tier_tests.rs b/src/cache/tests/cold_tier_tests.rs deleted file mode 100644 index 5d1000a..0000000 --- a/src/cache/tests/cold_tier_tests.rs +++ /dev/null @@ -1,857 +0,0 @@ -mod cold_tier_tests { -use super::*; -use http_body_util::BodyExt; - -fn cold_settings() -> Settings { - Settings { - enabled: true, - max_entries: 1, - max_bytes: 256 * 1024, - max_object_bytes: 64 * 1024, - cold_enabled: true, - cold_path: "cache/cold-test".into(), - cold_max_bytes: 2 * 1024 * 1024, - cold_max_entries: 16, - cold_after_seconds: 300, - ..Settings::default() - } -} - -async fn fill(cache: &Arc, settings: &Settings, path: &str, body: &'static str) -> Ticket { - let ticket = cache - .ticket_scoped( - 11, - "cold-test", - "http", - "cold.example", - "origin@127.0.0.1:8080", - path, - &HeaderMap::new(), - ) - .unwrap(); - let mut response = Response::new(Body::from(body)); - response.headers_mut().insert( - "cache-control", - HeaderValue::from_static("public, max-age=120"), - ); - response - .headers_mut() - .insert("etag", HeaderValue::from_static("\"cold-v1\"")); - let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap(); - cache - .wrap(response, ticket.clone(), plan, settings) - .into_body() - .collect() - .await - .unwrap(); - ticket -} - -async fn wait_for_cold(cache: &Cache, expected: usize) { - for _ in 0..100 { - if cache.view().cold_entries >= expected { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("cold tier did not persist in time"); -} - -#[tokio::test] -async fn selective_purge_removes_matching_cold_object() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - let first = fill(&cache, &settings, "/one?v=1", "first-payload").await; - let _second = fill(&cache, &settings, "/two", "second-payload").await; - wait_for_cold(&cache, 1).await; - assert_eq!(cache.view().cold_entries, 1); - let result = cache - .invalidate(InvalidationRequest { - operation: InvalidationOperation::Purge, - selector: InvalidationSelector::Exact, - value: "/one?v=1".into(), - host: Some("cold.example".into()), - vhost: Some("cold-test".into()), - ttl_seconds: None, - confirm_wide: false, - }) - .unwrap(); - assert_eq!(result.affected_entries, 1); - assert_eq!(cache.view().cold_entries, 0); - assert!(cache.get_tiered(&first, &settings).await.is_none()); - shutdown.cancel(); -} - -#[tokio::test] -async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - let shutdown = CancellationToken::new(); - let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone()); - let ticket = cache.ticket_scoped( - 11, "cold-test", "http", "cold.example", "origin@127.0.0.1:8080", - "/aged-theme.css", &HeaderMap::new(), - ).unwrap(); - let body = b"body { color: red; margin: 0px; }"; - let mut origin = Response::new(Body::from(Bytes::from_static(body))); - origin.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=2592000")); - origin.headers_mut().insert("age", HeaderValue::from_static("1553483")); - origin.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap()); - origin.headers_mut().insert("content-type", HeaderValue::from_static("text/css")); - origin.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap()); - origin.headers_mut().insert("etag", HeaderValue::from_static("\"origin\"")); - let origin_plan = plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap(); - let transformed = crate::transform::apply(origin, "/aged-theme.css", - &crate::transform::Settings { enabled: true, css: true, ..Default::default() }, false - ).await.unwrap(); - let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings, Duration::ZERO).unwrap(); - cache.wrap(transformed, ticket.clone(), origin_plan.restrict(transformed_plan), &settings) - .into_body().collect().await.unwrap(); - assert_eq!(cache.view().entries, 1); - let _another = fill(&cache, &settings, "/another", "another-payload").await; - wait_for_cold(&cache, 1).await; - let row = cache.dashboard(Some("cold-test"), 0, 20).objects - .into_iter().find(|v| v.path == "/aged-theme.css").unwrap(); - assert_eq!(row.tier, "cold"); - assert!(row.transformed); - assert!(row.remaining_ttl_seconds >= 86_000); - shutdown.cancel(); - drop(cache); - let shutdown = CancellationToken::new(); - let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone()); - let hit = cache.get_tiered(&ticket, &settings).await.expect("aged transformed COLD should HIT after restart"); - assert!(hit.transformed); - assert!(hit.remaining_ttl >= 86_000); - let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes(); - assert_ne!(transformed_body.as_ref(), &body[..]); - shutdown.cancel(); -} - -#[tokio::test] -async fn lru_demotion_persists_and_survives_restart() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - settings.validate().unwrap(); - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - let first = fill(&cache, &settings, "/one", "first-payload").await; - let _second = fill(&cache, &settings, "/two", "second-payload").await; - wait_for_cold(&cache, 1).await; - assert_eq!(cache.view().entries, 1); - assert_eq!(cache.view().cold_entries, 1); - let cold = cache.dashboard(Some("cold-test"), 0, 50); - assert!(cold - .objects - .iter() - .any(|row| row.path == "/one" && row.tier == "cold")); - - shutdown.cancel(); - drop(cache); - - let restart_shutdown = CancellationToken::new(); - let restarted = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - restart_shutdown.clone(), - ); - assert_eq!(restarted.view().cold_entries, 1); - let hit = restarted - .get_tiered(&first, &settings) - .await - .expect("cold object should be readable after restart"); - let payload = hit.response.into_body().collect().await.unwrap().to_bytes(); - assert_eq!(payload, Bytes::from_static(b"first-payload")); - assert_eq!(restarted.view().cold_hits, 1); - assert_eq!(restarted.view().cold_promotions, 1); - restart_shutdown.cancel(); -} - -#[tokio::test] -async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - let shutdown = CancellationToken::new(); - let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone()); - let first = fill(&cache, &settings, "/one", "first-payload").await; - fill(&cache, &settings, "/two", "second-payload").await; - wait_for_cold(&cache, 1).await; - { - let mut cold = cache.cold.lock().unwrap(); - for entry in cold.entries.values_mut() { - entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60); - } - } - let dashboard = cache.dashboard(Some("cold-test"), 0, 50); - assert_eq!(dashboard.matching_objects, 2); - let row = dashboard.objects.iter().find(|row| row.path == "/one").unwrap(); - assert_eq!(row.tier, "cold"); - assert_eq!(row.remaining_ttl_seconds, 0); - assert!(row.age_seconds >= 2 * 60 * 60); - assert_eq!(row.storage_bytes, cache.view().cold_bytes); - assert!(cache.get_tiered(&first, &settings).await.is_none()); - let mut stale = first; - stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale")); - let hit = cache.get_tiered(&stale, &settings).await.expect("explicit max-stale can reuse the retained disk object"); - assert!(hit.stale); - assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "first-payload"); - shutdown.cancel(); -} - -#[tokio::test] -async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 8; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - - let transformed_ticket = cache - .ticket_scoped( - 11, - "cold-test", - "http", - "cold.example", - "origin@127.0.0.1:8080", - "/transformed.json", - &HeaderMap::new(), - ) - .unwrap(); - let mut transformed = Response::new(Body::from(r#"{"a":1}"#)); - transformed.headers_mut().insert( - "cache-control", - HeaderValue::from_static( - "public, max-age=120, stale-while-revalidate=30, stale-if-error=90", - ), - ); - transformed.headers_mut().insert( - "etag", - HeaderValue::from_static("\"rustproxy-representation\""), - ); - transformed - .headers_mut() - .insert("content-length", HeaderValue::from_static("7")); - transformed - .extensions_mut() - .insert(crate::transform::OriginValidators { - etag: Some(HeaderValue::from_static("\"origin-v1\"")), - last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")), - }); - transformed - .extensions_mut() - .insert(crate::transform::Applied { - kind: crate::transform::Kind::Json, - original_bytes: 9, - transformed_bytes: 7, - }); - let transformed_plan = plan( - StatusCode::OK, - transformed.headers(), - &settings, - Duration::ZERO, - ) - .unwrap(); - cache - .wrap( - transformed, - transformed_ticket.clone(), - transformed_plan, - &settings, - ) - .into_body() - .collect() - .await - .unwrap(); - - let protected_ticket = cache - .ticket_scoped( - 11, - "cold-test", - "http", - "cold.example", - "origin@127.0.0.1:8080", - "/protected.css", - &HeaderMap::new(), - ) - .unwrap(); - let original_body = "body { color: red; }"; - let mut protected = Response::new(Body::from(original_body)); - protected.headers_mut().insert( - "cache-control", - HeaderValue::from_static("public, max-age=120, immutable, no-transform"), - ); - protected - .headers_mut() - .insert("etag", HeaderValue::from_static("\"origin-css\"")); - protected.extensions_mut().insert(crate::compression::Skip); - let protected_plan = plan( - StatusCode::OK, - protected.headers(), - &settings, - Duration::ZERO, - ) - .unwrap(); - cache - .wrap( - protected, - protected_ticket.clone(), - protected_plan, - &settings, - ) - .into_body() - .collect() - .await - .unwrap(); - - assert_eq!(cache.flush_hot_to_cold().await, 2); - wait_for_cold(&cache, 2).await; - shutdown.cancel(); - drop(cache); - - let restart_shutdown = CancellationToken::new(); - let restarted = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - restart_shutdown.clone(), - ); - assert_eq!(restarted.view().cold_entries, 2); - // COLD entries intentionally keep validators and headers on disk to bound RAM. - // Inspect them through the same verified metadata loader used on COLD hits. - let (key, transformed) = { - let cold = restarted.cold.lock().unwrap(); - cold.entries - .iter() - .find(|(_, entry)| entry.path == "/transformed.json") - .map(|(key, entry)| (*key, entry.clone())) - .expect("transformed cold entry") - }; - assert_eq!(transformed.stale_while_revalidate, 30); - assert_eq!(transformed.stale_if_error, 90); - assert!(transformed.has_validator); - assert!(transformed.transformed); - let metadata = read_cold_metadata(&key, &transformed) - .await - .expect("persisted transformed metadata"); - assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\""); - assert_eq!( - metadata.origin_last_modified.as_ref().unwrap(), - "Mon, 26 Jan 2026 23:11:40 GMT" - ); - assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\""); - - let transformed_hit = restarted - .get_tiered(&transformed_ticket, &settings) - .await - .expect("transformed object after restart"); - assert_eq!( - transformed_hit.response.headers()["etag"], - "\"rustproxy-representation\"" - ); - assert!(transformed_hit.transformed); - assert!(transformed_hit - .response - .extensions() - .get::() - .is_some()); - assert_eq!( - transformed_hit - .response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(), - Bytes::from_static(br#"{"a":1}"#) - ); - - let protected_hit = restarted - .get_tiered(&protected_ticket, &settings) - .await - .expect("no-transform object after restart"); - assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\""); - assert!(crate::transform::no_transform( - protected_hit.response.headers() - )); - assert!(protected_hit - .response - .extensions() - .get::() - .is_some()); - assert_eq!( - protected_hit - .response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(), - Bytes::from_static(b"body { color: red; }") - ); - restart_shutdown.cancel(); -} - -#[tokio::test] -async fn cold_policy_can_disable_lru_demotion() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.cold_on_lru = false; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/one", "first-payload").await; - fill(&cache, &settings, "/two", "second-payload").await; - tokio::time::sleep(Duration::from_millis(30)).await; - let view = cache.view(); - assert_eq!(view.entries, 1); - assert_eq!(view.cold_entries, 0); - assert_eq!(view.evictions, 1); - shutdown.cancel(); -} - -#[tokio::test] -async fn cold_policy_can_disable_idle_demotion() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 8; - settings.cold_on_idle = false; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/idle", "idle-payload").await; - { - let mut store = cache.store.lock().unwrap(); - for entry in store.entries.values_mut() { - entry.last_access = Instant::now() - Duration::from_secs(600); - } - } - cache.demote_idle(); - assert_eq!(cache.view().entries, 1); - assert_eq!(cache.view().cold_entries, 0); - - settings.cold_on_idle = true; - cache.configure(settings.clone()); - cache.demote_idle(); - wait_for_cold(&cache, 1).await; - assert_eq!(cache.view().entries, 0); - shutdown.cancel(); -} - -#[tokio::test] -async fn cold_policy_can_demote_by_total_hot_residency_age() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 8; - settings.cold_on_idle = false; - settings.cold_on_age = true; - settings.cold_max_hot_seconds = 5; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/aged", "aged-payload").await; - { - let mut store = cache.store.lock().unwrap(); - for entry in store.entries.values_mut() { - entry.resident_since = Instant::now() - Duration::from_secs(60); - entry.last_access = Instant::now(); - } - } - cache.demote_idle(); - wait_for_cold(&cache, 1).await; - assert_eq!(cache.view().entries, 0); - assert_eq!(cache.view().cold_entries, 1); - shutdown.cancel(); -} - -#[tokio::test] -async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 8; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/one", "first-payload").await; - fill(&cache, &settings, "/two", "second-payload").await; - assert_eq!(cache.view().entries, 2); - assert!(cache.view().resident_bytes > 0); - assert_eq!(cache.view().cold_entries, 0); - - assert_eq!(cache.flush_hot_to_cold().await, 2); - let view = cache.view(); - assert_eq!(view.entries, 0); - assert_eq!(view.resident_bytes, 0); - assert_eq!(view.hot_logical_bytes, 0); - assert_eq!( - view.transient_bytes, 0, - "persisted ColdEntry must not retain the body allocation" - ); - assert_eq!(view.cold_entries, 2); - shutdown.cancel(); - - let restart_shutdown = CancellationToken::new(); - let restarted = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - restart_shutdown.clone(), - ); - assert_eq!(restarted.view().cold_entries, 2); - restart_shutdown.cancel(); -} - -#[tokio::test] -async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 8; - settings.cold_on_shutdown = false; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/update", "update-payload").await; - assert_eq!(cache.flush_hot_to_cold().await, 0); - assert_eq!(cache.view().entries, 1); - assert_eq!(cache.flush_hot_to_cold_for_update().await, 1); - let view = cache.view(); - assert_eq!(view.entries, 0); - assert_eq!(view.resident_bytes, 0); - assert_eq!(view.cold_entries, 1); - shutdown.cancel(); -} - -#[tokio::test] -async fn graceful_shutdown_flush_can_be_disabled() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 8; - settings.cold_on_shutdown = false; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/one", "first-payload").await; - assert_eq!(cache.flush_hot_to_cold().await, 0); - assert_eq!(cache.view().entries, 1); - assert_eq!(cache.view().cold_entries, 0); - shutdown.cancel(); -} - -#[tokio::test] -async fn cold_cache_uses_two_hex_character_shards() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/one", "first-payload").await; - fill(&cache, &settings, "/two", "second-payload").await; - wait_for_cold(&cache, 1).await; - let root = dir.path().join(&settings.cold_path); - let mut shards = Vec::new(); - for item in std::fs::read_dir(&root).unwrap() { - let item = item.unwrap(); - let name = item.file_name().to_string_lossy().to_string(); - assert!( - item.path().is_dir(), - "cold root must contain shard directories only" - ); - assert_eq!(name.len(), 2); - assert!(name.bytes().all(|b| b.is_ascii_hexdigit())); - shards.push(item.path()); - } - assert!(!shards.is_empty()); - assert!(shards.iter().any(|shard| { - std::fs::read_dir(shard) - .unwrap() - .filter_map(|entry| entry.ok()) - .any(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - name.ends_with(".json") || name.ends_with(".body") - }) - })); - shutdown.cancel(); -} - -#[tokio::test] -async fn legacy_flat_cold_cache_is_not_migrated() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - let root = dir.path().join(&settings.cold_path); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(root.join("legacy.body"), b"old").unwrap(); - std::fs::write(root.join("legacy.json"), b"{}").unwrap(); - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings, - shutdown.clone(), - ); - assert_eq!(cache.view().cold_entries, 0); - assert!(!root.join("legacy.body").exists()); - assert!(!root.join("legacy.json").exists()); - shutdown.cancel(); -} - -#[tokio::test] -async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 1; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/one", "first-payload").await; - fill(&cache, &settings, "/two", "second-payload").await; - fill(&cache, &settings, "/three", "third-payload").await; - wait_for_cold(&cache, 2).await; - let job = cache - .start_cold_cleanup(ColdCleanupRequest { - unused_for_seconds: None, - oldest: Some(1), - hits_zero: false, - }) - .unwrap(); - for _ in 0..100 { - let current = cache.cold_cleanup_job(job.id).unwrap(); - if current.status == "done" { - assert_eq!(current.removed_entries, 1); - assert!(current.freed_bytes > 0); - assert_eq!(cache.view().cold_entries, 1); - shutdown.cancel(); - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("cold cleanup did not finish"); -} - -#[tokio::test] -async fn manual_cold_cleanup_is_single_flight() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings, - shutdown.clone(), - ); - let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap(); - let result = cache.start_cold_cleanup(ColdCleanupRequest { - unused_for_seconds: None, - oldest: Some(1), - hits_zero: false, - }); - assert!(result.is_err()); - shutdown.cancel(); -} - -#[tokio::test] -async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() { - let dir = tempfile::tempdir().unwrap(); - let mut settings = cold_settings(); - settings.max_entries = 1; - let shutdown = CancellationToken::new(); - let cache = Cache::start( - 11, - Some(dir.path().to_path_buf()), - settings.clone(), - shutdown.clone(), - ); - fill(&cache, &settings, "/one", "first-payload").await; - fill(&cache, &settings, "/two", "second-payload").await; - wait_for_cold(&cache, 1).await; - - let key = { - let mut cold = cache.cold.lock().unwrap(); - let key = *cold.entries.keys().next().unwrap(); - cold.entries.get_mut(&key).unwrap().pinned_reads = 1; - key - }; - let request = ColdCleanupRequest { - unused_for_seconds: None, - oldest: Some(1), - hits_zero: false, - }; - let result = cache - .remove_cold_candidates(Some(&request), None, None, 1) - .await; - assert_eq!(result.removed_entries, 0); - assert!(cache.cold.lock().unwrap().entries.contains_key(&key)); - - cache - .cold - .lock() - .unwrap() - .entries - .get_mut(&key) - .unwrap() - .pinned_reads = 0; - let result = cache - .remove_cold_candidates(Some(&request), None, None, 1) - .await; - assert_eq!(result.removed_entries, 1); - assert!(!cache.cold.lock().unwrap().entries.contains_key(&key)); - shutdown.cancel(); -} - -#[test] -fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() { - let cache = Cache::new(1); - let key = [7_u8; 32]; - let template = ColdEntry { - base_key: key, - vary: Vec::new(), - revision: 1, - scope: "app".to_owned(), - scheme: "https".to_owned(), - authority: "example.test".to_owned(), - domain: "example.test".to_owned(), - path: "/asset".to_owned(), - target: "https://example.test/asset".to_owned(), - tags: Vec::new(), - created_at: SystemTime::now(), - body_path: PathBuf::from("old.body"), - meta_path: PathBuf::from("old.json"), - body_bytes: 1, - body_sha256: [0u8; 32], - disk_bytes: 2, - stored_at: SystemTime::now(), - last_access: SystemTime::now(), - age_at_store_seconds: 0, - resident_age_at_store_seconds: 0, - ttl: 60, - stale_while_revalidate: 0, - stale_if_error: 0, - must_revalidate: false, - proxy_revalidate: false, - has_validator: false, - hits: 0, - transformed: false, - sequence: 1, - generation: 1, - skip_compression: false, - requires_revalidation: false, - pinned_reads: 1, - }; - cache - .cold - .lock() - .unwrap() - .entries - .insert(key, template.clone()); - let old_guard = ColdReadGuard { - cache: &cache, - key, - generation: 1, - }; - - let mut replacement = template; - replacement.sequence = 2; - replacement.generation = 2; - replacement.pinned_reads = 1; - cache.cold.lock().unwrap().entries.insert(key, replacement); - drop(old_guard); - - let cold = cache.cold.lock().unwrap(); - let replacement = cold.entries.get(&key).unwrap(); - assert_eq!(replacement.generation, 2); - assert_eq!(replacement.pinned_reads, 1); -} - -#[test] -fn cold_path_cannot_escape_data_directory() { - let mut settings = Settings::default(); - settings.cold_path = "../outside".into(); - assert!(settings.validate().is_err()); - settings.cold_path = "/tmp/outside".into(); - assert!(settings.validate().is_err()); - settings.cold_path = "cache/cold".into(); - assert!(settings.validate().is_ok()); -} - -#[tokio::test] -async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() { - let dir = tempfile::tempdir().unwrap(); - let settings = cold_settings(); - let shutdown = CancellationToken::new(); - let mut fingerprints = HashMap::new(); - fingerprints.insert("cold-test".to_owned(), 77); - let cache = Cache::start_with_scope_revisions( - 11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(), - fingerprints.clone(), - ); - fill(&cache, &settings, "/one", "stable-contents").await; - fill(&cache, &settings, "/two", "second-contents").await; - wait_for_cold(&cache, 1).await; - shutdown.cancel(); - drop(cache); - - let restart = CancellationToken::new(); - let same = Cache::start_with_scope_revisions( - 11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(), - fingerprints.clone(), - ); - assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD"); - restart.cancel(); - drop(same); - - fingerprints.insert("cold-test".to_owned(), 78); - let restart = CancellationToken::new(); - let changed = Cache::start_with_scope_revisions( - 11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(), fingerprints, - ); - assert_eq!(changed.view().cold_entries, 0, "old COLD must never survive a content revision"); - restart.cancel(); -} -} diff --git a/src/cache/tickets.rs b/src/cache/tickets.rs index a6c8e62..fe54ce7 100644 --- a/src/cache/tickets.rs +++ b/src/cache/tickets.rs @@ -1,7 +1,4 @@ impl Cache { - /// Varnish-style primary hash: public URL/host identity first, with response - /// `Vary` handled as a secondary variant key. Irrelevant request headers and - /// load-balanced backend members therefore do not fragment the cache. pub fn ticket( &self, revision: u64, @@ -23,12 +20,11 @@ impl Cache { target: &str, headers: &HeaderMap, ) -> Option { - self.ticket_scoped_impl(None, revision, scope, scheme, authority, _route, target, headers) + self.ticket_scoped_impl( + None, revision, scope, scheme, authority, _route, target, headers, + ) } - /// Production requests bind the cache ticket to the immutable routing - /// configuration used to authorize/route the request. After a config save, - /// stale in-flight snapshots cannot re-fill a scope after its purge. #[allow(clippy::too_many_arguments)] pub fn ticket_scoped_for_config( &self, @@ -41,7 +37,16 @@ impl Cache { target: &str, headers: &HeaderMap, ) -> Option { - self.ticket_scoped_impl(Some(control_revision), revision, scope, scheme, authority, route, target, headers) + self.ticket_scoped_impl( + Some(control_revision), + revision, + scope, + scheme, + authority, + route, + target, + headers, + ) } #[allow(clippy::too_many_arguments)] @@ -61,9 +66,6 @@ impl Cache { } let domain = crate::config::request_host(authority) .unwrap_or_else(|_| authority.chars().take(253).collect()); - // Varnish built-in hashing lowercases Host before hashing, but otherwise - // keeps the authority (including an explicit port) distinct. RustProxy has - // already validated the authority before this point. let hash_scheme = scheme.to_ascii_lowercase(); let hash_authority = authority.to_ascii_lowercase(); let mut digest = Sha256::new(); @@ -85,7 +87,7 @@ impl Cache { let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); (store.revision == revision && expected_control_revision.is_none_or(|expected| expected == store.control_revision)) - .then_some(Ticket { + .then_some(Ticket { scheme: hash_scheme, authority: hash_authority, domain, @@ -336,8 +338,6 @@ impl Cache { self: &Arc, cached: &Revalidation, ) -> Option { - // Vary variants share a base Ticket key. Deduplicate only the exact cached - // representation so an SWR refresh for one variant cannot suppress another. let key = cached.storage_key; let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner()); if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) { diff --git a/src/cache/tests/v022_cache_tests.rs b/src/cache/v022_cache_tests.rs similarity index 83% rename from src/cache/tests/v022_cache_tests.rs rename to src/cache/v022_cache_tests.rs index 250e9f2..8e1b382 100644 --- a/src/cache/tests/v022_cache_tests.rs +++ b/src/cache/v022_cache_tests.rs @@ -83,21 +83,55 @@ mod v022_cache_tests { ] { let cache = Cache::new(22); let settings = active_settings(); - let ticket = cache.ticket(22, "http", "example.test", "origin", "/transform.js", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 22, + "http", + "example.test", + "origin", + "/transform.js", + &HeaderMap::new(), + ) + .unwrap(); let mut response = Response::new(Body::from(body.clone())); - response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120")); - response.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap()); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120"), + ); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&body.len().to_string()).unwrap(), + ); response.extensions_mut().insert(crate::transform::Applied { kind: crate::transform::Kind::Javascript, original_bytes: body.len(), transformed_bytes: body.len(), }); - let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + let policy = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); assert_eq!(policy.expected_length, Some(body.len())); - let downstream = cache.wrap(response, ticket.clone(), policy, &settings) - .into_body().collect().await.unwrap().to_bytes(); + let downstream = cache + .wrap(response, ticket.clone(), policy, &settings) + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); assert_eq!(downstream, body); - let cached = cache.get(&ticket).unwrap().response.into_body().collect().await.unwrap().to_bytes(); + let cached = cache + .get(&ticket) + .unwrap() + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); assert_eq!(cached, body); assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0); } @@ -107,26 +141,55 @@ mod v022_cache_tests { async fn uniquely_owned_transformed_frame_is_cached_without_body_copy() { let cache = Cache::new(22); let settings = active_settings(); - let ticket = cache.ticket(22, "http", "example.test", "origin", "/owned.js", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 22, + "http", + "example.test", + "origin", + "/owned.js", + &HeaderMap::new(), + ) + .unwrap(); let payload = Bytes::from(vec![b'z'; 8192]); let original_allocation = payload.as_ptr(); let mut response = Response::new(Body::from(payload)); - response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120")); - response.headers_mut().insert("content-length", HeaderValue::from_static("8192")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120"), + ); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("8192")); response.extensions_mut().insert(crate::transform::Applied { kind: crate::transform::Kind::Javascript, original_bytes: 8192, transformed_bytes: 8192, }); - let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); - let downstream = cache.wrap(response, ticket.clone(), policy, &settings) - .into_body().collect().await.unwrap().to_bytes(); + let policy = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + let downstream = cache + .wrap(response, ticket.clone(), policy, &settings) + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); assert_eq!(downstream.len(), 8192); assert_eq!(downstream.as_ptr(), original_allocation); let hit = cache.get(&ticket).unwrap(); let cached = hit.response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(cached, downstream); - assert_eq!(cached.as_ptr(), original_allocation, "cache fill copied the transformed body"); + assert_eq!( + cached.as_ptr(), + original_allocation, + "cache fill copied the transformed body" + ); assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0); } @@ -180,8 +243,22 @@ mod v022_cache_tests { ) .await; let cases = [ - (ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"), - (ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"), + ( + ByteRange { + start: None, + end: Some(3), + }, + "hij", + "bytes 7-9/10", + ), + ( + ByteRange { + start: Some(7), + end: None, + }, + "hij", + "bytes 7-9/10", + ), ]; for (range, expected, content_range) in cases { let hit = cache.get(&ticket).unwrap().apply_range(range, None); @@ -192,38 +269,66 @@ mod v022_cache_tests { } let unsatisfied = cache.get(&ticket).unwrap().apply_range( - ByteRange { start: Some(50), end: None }, + ByteRange { + start: Some(50), + end: None, + }, None, ); - assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE); - assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10"); assert_eq!( - unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(), + unsatisfied.response.status(), + StatusCode::RANGE_NOT_SATISFIABLE + ); + assert_eq!( + unsatisfied.response.headers()["content-range"], + "bytes */10" + ); + assert_eq!( + unsatisfied + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .len(), 0 ); let mismatch = HeaderValue::from_static("\"asset-v2\""); let full = cache.get(&ticket).unwrap().apply_range( - ByteRange { start: Some(0), end: Some(2) }, + ByteRange { + start: Some(0), + end: Some(2), + }, Some(&mismatch), ); assert_eq!(full.response.status(), StatusCode::OK); assert!(!full.response.headers().contains_key("content-range")); assert_eq!( - full.response.into_body().collect().await.unwrap().to_bytes(), + full.response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), Bytes::from_static(b"abcdefghij") ); let matched = HeaderValue::from_static("\"asset-v1\""); let partial = cache.get(&ticket).unwrap().apply_range( - ByteRange { start: Some(0), end: Some(2) }, + ByteRange { + start: Some(0), + end: Some(2), + }, Some(&matched), ); assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT); } #[test] - fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() { + fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation( + ) { let mut multipart = HeaderMap::new(); multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5")); assert_eq!( @@ -318,13 +423,23 @@ mod v022_cache_tests { let mut settings = active_settings(); settings.collapse_wait_timeout_ms = 10; let ticket = cache - .ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new()) + .ticket( + 22, + "http", + "collapse.example", + "origin", + "/timeout", + &HeaderMap::new(), + ) .unwrap(); let leader = match cache.claim_fill(&ticket, &settings).await { FillClaim::Leader(guard) => guard, _ => panic!("missing leader"), }; - assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout)); + assert!(matches!( + cache.claim_fill(&ticket, &settings).await, + FillClaim::Timeout + )); assert_eq!(cache.view().collapse_timeouts, 1); drop(leader); assert!(matches!( @@ -339,7 +454,14 @@ mod v022_cache_tests { let mut settings = active_settings(); settings.collapse_wait_timeout_ms = 5_000; let ticket = cache - .ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new()) + .ticket( + 22, + "http", + "collapse.example", + "origin", + "/cancel", + &HeaderMap::new(), + ) .unwrap(); let leader = match cache.claim_fill(&ticket, &settings).await { FillClaim::Leader(guard) => guard, @@ -377,10 +499,24 @@ mod v022_cache_tests { let mut pl_headers = HeaderMap::new(); pl_headers.insert("accept-language", HeaderValue::from_static("pl")); let en = cache - .ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers) + .ticket( + 22, + "http", + "vary-collapse.example", + "origin", + "/page", + &en_headers, + ) .unwrap(); let pl = cache - .ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers) + .ticket( + 22, + "http", + "vary-collapse.example", + "origin", + "/page", + &pl_headers, + ) .unwrap(); assert_eq!(en.key, pl.key, "Vary is learned from the response"); let leader = match cache.claim_fill(&en, &settings).await { @@ -455,7 +591,14 @@ mod v022_cache_tests { fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() { let cache = Cache::new(22); let ticket = cache - .ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new()) + .ticket( + 22, + "http", + "refresh.example", + "origin", + "/asset", + &HeaderMap::new(), + ) .unwrap(); let mut settings = active_settings(); settings.proactive_refresh_enabled = true; @@ -511,7 +654,9 @@ mod v022_cache_tests { &HeaderMap::new(), ) .unwrap(); - assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none()); + assert!(cache + .begin_proactive_schedule(&overflow, &settings) + .is_none()); assert!(cache.view().proactive_skipped > 0); drop(guards); } @@ -557,11 +702,17 @@ mod v022_cache_tests { #[tokio::test] async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() { fn current_ticket(cache: &Cache, old: &Ticket) -> Ticket { - // Each post-PURGE request obtains the current publication generation. - // Reusing an old ticket would reject unrelated entries too and make - // assertions about removed objects pass without testing the selector. - cache.ticket_scoped(22, &old.scope, &old.scheme, &old.authority, - "origin@127.0.0.1:8080", &old.target, &old.request_headers).unwrap() + cache + .ticket_scoped( + 22, + &old.scope, + &old.scheme, + &old.authority, + "origin@127.0.0.1:8080", + &old.target, + &old.request_headers, + ) + .unwrap() } let cache = Cache::new(22); let one = store( @@ -595,8 +746,19 @@ mod v022_cache_tests { .unwrap(); assert_eq!(result.affected_entries, 1); assert!(cache.get(¤t_ticket(&cache, &one)).is_none()); - let survivor = cache.get(¤t_ticket(&cache, &two)).expect("unrelated query remains stored"); - assert_eq!(survivor.response.into_body().collect().await.unwrap().to_bytes(), "two"); + let survivor = cache + .get(¤t_ticket(&cache, &two)) + .expect("unrelated query remains stored"); + assert_eq!( + survivor + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + "two" + ); let three = store( &cache, @@ -832,7 +994,10 @@ mod v022_cache_tests { "fresh", ) .await; - assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects"); + assert!( + cache.get(&fresh).is_some(), + "old BAN must not reject newer objects" + ); assert_eq!(cache.bans().len(), 1); assert!(cache.remove_ban(ban_id)); assert!(cache.bans().is_empty()); @@ -898,9 +1063,6 @@ mod v022_cache_tests { "unchanged", ) .await; - // A production cache may see an unbounded stream of PURGEs for URLs - // that will never be cached again. The invalidated-key index must - // not remember all of those removed objects indefinitely. for index in 0..512 { let target = format!("/unique-{index}"); let old = store( @@ -924,15 +1086,16 @@ mod v022_cache_tests { )) .unwrap(); assert_eq!(result.affected_entries, 1); - assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale"); + assert!( + !cache.ticket_current(&old), + "pre-purge ticket must remain stale" + ); let state = cache.store.lock().unwrap_or_else(|e| e.into_inner()); assert!( state.key_generations.len() <= 1, "key generations must not accumulate for already purged URLs" ); } - // A new ticket after the invalidation barrier is valid and can fill - // the same URL again, preserving ordinary cache behavior. let new = store( &cache, "app", @@ -956,6 +1119,9 @@ mod v022_cache_tests { &HeaderMap::new(), ) .unwrap(); - assert!(cache.get(&stable).is_some(), "unrelated entries must survive"); + assert!( + cache.get(&stable).is_some(), + "unrelated entries must survive" + ); } } diff --git a/src/compression.rs b/src/compression/mod.rs similarity index 88% rename from src/compression.rs rename to src/compression/mod.rs index 75ce4ad..e707e56 100644 --- a/src/compression.rs +++ b/src/compression/mod.rs @@ -1,6 +1,3 @@ -//! Streaming content negotiation with deterministic server-side preference. -//! Existing Content-Encoding, ranges, upgrades, event streams and no-transform -//! responses are never recompressed. No full-body aggregation is introduced. use anyhow::{ensure, Result}; use axum::{body::Body, response::Response}; use http::{HeaderMap, HeaderValue, Method, Request}; @@ -12,8 +9,6 @@ use tower_http::compression::{ Compression, CompressionLevel, }; -/// A u64 predicate independent of tower-http's version-specific SizeAbove type. -/// Unknown-size streams are compressed without buffering, like tower-http. #[derive(Clone, Copy)] struct MinimumSize(u64); impl Predicate for MinimumSize { @@ -72,8 +67,6 @@ impl Settings { } } -/// Per-vhost/Web-Service compression policy. None inherits the enclosing policy. -/// Global Response settings remain the hard codec ceiling and own minimum size. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Overrides { @@ -113,9 +106,6 @@ impl Overrides { #[derive(Clone)] pub struct Skip; -/// Encoding applied by RustProxy itself. This marker is kept in response -/// extensions so accounting never has to infer the selected codec from a -/// header that later response rules may rewrite. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppliedEncoding { Gzip, @@ -173,9 +163,6 @@ pub fn no_transform(headers: &HeaderMap) -> bool { }) } -/// Preserve exactly the request inputs used by encoding after forwarding has -/// consumed the request. Repeated field values and opaque bytes stay intact; -/// unrelated cookies, authorization and custom headers do not need a copy. pub(crate) fn request_headers(headers: &HeaderMap) -> HeaderMap { let mut selected = HeaderMap::new(); for name in ["accept-encoding", "range", "cache-control"] { @@ -228,7 +215,10 @@ fn quality(raw: &str) -> Option { Some((value * 1000.0).round() as u16) } -pub(crate) fn preferred_encoding(headers: &HeaderMap, settings: &Settings) -> Option { +pub(crate) fn preferred_encoding( + headers: &HeaderMap, + settings: &Settings, +) -> Option { let mut explicit = [None; 4]; let mut wildcard = None; for line in headers.get_all("accept-encoding").iter() { @@ -330,8 +320,6 @@ pub async fn encode( { response.extensions_mut().insert(applied); } - // A strong validator for identity bytes is not a strong validator of the - // compressed bytes. Preserve semantic validation as a weak ETag. if let Some(value) = response .headers() .get("etag") @@ -354,4 +342,4 @@ pub async fn encode( } #[cfg(test)] -include!("compression/tests.rs"); +include!("tests.rs"); diff --git a/src/config.rs b/src/config.rs index 3406386..fe9125d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,3 @@ -//! Desired configuration and its validated, immutable routing snapshot. include!("config/model.rs"); include!("config/snapshot.rs"); include!("config/internal_routing.rs"); @@ -8,3 +7,8 @@ include!("config/compile.rs"); #[cfg(test)] include!("config/tests.rs"); + +#[cfg(test)] +include!("config/route_policy_tests.rs"); +#[cfg(test)] +include!("config/transport_tests.rs"); diff --git a/src/config/backend.rs b/src/config/backend.rs index 364f963..717bade 100644 --- a/src/config/backend.rs +++ b/src/config/backend.rs @@ -60,14 +60,8 @@ pub(crate) fn compile_backend_with_timeout( Host::Ipv6(ip) => { crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr) } - // DNS destinations remain subject to the normal self-target guard, but - // are never trusted for administration-cookie passthrough. The special - // control-plane path must point at the listener explicitly by IP. Host::Domain(_) => false, }; - // Exact local listener targets are rejected by AddressPolicy by default, including DNS - // answers. An explicit per-backend risk acceptance can override only that self-target - // guard; remote origins on any valid backend port, including 9090, remain valid. let authority = match url.host().context("backend host missing")? { Host::Ipv6(ip) => format!("[{ip}]:{port}"), host => format!("{host}:{port}"), @@ -120,7 +114,6 @@ pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Resu (policy.proxy_addr, policy.tls_addr), ) } -/// Validate both active sockets and the sockets that will be used after restart. pub fn compile_with_defaults( config: Config, revision: u64, @@ -129,8 +122,6 @@ pub fn compile_with_defaults( ) -> Result { compile_reusing(config, revision, policy, defaults, None) } -/// Rebuild route policies and retain compatible backend clients, health observations, -/// full-exchange counters and affinity tokens through unrelated config edits. pub fn compile_reusing( config: Config, revision: u64, @@ -154,8 +145,6 @@ fn compile_local_hosts(config: &Config) -> Result { let mut claimed_hosts: HashMap> = HashMap::new(); for redirect in &config.redirects { let bindings = redirect.frontend.resolved(&config.listeners)?; - // Disabled redirect hosts reserve their names as vhosts do, preventing - // an overlapping fallback from unexpectedly taking traffic. for name in &redirect.frontend.hosts { let claimed = claimed_hosts.entry(name.clone()).or_default(); for a in claimed.iter() { @@ -181,8 +170,6 @@ fn compile_local_hosts(config: &Config) -> Result { } for host in &config.not_found_hosts { let bindings = host.frontend.resolved(&config.listeners)?; - // Disabled local-response hosts reserve names too, avoiding accidental - // exposure through a broader wildcard vhost. for name in &host.frontend.hosts { let claimed = claimed_hosts.entry(name.clone()).or_default(); for a in claimed.iter() { diff --git a/src/config/compile.rs b/src/config/compile.rs index 8a86ebb..f686faa 100644 --- a/src/config/compile.rs +++ b/src/config/compile.rs @@ -10,9 +10,6 @@ pub fn compile_with_active( config.logging.validate()?; config.error_log.validate()?; let desired_admin = config.administration.resolved_listen(policy.admin_addr); - // A persisted non-loopback address is only accepted with administration TLS. - // When `listen` is omitted, the startup/CLI address remains authoritative and - // its legacy plain-HTTP risk gate is enforced by main before compilation. ensure!( config.administration.listen.is_none() || desired_admin.ip().is_loopback() @@ -48,29 +45,53 @@ pub fn compile_with_active( config.compression.validate()?; config.server_header.validate()?; let forwarding = config.forwarding.compile()?; - ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)"); + ensure!( + config.php_fpm_profiles.len() <= 128, + "too many PHP-FPM profiles (max 128)" + ); let mut php_profiles = HashMap::new(); for profile in &mut config.php_fpm_profiles { - profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?; - ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(), - "duplicate PHP-FPM profile id"); + profile + .validate() + .with_context(|| format!("PHP-FPM profile {}", profile.id))?; + ensure!( + php_profiles + .insert(profile.id.clone(), profile.php.clone()) + .is_none(), + "duplicate PHP-FPM profile id" + ); } - ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)"); + ensure!( + config.web_services.len() <= 128, + "too many Web Services (max 128)" + ); let mut web_services = HashMap::new(); - // PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused - // workers. Different directive sets on one socket can leak to the next vhost. - // Report unsafe legacy configurations but keep them loadable for migration. - let mut php_endpoint_policies: HashMap, std::collections::BTreeMap)> = HashMap::new(); + let mut php_endpoint_policies: HashMap< + String, + ( + String, + std::collections::BTreeMap, + std::collections::BTreeMap, + ), + > = HashMap::new(); for service in &mut config.web_services { - service.validate().with_context(|| format!("Web Service {}", service.id))?; - // A profile changes effective runtime settings, not the saved standalone - // PHP configuration. Detaching a profile restores local settings exactly. + service + .validate() + .with_context(|| format!("Web Service {}", service.id))?; let mut effective = service.clone(); if let Some(id) = &service.php_profile_id { - effective.php = php_profiles.get(id).with_context(|| { - format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id) - })?.clone(); - effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; + effective.php = php_profiles + .get(id) + .with_context(|| { + format!( + "Web Service {} references unknown PHP-FPM profile {}", + service.id, id + ) + })? + .clone(); + effective + .validate() + .with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; } if effective.php.enabled { let php = &effective.php; @@ -82,13 +103,25 @@ pub fn compile_with_active( ); } } else { - php_endpoint_policies.insert(php.endpoint.clone(), ( - effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(), - )); + php_endpoint_policies.insert( + php.endpoint.clone(), + ( + effective.id.clone(), + php.php_values.clone(), + php.php_admin_values.clone(), + ), + ); } } - let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?); - ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id"); + let compiled = Arc::new( + effective + .compile() + .with_context(|| format!("Web Service {}", service.id))?, + ); + ensure!( + web_services.insert(service.id.clone(), compiled).is_none(), + "duplicate Web Service id" + ); } ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)"); let mut vhost_ids = HashSet::new(); @@ -202,8 +235,6 @@ pub fn compile_with_active( guarded_bindings.extend(previous.listeners.iter().map(|l| l.address)); } guarded_bindings.extend(active.iter().map(|l| l.address)); - // Startup/default addresses remain protected backend destinations even when - // the desired named-listener plan changes before a restart. guarded_bindings.push(defaults.0); guarded_bindings.extend(defaults.1); guarded_bindings.extend([ @@ -264,22 +295,29 @@ pub fn compile_with_active( "duplicate ACL profile id" ); } - // Prevent unbounded DNS ACL work across the entire published configuration. crate::acl::dynamic::configured_hostnames(&config)?; for agent in &config.docker_agents.agents { - for profile_id in crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) { + for profile_id in + crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) + { ensure!( acl_profiles.contains_key(profile_id), "Docker agent {} references unknown default ACL profile {}", - agent.id, profile_id + agent.id, + profile_id ); } for assignment in &agent.assignments { - for profile_id in crate::acl::profile_ids(assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids) { + for profile_id in crate::acl::profile_ids( + assignment.acl_profile_id.as_deref(), + &assignment.acl_profile_ids, + ) { ensure!( acl_profiles.contains_key(profile_id), "Docker assignment {} on agent {} references unknown ACL profile {}", - assignment.service_key, agent.id, profile_id + assignment.service_key, + agent.id, + profile_id ); } } @@ -392,7 +430,9 @@ fn validate_route_rewrite( Ok(()) } -fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result> { +fn validate_location_redirect( + redirect: Option<&crate::vhost::LocationRedirect>, +) -> Result> { let Some(redirect) = redirect else { return Ok(None); }; @@ -406,26 +446,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) && !redirect.target.chars().any(char::is_control), "invalid location redirect target" ); - // Replace capture references with a harmless segment so URL structure can - // be validated before requests reach the data plane. - let authority_end = redirect.target.find("://").and_then(|start| { - let rest = &redirect.target[start + 3..]; - rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len())) - }).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?; - ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority"); + let authority_end = redirect + .target + .find("://") + .and_then(|start| { + let rest = &redirect.target[start + 3..]; + rest.char_indices() + .find(|(_, ch)| matches!(ch, '/' | '?' | '#')) + .map(|(offset, _)| start + 3 + offset) + .or(Some(redirect.target.len())) + }) + .ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?; + ensure!( + !redirect.target[..authority_end].contains('$'), + "location redirect captures are not allowed in the URL authority" + ); let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?") .expect("static capture regex") .replace_all(&redirect.target, "capture"); - let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?; - ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host"); - ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials"); + let parsed = + url::Url::parse(&sample).context("location redirect target must be an absolute URL")?; + ensure!( + matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), + "location redirect target must use http/https and include a host" + ); + ensure!( + parsed.username().is_empty() && parsed.password().is_none(), + "location redirect target cannot contain credentials" + ); let rewrite_regex = match redirect.rewrite_pattern.as_deref() { None | Some("") => None, Some(pattern) => { - ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex"); + ensure!( + pattern.len() <= 1024 && !pattern.chars().any(char::is_control), + "invalid location redirect rewrite regex" + ); let mut builder = regex::RegexBuilder::new(pattern); - builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024); - Some(builder.build().context("invalid location redirect rewrite regex")?) + builder + .size_limit(2 * 1024 * 1024) + .dfa_size_limit(2 * 1024 * 1024); + Some( + builder + .build() + .context("invalid location redirect rewrite regex")?, + ) } }; Ok(Some(crate::config::CompiledLocationRedirect { diff --git a/src/config/internal_routing.rs b/src/config/internal_routing.rs index 217b9a8..c1cfbfa 100644 --- a/src/config/internal_routing.rs +++ b/src/config/internal_routing.rs @@ -1,7 +1,4 @@ impl Snapshot { - /// Resolve a vhost-local internal named location. Internal locations are - /// deliberately excluded from ordinary URI matching and can only be reached - /// by an explicit internal dispatch such as an error fallback. pub fn select_internal_on( &self, host: &str, @@ -25,21 +22,31 @@ impl Snapshot { Self::find_host_pattern(host, |pattern| { let owns_host = self.fallbacks.get(pattern).is_some_and(|routes| { routes.iter().any(|route| { - route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id) - && route.bindings.iter().any(|binding| binding.matches(local, tls)) + route + .frontend + .as_ref() + .is_some_and(|frontend| frontend.id == vhost_id) + && route + .bindings + .iter() + .any(|binding| binding.matches(local, tls)) }) }); if !owns_host { return None; } - // Some(None) stops at the owning hostname even when the named - // location is absent. Do not try a less-specific wildcard. Some(self.routes.get(pattern).and_then(|routes| { routes.iter().find(|route| { route.internal && route.name == target - && route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id) - && route.bindings.iter().any(|binding| binding.matches(local, tls)) + && route + .frontend + .as_ref() + .is_some_and(|frontend| frontend.id == vhost_id) + && route + .bindings + .iter() + .any(|binding| binding.matches(local, tls)) }) })) }) diff --git a/src/config/model.rs b/src/config/model.rs index 3f4f4e7..2866de4 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -36,8 +36,6 @@ pub struct Config { pub geoip_updates: crate::geoip::UpdateSettings, #[serde(default)] pub listeners: crate::settings::Listeners, - /// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet - /// persist an explicit `binding.http2_settings` object. #[serde(default)] pub http2: crate::settings::Http2Connections, #[serde(default)] @@ -112,20 +110,14 @@ pub struct Backend { pub address_allowlist: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub tls_ca_pem: Option, - /// Optional DNS name used for TLS SNI and certificate hostname verification. - /// TCP/DNS connection targeting remains pinned to `url`. #[serde(default, skip_serializing_if = "Option::is_none")] pub tls_server_name: Option, - /// Verify the HTTPS backend certificate chain and server identity. - /// Disabled by default for reverse-proxy compatibility; enable explicitly when required. #[serde(default)] pub tls_verify: bool, pub id: String, pub url: String, #[serde(default)] pub allow_private: bool, - /// Explicitly allow a backend that resolves to a local RustProxy listener. - /// This can expose the control plane or create a proxy loop and is off by default. #[serde(default)] pub accept_local_listener_risk: bool, #[serde(default)] @@ -136,12 +128,8 @@ pub struct Backend { pub enum UpstreamProtocol { #[default] Http1, - /// Legacy value `http2`: HTTPS uses ALPN H2/H1, HTTP uses H2C prior knowledge. - /// Retain its 0.32.2 meaning when loading unchanged saved configurations. Http2, - /// HTTPS negotiates H2/H1; plaintext uses H1 without H2C probing. Auto, - /// New explicit mode `h2`: require H2 for HTTPS and H2C for cleartext. H2, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -159,7 +147,6 @@ pub struct Update { pub struct ValidationPolicy { pub tls_addr: Option, pub cert_dir: Option, - /// Resolved runtime data directory. Exposed only through authenticated admin status. pub data_dir: Option, pub proxy_addr: SocketAddr, pub admin_addr: SocketAddr, @@ -170,21 +157,14 @@ pub struct CompiledBackend { pub client: crate::upstream::ProxyClient, pub websocket_client: crate::upstream::ProxyClient, pub id: String, - /// Configured network origin used by raw TCP probes and policy checks. pub origin: String, - /// HTTP request origin. For HTTPS this may carry a separate verified TLS name. pub request_origin: String, - /// Parsed once at configuration publish; reused for every upstream request. pub request_origin_uri: http::Uri, pub host_header: HeaderValue, pub preserve_host: bool, pub protocol: UpstreamProtocol, - /// Whether backend TLS certificate chain/name verification is enabled. - /// Retained in the compiled snapshot for failure diagnostics only. pub tls_verify: bool, pub accept_local_listener_risk: bool, - /// True only when this backend URL names the configured administration socket - /// directly. Used by the guarded control-plane reverse-proxy path. pub targets_admin_listener: bool, } #[derive(Debug, Clone)] @@ -208,7 +188,6 @@ pub struct CompiledRoute { pub frontend: Option>, pub pool: Option>, pub cache: Option, - /// Vhost-local stable location ID (`main` for the main source). pub name: String, pub id: String, pub internal: bool, @@ -237,10 +216,8 @@ pub struct CompiledFrontend { pub websocket_rfc8441: bool, pub websocket_rfc9220: bool, pub force_ssl: bool, - /// Optional quick www/non-www canonical redirect (from, to). pub www_redirect: Option<(String, String)>, pub forward_client_ip: bool, - /// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected. pub https_redirect_port: Option, pub http2_enabled: bool, pub http3_enabled: bool, @@ -264,7 +241,6 @@ pub struct CompiledFrontend { pub acl_profile_id: Option, pub profile_acl: Option, pub profile_country_access: crate::rate_limit::CountryAccess, - /// All Vhost profile scopes, checked before the inline ACL. pub profile_policies: Vec, pub acl: Option, pub headers: CompiledHeaders, @@ -295,7 +271,6 @@ pub struct Snapshot { pub tls: Option>, pub envelope: Envelope, routes: HashMap>>, - /// Main vhost backend fallback, kept separate from user Custom locations. fallbacks: HashMap>>, redirects: HashMap>>, not_found_hosts: HashMap>>, @@ -305,7 +280,6 @@ pub struct Snapshot { } impl std::fmt::Debug for Snapshot { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // QUIC crypto has no Debug implementation; never format private keys. f.debug_struct("Snapshot") .field("revision", &self.envelope.revision) .field("listeners", &self.listeners) diff --git a/src/config/route_policy_tests.rs b/src/config/route_policy_tests.rs new file mode 100644 index 0000000..d558070 --- /dev/null +++ b/src/config/route_policy_tests.rs @@ -0,0 +1,99 @@ +mod route_policy_tests { + use crate::{ + acl::{routing_path, Acl, Action, Rule as AclRule}, + config::{compile, demo_config, ValidationPolicy}, + headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule}, + vhost::{LocationResponse, PathRoute}, + }; + use http::HeaderMap; + + fn policy() -> ValidationPolicy { + ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } + } + + fn local_route() -> PathRoute { + PathRoute { + id: "local-status".into(), + path_prefix: "/status".into(), + response: Some(LocationResponse { + status: 200, + content_type: "text/plain; charset=utf-8".into(), + body: "ok".into(), + }), + ..PathRoute::default() + } + } + + #[test] + fn repeated_slashes_route_normally_but_reach_upstream_unchanged() { + let raw_target = "/api//v1/items"; + let routing_target = routing_path(raw_target).unwrap(); + assert_eq!(routing_target, "/api/v1/items"); + + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].routes = vec![PathRoute { + id: "api".into(), + path_prefix: "/api".into(), + ..PathRoute::default() + }]; + let snapshot = compile(config, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", &routing_target).unwrap(); + assert_eq!(selected.id, "demo/api"); + + assert_eq!(raw_target, "/api//v1/items"); + } + + #[test] + fn local_location_response_uses_response_rules_without_opening_backend() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut route = local_route(); + route.headers = HeaderRules { + request: vec![], + response: vec![HeaderRule { + op: Operation::Set, + name: "x-local-response".into(), + value: Some("yes".into()), + }], + }; + config.vhosts[0].routes = vec![route]; + + let snapshot = compile(config, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", "/status").unwrap(); + let response = selected.response.as_ref().expect("route must remain local"); + assert_eq!(response.status, 200); + assert_eq!(response.body, "ok"); + + let mut headers = HeaderMap::new(); + apply_headers(&mut headers, &selected.headers.response); + assert_eq!(headers.get("x-local-response").unwrap(), "yes"); + + assert!(selected.response.is_some()); + } + + #[test] + fn local_location_response_still_respects_route_acl() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut route = local_route(); + route.acl = Some(Acl { + default: Action::Deny, + rules: vec![AclRule { + network: "10.0.0.0/8".into(), + action: Action::Allow, + }], + }); + config.vhosts[0].routes = vec![route]; + + let snapshot = compile(config, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", "/status").unwrap(); + let acl = selected.acl.as_ref().expect("route ACL must be compiled"); + assert!(acl.allows("10.1.2.3".parse().unwrap())); + assert!(!acl.allows("192.0.2.1".parse().unwrap())); + assert!(selected.response.is_some()); + } +} diff --git a/src/config/routing.rs b/src/config/routing.rs index f773557..f015e60 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -77,7 +77,6 @@ fn compile_routing( || host_bindings.iter().all(|l| l.tls), "prefixed affinity cookies require TLS on every resolved frontend" ); - // Disabled vhosts also reserve their name, preventing fallback exposure. for name in &routing_hosts { let claimed = claimed_hosts.entry(name.clone()).or_default(); for a in claimed.iter() { @@ -91,8 +90,6 @@ fn compile_routing( } claimed.extend(host_bindings.iter().copied()); } - // Backend definitions are irrelevant to a Web Server main source unless - // an enabled Custom Location explicitly uses Reverse Proxy. let needs_proxy_pool = host.source.is_reverse_proxy() || host.routes.iter().any(|route| { route.enabled @@ -119,8 +116,6 @@ fn compile_routing( None } else { let mut pool_settings = host.load_balancing.clone(); - // Blank health-check Host means the first exact DNS name of the vhost. - // This is important when the backend URL is an IP shared by several vhosts. if pool_settings.health_check.host.is_none() && (pool_settings.health_check.mode == crate::balance::HealthMode::Http || host.backends.iter().any(|member| { @@ -369,7 +364,6 @@ fn compile_routing( if host.enabled && route.enabled { let (source, route_pool, backend) = if route.response.is_some() || route.redirect.is_some() { - // Local return/redirect is handled before source dispatch and needs no origin. (CompiledSource::ReverseProxy, None, None) } else { match &route.source { diff --git a/src/config/snapshot.rs b/src/config/snapshot.rs index f212807..d7755bb 100644 --- a/src/config/snapshot.rs +++ b/src/config/snapshot.rs @@ -1,13 +1,8 @@ impl Snapshot { - // Exact-first lookups with stack-buffered wildcard keys for normal hosts. - fn find_host_pattern( - host: &str, - mut lookup: impl FnMut(&str) -> Option, - ) -> Option { + fn find_host_pattern(host: &str, mut lookup: impl FnMut(&str) -> Option) -> Option { if let Some(value) = lookup(host) { return Some(value); } - // Skip the final dot: only one label follows it. let last_dot = host.rfind('.')?; let mut wildcard = [0u8; 256]; wildcard[..2].copy_from_slice(b"*."); @@ -25,7 +20,6 @@ impl Snapshot { return Some(value); } } else if let Some(value) = lookup(&format!("*.{suffix}")) { - // Preserve behavior for unusual overlong internal host inputs. return Some(value); } } @@ -40,7 +34,6 @@ impl Snapshot { }) .unwrap_or(true) } - /// RFC 8441 is connection-scoped; requests still enforce vhost opt-in. pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool { self.frontends.values().flatten().any(|frontend| { frontend.websockets @@ -51,7 +44,6 @@ impl Snapshot { }) }) } - /// RFC 9220 is connection-scoped; requests still enforce vhost opt-in. pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool { self.frontends.values().flatten().any(|frontend| { frontend.websockets @@ -62,8 +54,6 @@ impl Snapshot { }) }) } - /// Production routing always uses the TCP connection's local endpoint. - /// Exact hostnames win over the longest matching wildcard hostname. pub fn select_on( &self, host: &str, @@ -73,7 +63,6 @@ impl Snapshot { ) -> Option<&CompiledRoute> { self.select_on_arc(host, path, local, tls).map(Arc::as_ref) } - /// Shared compiled-route handle for middleware and forward(). pub fn select_on_arc( &self, host: &str, @@ -87,7 +76,6 @@ impl Snapshot { .iter() .find(|route| route.bindings.iter().any(|l| l.matches(local, tls))) })?; - // The claimed vhost owns its fallback ahead of broader wildcards. self.routes .get(pattern) .and_then(|routes| { @@ -112,7 +100,6 @@ impl Snapshot { }) }) } - /// Resolve protocol-error attribution with the routing precedence and bindings. pub fn resolve_logging_vhost( &self, local: SocketAddr, @@ -136,11 +123,7 @@ impl Snapshot { }); } } - for (candidate, matched_by) in [ - (authority, "authority"), - (host, "host"), - (sni, "sni"), - ] { + for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] { let Some(candidate) = candidate else { continue; }; @@ -156,7 +139,6 @@ impl Snapshot { } None } - /// Select redirects using accepted socket, TLS state and normalized path. pub fn redirect_on( &self, host: &str, @@ -189,7 +171,6 @@ impl Snapshot { }) }) } - /// Select a host-owned local 404/static response using the accepted socket. pub fn not_found_host_on( &self, host: &str, @@ -209,10 +190,12 @@ impl Snapshot { }) }) } - /// Legacy inspection helper only; network handlers must call select_on. pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> { Self::find_host_pattern(host, |pattern| { - let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?; + let fallback = self + .fallbacks + .get(pattern) + .and_then(|routes| routes.first())?; self.routes .get(pattern) .and_then(|routes| select_route(routes, path, |_| true)) @@ -269,17 +252,25 @@ where prefix } impl CompiledRoute { - pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result> { + pub fn redirect_location( + &self, + path: &str, + uri: &http::Uri, + ) -> Result> { let Some(spec) = &self.redirect else { return Ok(None); }; let target = if let Some(regex) = &spec.rewrite_regex { - ensure!(regex.is_match(path), "location redirect rewrite regex did not match"); + ensure!( + regex.is_match(path), + "location redirect rewrite regex did not match" + ); regex.replace(path, spec.target.as_str()).into_owned() } else { spec.target.clone() }; - let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?; + let mut target = + url::Url::parse(&target).context("invalid compiled location redirect target")?; if spec.preserve_query { if let Some(query) = uri.query() { let merged = match target.query() { @@ -289,10 +280,11 @@ impl CompiledRoute { target.set_query(Some(&merged)); } } - Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?)) + Ok(Some( + http::HeaderValue::from_str(target.as_str()) + .context("invalid redirect Location header")?, + )) } - /// Return a normalized replacement path for the upstream hop. `None` means - /// preserve the client's exact origin-form path bytes. pub fn rewritten_path(&self, path: &str) -> Result> { use crate::vhost::RewriteMode; let rewritten = match self.rewrite.mode { @@ -346,7 +338,6 @@ pub(crate) fn valid_id(id: &str) -> bool { .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } -/// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4. pub fn normalize_host(value: &str) -> Result { let host = value .strip_suffix('.') @@ -370,8 +361,6 @@ pub fn normalize_host(value: &str) -> Result { ); Ok(host) } -/// Configured frontend patterns may use a single leading `*.` wildcard. -/// Runtime Host values remain exact and are normalized by `normalize_host`. pub fn normalize_host_pattern(value: &str) -> Result { let value = value.trim(); if let Some(base) = value.strip_prefix("*.") { @@ -395,13 +384,10 @@ pub fn normalize_host_pattern(value: &str) -> Result { pub fn request_host(value: &str) -> Result { ensure!(!value.contains('@'), "invalid Host"); let authority: Authority = value.parse().context("invalid Host authority")?; - // Authority::port() returns None for both an absent and an invalid port. - // Inspect the explicit suffix so an out-of-range port cannot bypass validation. let raw_host = authority.host(); let suffix = &authority.as_str()[raw_host.len()..]; if !suffix.is_empty() { let port = suffix.strip_prefix(':').context("invalid Host port")?; - // HTTP permits an empty port suffix, which selects the default port. if !port.is_empty() { let number: u16 = port.parse().context("invalid Host port")?; ensure!(number > 0, "invalid Host port"); @@ -461,7 +447,6 @@ pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> { !(s[0] == 0x2001 && s[1] == 0x0db8), "documentation address is not a backend" ); - // Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges. let ula = s[0] & 0xfe00 == 0xfc00; let global = s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0); diff --git a/src/config/tests.rs b/src/config/tests.rs index 894cf24..d666d46 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1,1095 +1,1170 @@ mod tests { -use super::*; -fn policy() -> ValidationPolicy { - ValidationPolicy { - tls_addr: None, - cert_dir: None, - data_dir: None, - proxy_addr: "127.0.0.1:8080".parse().unwrap(), - admin_addr: "127.0.0.1:9090".parse().unwrap(), + use super::*; + fn policy() -> ValidationPolicy { + ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } } -} -#[test] -fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() { - let snapshot = compile(demo_config("127.0.0.1:9000".parse().unwrap()), 1, &policy()).unwrap(); - let local = "127.0.0.1:8080".parse().unwrap(); - let first = snapshot.select_on_arc("localhost", "/", local, false).unwrap(); - let second = snapshot.select_on_arc("localhost", "/child", local, false).unwrap(); - // Both selectors point into one immutable compiled snapshot. - assert!(std::sync::Arc::ptr_eq(first, second)); - let legacy = snapshot.select_on("localhost", "/", local, false).unwrap(); - assert!(std::ptr::eq(first.as_ref(), legacy)); - assert!(snapshot.select_on_arc("not-this-host", "/", local, false).is_none()); -} - -#[test] -fn missing_http2_settings_use_release_defaults() { - let config: Config = serde_json::from_value(serde_json::json!({})).unwrap(); - assert_eq!(config.http2, crate::settings::Http2Connections::default()); - config.http2.validate().unwrap(); -} - -#[test] -fn normalized_unicode_prefix_survives_serialization_and_recompile() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into(); - let first = compile(config, 0, &policy()).unwrap(); - let saved = serde_json::to_string(&first.envelope.config).unwrap(); - let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap(); - assert!(second.select("localhost", "/caf\u{e9}/hello").is_some()); -} -#[test] -fn web_server_main_source_needs_no_backend_pool() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.web_services.push(crate::web_server::Service { - id: "site".into(), - name: "Site".into(), - document_root: tmp.path().to_path_buf(), - index: vec!["index.html".into()], - directory_listing: false, - ..Default::default() - }); - config.vhosts[0].source = crate::vhost::Source::WebServer { - web_service_id: "site".into(), - }; - config.vhosts[0].backends.clear(); - config.vhosts[0].routes.clear(); - let snapshot = compile(config, 1, &policy()).unwrap(); - let route = snapshot.select("localhost", "/").unwrap(); - assert!(matches!(&route.source, CompiledSource::WebServer(_))); - assert!(route.pool.is_none()); - assert!(route.backend.is_none()); - assert!(snapshot.pools.is_empty()); -} - -#[test] -fn web_server_and_reverse_proxy_can_share_one_vhost() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.web_services.push(crate::web_server::Service { - id: "site".into(), - name: "Site".into(), - document_root: tmp.path().to_path_buf(), - index: vec!["index.html".into()], - directory_listing: false, - ..Default::default() - }); - let host = &mut config.vhosts[0]; - host.source = crate::vhost::Source::WebServer { - web_service_id: "site".into(), - }; - host.routes = vec![crate::vhost::PathRoute { - id: "api".into(), - path_prefix: "/api".into(), - source: crate::vhost::Source::ReverseProxy, - ..Default::default() - }]; - let snapshot = compile(config, 1, &policy()).unwrap(); - let root = snapshot.select("localhost", "/").unwrap(); - let api = snapshot.select("localhost", "/api/test").unwrap(); - assert!(matches!(&root.source, CompiledSource::WebServer(_))); - assert!(matches!(&api.source, CompiledSource::ReverseProxy)); - assert!(api.pool.is_some()); - assert!(api.backend.is_some()); -} - -#[test] -fn one_web_service_can_be_reused_by_multiple_vhosts() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"shared").unwrap(); - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.web_services.push(crate::web_server::Service { - id: "shared-site".into(), - name: "Shared site".into(), - document_root: tmp.path().to_path_buf(), - index: vec!["index.html".into()], - directory_listing: false, - ..Default::default() - }); - let first = &mut config.vhosts[0]; - first.source = crate::vhost::Source::WebServer { - web_service_id: "shared-site".into(), - }; - first.backends.clear(); - first.routes.clear(); - - let mut second = first.clone(); - second.id = "shared-second".into(); - second.frontend.hosts = vec!["second.localhost".into()]; - config.vhosts.push(second); - - let snapshot = compile(config, 1, &policy()).unwrap(); - let first_route = snapshot.select("localhost", "/").unwrap(); - let second_route = snapshot.select("second.localhost", "/").unwrap(); - assert!( - matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") - ); - assert!( - matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") - ); -} - -#[test] -fn unknown_web_service_reference_is_rejected() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.vhosts[0].source = crate::vhost::Source::WebServer { - web_service_id: "missing".into(), - }; - let error = compile(config, 1, &policy()).unwrap_err().to_string(); - assert!(error.contains("unknown Web Service"), "{error}"); -} - -#[test] -fn missing_source_fields_remain_reverse_proxy_for_old_configs() { - let source: crate::vhost::Source = - serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap(); - assert_eq!(source, crate::vhost::Source::ReverseProxy); - let route: crate::vhost::PathRoute = - serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap(); - assert_eq!(route.source, crate::vhost::Source::ReverseProxy); -} - -#[test] -fn host_normalization() { - assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com"); - assert_eq!(request_host("example.com:65535").unwrap(), "example.com"); - assert_eq!(request_host("[::1]").unwrap(), "::1"); - assert_eq!(request_host("[::1]:8080").unwrap(), "::1"); - assert_eq!(request_host("a:").unwrap(), "a"); - assert_eq!(request_host("[::1]:").unwrap(), "::1"); - for invalid in [ - "", - "a..b", - "*.test", - "a/b", - "user@host", - "-bad.test", - "a:99999", - "a:0", - "a:abc", - "[::1]:99999", - "[::1]:0", - "[::1]suffix", - "[::1]suffix:80", - ] { - assert!(request_host(invalid).is_err(), "{invalid}"); - } -} -#[test] -fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() { - assert_eq!( - normalize_host_pattern("*.Example.TEST.").unwrap(), - "*.example.test" - ); - for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] { - assert!(normalize_host_pattern(invalid).is_err(), "{invalid}"); + #[test] + fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() { + let snapshot = + compile(demo_config("127.0.0.1:9000".parse().unwrap()), 1, &policy()).unwrap(); + let local = "127.0.0.1:8080".parse().unwrap(); + let first = snapshot + .select_on_arc("localhost", "/", local, false) + .unwrap(); + let second = snapshot + .select_on_arc("localhost", "/child", local, false) + .unwrap(); + assert!(std::sync::Arc::ptr_eq(first, second)); + let legacy = snapshot.select_on("localhost", "/", local, false).unwrap(); + assert!(std::ptr::eq(first.as_ref(), legacy)); + assert!(snapshot + .select_on_arc("not-this-host", "/", local, false) + .is_none()); } - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].id = "wildcard".into(); - c.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; - let mut exact = c.vhosts[0].clone(); - exact.id = "exact".into(); - exact.frontend.hosts = vec!["api.example.test".into()]; - c.vhosts.push(exact); - let snapshot = compile(c, 1, &policy()).unwrap(); - let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - assert_eq!( - snapshot - .select_on("api.example.test", "/", local, false) - .unwrap() - .id, - "exact/main" - ); - assert_eq!( - snapshot - .select_on("www.example.test", "/", local, false) - .unwrap() - .id, - "wildcard/main" - ); -} -#[test] -fn logging_vhost_resolution_reuses_router_host_precedence() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.vhosts[0].id = "wildcard".into(); - config.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; - let mut exact = config.vhosts[0].clone(); - exact.id = "exact".into(); - exact.frontend.hosts = vec!["api.example.test".into()]; - config.vhosts.push(exact); - let snapshot = compile(config, 1, &policy()).unwrap(); - let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + #[test] + fn missing_http2_settings_use_release_defaults() { + let config: Config = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(config.http2, crate::settings::Http2Connections::default()); + config.http2.validate().unwrap(); + } - let routed = snapshot - .resolve_logging_vhost( - local, - false, - Some("wildcard"), - Some("api.example.test"), - None, - None, - ) - .unwrap(); - assert_eq!(routed.vhost, "wildcard"); - assert_eq!(routed.matched_by, "router"); - - let exact = snapshot - .resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None) - .unwrap(); - assert_eq!(exact.vhost, "exact"); - assert_eq!(exact.matched_by, "authority"); - - let wildcard = snapshot - .resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None) - .unwrap(); - assert_eq!(wildcard.vhost, "wildcard"); - assert_eq!(wildcard.matched_by, "host"); - - let sni = snapshot - .resolve_logging_vhost(local, false, None, None, None, Some("api.example.test")) - .unwrap(); - assert_eq!(sni.vhost, "exact"); - assert_eq!(sni.matched_by, "sni"); - - assert!(snapshot - .resolve_logging_vhost(local, false, None, None, None, Some("unknown.test")) - .is_none()); - assert!(snapshot - .resolve_logging_vhost(local, false, None, None, None, None) - .is_none()); -} - -#[test] -fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.acl_profiles = serde_json::from_value(serde_json::json!([{ - "id":"office", "name":"Office", - "acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}, - "country_access":{"mode":"block","countries":["RU"]} - }])) - .unwrap(); - c.vhosts[0].acl_profile_id = Some("office".into()); - let snapshot = compile(c.clone(), 1, &policy()).unwrap(); - let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let frontend = snapshot.frontend("localhost", local, false).unwrap(); - assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); - assert!(frontend - .profile_acl - .as_ref() - .unwrap() - .allows("10.2.3.4".parse().unwrap())); - assert!(!frontend - .profile_acl - .as_ref() - .unwrap() - .allows("192.0.2.1".parse().unwrap())); - assert!(!frontend.profile_country_access.allows(Some("RU"))); - - c.vhosts[0].acl_profile_id = Some("missing".into()); - assert!(compile(c, 2, &policy()).is_err()); -} - -#[test] -fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].websockets = true; - c.vhosts[0].websocket_rfc8441 = true; - let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); - assert!(error.contains("HTTP/2-capable listener"), "{error}"); - - c.listeners.endpoints[0].binding.h2c = true; - let snapshot = compile(c.clone(), 2, &policy()).unwrap(); - let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - assert!(snapshot.websocket_rfc8441_on(local, false)); - assert!(!snapshot.websocket_rfc8441_on(local, true)); - - c.vhosts[0].websocket_rfc8441 = false; - let snapshot = compile(c, 3, &policy()).unwrap(); - assert!(!snapshot.websocket_rfc8441_on(local, false)); -} -#[test] -fn force_ssl_requires_plain_http_and_chooses_https_target() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].force_ssl = true; - let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); - assert!( - error.contains("at least one selected TLS listener"), - "{error}" - ); - - c.listeners.endpoints.push(crate::settings::NamedListener { - id: "https-a".into(), - name: "HTTPS A".into(), - scope: crate::settings::ListenerScope::Internal, - enabled: true, - binding: crate::vhost::Listener { - address: "127.0.0.1:8443".parse().unwrap(), - tls: true, - http2: true, - http2_settings: Some(crate::settings::Http2Connections::default()), - h2c: false, - http3: None, - }, - additional_addresses: vec![], - }); - c.listeners.endpoints.push(crate::settings::NamedListener { - id: "https-b".into(), - name: "HTTPS B".into(), - scope: crate::settings::ListenerScope::Internal, - enabled: true, - binding: crate::vhost::Listener { - address: "127.0.0.1:9443".parse().unwrap(), - tls: true, - http2: true, - http2_settings: Some(crate::settings::Http2Connections::default()), - h2c: false, - http3: None, - }, - additional_addresses: vec![], - }); - c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()]; - let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string(); - assert!(error.contains("cannot choose an HTTPS target"), "{error}"); - - c.listeners - .endpoints - .iter_mut() - .find(|item| item.id == "https-b") - .unwrap() - .binding - .address = "127.0.0.1:443".parse().unwrap(); - let snapshot = compile(c.clone(), 3, &policy()).unwrap(); - let frontend = snapshot - .frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false) - .unwrap(); - assert_eq!(frontend.https_redirect_port, Some(443)); - - c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()]; - let error = compile(c, 4, &policy()).unwrap_err().to_string(); - assert!(error.contains("plain HTTP listener"), "{error}"); -} -#[test] -fn preserve_host_is_valid_with_every_upstream_protocol() { - for protocol in [ - UpstreamProtocol::Http1, - UpstreamProtocol::Auto, - UpstreamProtocol::Http2, - UpstreamProtocol::H2, - ] { + #[test] + fn normalized_unicode_prefix_survives_serialization_and_recompile() { let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - let member = &mut config.vhosts[0].backends[0]; - member.url = "https://10.87.20.2:443".into(); - member.allow_private = true; - member.preserve_host = true; - member.protocol = protocol; - let snapshot = compile(config, 1, &policy()).unwrap(); - assert!(snapshot.pools[0].members[0].backend.preserve_host); + config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into(); + let first = compile(config, 0, &policy()).unwrap(); + let saved = serde_json::to_string(&first.envelope.config).unwrap(); + let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap(); + assert!(second.select("localhost", "/caf\u{e9}/hello").is_some()); + } + #[test] + fn web_server_main_source_needs_no_backend_pool() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "site".into(), + name: "Site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + config.vhosts[0].source = crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }; + config.vhosts[0].backends.clear(); + config.vhosts[0].routes.clear(); + let snapshot = compile(config, 1, &policy()).unwrap(); + let route = snapshot.select("localhost", "/").unwrap(); + assert!(matches!(&route.source, CompiledSource::WebServer(_))); + assert!(route.pool.is_none()); + assert!(route.backend.is_none()); + assert!(snapshot.pools.is_empty()); } -} -#[test] -fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); - c.vhosts[0].backends[0].allow_private = true; - c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into()); - let s = compile(c, 1, &policy()).unwrap(); - let backend = &s.pools[0].members[0].backend; - assert_eq!(backend.origin, "https://10.87.20.2:443"); - assert_eq!( - backend.request_origin, - "https://backend.unitra.internal:443" - ); -} -#[test] -fn tls_server_name_is_https_dns_only() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into()); - assert!(compile(c.clone(), 1, &policy()).is_err()); - c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); - c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into()); - assert!(compile(c, 1, &policy()).is_err()); -} -#[test] -fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - assert!(!c.vhosts[0].backends[0].tls_verify); - assert!(compile(c.clone(), 1, &policy()).is_ok()); + #[test] + fn web_server_and_reverse_proxy_can_share_one_vhost() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "site".into(), + name: "Site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + let host = &mut config.vhosts[0]; + host.source = crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }; + host.routes = vec![crate::vhost::PathRoute { + id: "api".into(), + path_prefix: "/api".into(), + source: crate::vhost::Source::ReverseProxy, + ..Default::default() + }]; + let snapshot = compile(config, 1, &policy()).unwrap(); + let root = snapshot.select("localhost", "/").unwrap(); + let api = snapshot.select("localhost", "/api/test").unwrap(); + assert!(matches!(&root.source, CompiledSource::WebServer(_))); + assert!(matches!(&api.source, CompiledSource::ReverseProxy)); + assert!(api.pool.is_some()); + assert!(api.backend.is_some()); + } - c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); - c.vhosts[0].backends[0].allow_private = true; - c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into()); - assert!(compile(c.clone(), 1, &policy()).is_ok()); + #[test] + fn one_web_service_can_be_reused_by_multiple_vhosts() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"shared").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "shared-site".into(), + name: "Shared site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + let first = &mut config.vhosts[0]; + first.source = crate::vhost::Source::WebServer { + web_service_id: "shared-site".into(), + }; + first.backends.clear(); + first.routes.clear(); - c.vhosts[0].backends[0].tls_verify = true; - assert!(compile(c, 1, &policy()).is_err()); -} -#[test] -fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.redirects = serde_json::from_value(serde_json::json!([{ + let mut second = first.clone(); + second.id = "shared-second".into(); + second.frontend.hosts = vec!["second.localhost".into()]; + config.vhosts.push(second); + + let snapshot = compile(config, 1, &policy()).unwrap(); + let first_route = snapshot.select("localhost", "/").unwrap(); + let second_route = snapshot.select("second.localhost", "/").unwrap(); + assert!( + matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") + ); + assert!( + matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") + ); + } + + #[test] + fn unknown_web_service_reference_is_rejected() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].source = crate::vhost::Source::WebServer { + web_service_id: "missing".into(), + }; + let error = compile(config, 1, &policy()).unwrap_err().to_string(); + assert!(error.contains("unknown Web Service"), "{error}"); + } + + #[test] + fn missing_source_fields_remain_reverse_proxy_for_old_configs() { + let source: crate::vhost::Source = + serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap(); + assert_eq!(source, crate::vhost::Source::ReverseProxy); + let route: crate::vhost::PathRoute = + serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap(); + assert_eq!(route.source, crate::vhost::Source::ReverseProxy); + } + + #[test] + fn host_normalization() { + assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com"); + assert_eq!(request_host("example.com:65535").unwrap(), "example.com"); + assert_eq!(request_host("[::1]").unwrap(), "::1"); + assert_eq!(request_host("[::1]:8080").unwrap(), "::1"); + assert_eq!(request_host("a:").unwrap(), "a"); + assert_eq!(request_host("[::1]:").unwrap(), "::1"); + for invalid in [ + "", + "a..b", + "*.test", + "a/b", + "user@host", + "-bad.test", + "a:99999", + "a:0", + "a:abc", + "[::1]:99999", + "[::1]:0", + "[::1]suffix", + "[::1]suffix:80", + ] { + assert!(request_host(invalid).is_err(), "{invalid}"); + } + } + #[test] + fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() { + assert_eq!( + normalize_host_pattern("*.Example.TEST.").unwrap(), + "*.example.test" + ); + for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] { + assert!(normalize_host_pattern(invalid).is_err(), "{invalid}"); + } + + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].id = "wildcard".into(); + c.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; + let mut exact = c.vhosts[0].clone(); + exact.id = "exact".into(); + exact.frontend.hosts = vec!["api.example.test".into()]; + c.vhosts.push(exact); + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert_eq!( + snapshot + .select_on("api.example.test", "/", local, false) + .unwrap() + .id, + "exact/main" + ); + assert_eq!( + snapshot + .select_on("www.example.test", "/", local, false) + .unwrap() + .id, + "wildcard/main" + ); + } + #[test] + fn logging_vhost_resolution_reuses_router_host_precedence() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].id = "wildcard".into(); + config.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; + let mut exact = config.vhosts[0].clone(); + exact.id = "exact".into(); + exact.frontend.hosts = vec!["api.example.test".into()]; + config.vhosts.push(exact); + let snapshot = compile(config, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + + let routed = snapshot + .resolve_logging_vhost( + local, + false, + Some("wildcard"), + Some("api.example.test"), + None, + None, + ) + .unwrap(); + assert_eq!(routed.vhost, "wildcard"); + assert_eq!(routed.matched_by, "router"); + + let exact = snapshot + .resolve_logging_vhost( + local, + false, + None, + Some("API.EXAMPLE.TEST:8080"), + None, + None, + ) + .unwrap(); + assert_eq!(exact.vhost, "exact"); + assert_eq!(exact.matched_by, "authority"); + + let wildcard = snapshot + .resolve_logging_vhost( + local, + false, + None, + None, + Some("www.example.test:8080"), + None, + ) + .unwrap(); + assert_eq!(wildcard.vhost, "wildcard"); + assert_eq!(wildcard.matched_by, "host"); + + let sni = snapshot + .resolve_logging_vhost(local, false, None, None, None, Some("api.example.test")) + .unwrap(); + assert_eq!(sni.vhost, "exact"); + assert_eq!(sni.matched_by, "sni"); + + assert!(snapshot + .resolve_logging_vhost(local, false, None, None, None, Some("unknown.test")) + .is_none()); + assert!(snapshot + .resolve_logging_vhost(local, false, None, None, None, None) + .is_none()); + } + + #[test] + fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.acl_profiles = serde_json::from_value(serde_json::json!([{ + "id":"office", "name":"Office", + "acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}, + "country_access":{"mode":"block","countries":["RU"]} + }])) + .unwrap(); + c.vhosts[0].acl_profile_id = Some("office".into()); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let frontend = snapshot.frontend("localhost", local, false).unwrap(); + assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); + assert!(frontend + .profile_acl + .as_ref() + .unwrap() + .allows("10.2.3.4".parse().unwrap())); + assert!(!frontend + .profile_acl + .as_ref() + .unwrap() + .allows("192.0.2.1".parse().unwrap())); + assert!(!frontend.profile_country_access.allows(Some("RU"))); + + c.vhosts[0].acl_profile_id = Some("missing".into()); + assert!(compile(c, 2, &policy()).is_err()); + } + + #[test] + fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].websockets = true; + c.vhosts[0].websocket_rfc8441 = true; + let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); + assert!(error.contains("HTTP/2-capable listener"), "{error}"); + + c.listeners.endpoints[0].binding.h2c = true; + let snapshot = compile(c.clone(), 2, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert!(snapshot.websocket_rfc8441_on(local, false)); + assert!(!snapshot.websocket_rfc8441_on(local, true)); + + c.vhosts[0].websocket_rfc8441 = false; + let snapshot = compile(c, 3, &policy()).unwrap(); + assert!(!snapshot.websocket_rfc8441_on(local, false)); + } + #[test] + fn force_ssl_requires_plain_http_and_chooses_https_target() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].force_ssl = true; + let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); + assert!( + error.contains("at least one selected TLS listener"), + "{error}" + ); + + c.listeners.endpoints.push(crate::settings::NamedListener { + id: "https-a".into(), + name: "HTTPS A".into(), + scope: crate::settings::ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address: "127.0.0.1:8443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: None, + }, + additional_addresses: vec![], + }); + c.listeners.endpoints.push(crate::settings::NamedListener { + id: "https-b".into(), + name: "HTTPS B".into(), + scope: crate::settings::ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address: "127.0.0.1:9443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: None, + }, + additional_addresses: vec![], + }); + c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()]; + let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string(); + assert!(error.contains("cannot choose an HTTPS target"), "{error}"); + + c.listeners + .endpoints + .iter_mut() + .find(|item| item.id == "https-b") + .unwrap() + .binding + .address = "127.0.0.1:443".parse().unwrap(); + let snapshot = compile(c.clone(), 3, &policy()).unwrap(); + let frontend = snapshot + .frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!(frontend.https_redirect_port, Some(443)); + + c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()]; + let error = compile(c, 4, &policy()).unwrap_err().to_string(); + assert!(error.contains("plain HTTP listener"), "{error}"); + } + #[test] + fn preserve_host_is_valid_with_every_upstream_protocol() { + for protocol in [ + UpstreamProtocol::Http1, + UpstreamProtocol::Auto, + UpstreamProtocol::Http2, + UpstreamProtocol::H2, + ] { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + let member = &mut config.vhosts[0].backends[0]; + member.url = "https://10.87.20.2:443".into(); + member.allow_private = true; + member.preserve_host = true; + member.protocol = protocol; + let snapshot = compile(config, 1, &policy()).unwrap(); + assert!(snapshot.pools[0].members[0].backend.preserve_host); + } + } + + #[test] + fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into()); + let s = compile(c, 1, &policy()).unwrap(); + let backend = &s.pools[0].members[0].backend; + assert_eq!(backend.origin, "https://10.87.20.2:443"); + assert_eq!( + backend.request_origin, + "https://backend.unitra.internal:443" + ); + } + #[test] + fn tls_server_name_is_https_dns_only() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into()); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into()); + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(!c.vhosts[0].backends[0].tls_verify); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into()); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + + c.vhosts[0].backends[0].tls_verify = true; + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.redirects = serde_json::from_value(serde_json::json!([{ "id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]}, "rules":[{"id":"root","target":"https://new.localhost/","status_code":308}] }])).unwrap(); - let snapshot = compile(c, 1, &policy()).unwrap(); - let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let (_host, rule) = snapshot - .redirect_on("old.localhost", "/a", local, false) - .unwrap(); - assert_eq!(rule.id, "root"); - assert!(snapshot - .select_on("old.localhost", "/a", local, false) - .is_none()); -} - -#[test] -fn custom_locations_override_but_main_vhost_remains_fallback() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - // The demo document still contains the pre-0.16 synthetic root route. - // Validation must normalize it away and keep the main backend internally. - let mut custom = c.vhosts[0].routes[0].clone(); - custom.id = "test".into(); - custom.path_prefix = "/test".into(); - custom.response = Some(crate::vhost::LocationResponse { - status: 200, - content_type: "text/plain".into(), - body: "ok".into(), - }); - c.vhosts[0].routes.push(custom); - let snapshot = compile(c, 1, &policy()).unwrap(); - - assert_eq!( - snapshot.select("localhost", "/test").unwrap().id, - "demo/test" - ); - assert_eq!( - snapshot.select("localhost", "/test/a").unwrap().id, - "demo/test" - ); - assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main"); - assert_eq!( - snapshot.select("localhost", "/abc").unwrap().id, - "demo/main" - ); - assert_eq!( - snapshot.select("localhost", "/static/a.js").unwrap().id, - "demo/main" - ); - assert!(snapshot.envelope.config.vhosts[0] - .routes - .iter() - .all(|route| !route.is_legacy_main_fallback())); -} - -#[test] -fn deliberate_root_custom_location_can_override_main_fallback() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - let root = &mut c.vhosts[0].routes[0]; - root.response = Some(crate::vhost::LocationResponse { - status: 204, - content_type: "text/plain".into(), - body: String::new(), - }); - let snapshot = compile(c, 1, &policy()).unwrap(); - assert_eq!( - snapshot.select("localhost", "/anything").unwrap().id, - "demo/root" - ); -} - -#[test] -fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.administration.listen = Some("0.0.0.0:9090".parse().unwrap()); - c.administration.tls = true; - c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into(); - c.vhosts[0].backends[0].allow_private = true; - c.vhosts[0].backends[0].accept_local_listener_risk = true; - let snapshot = compile(c, 1, &policy()).unwrap(); - assert!(snapshot.pools[0].members[0].backend.targets_admin_listener); -} - -#[test] -fn backend_diagnostic_flags_compile_to_frontend() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].backend_header = true; - c.vhosts[0].backend_timing_header = true; - let snapshot = compile(c, 1, &policy()).unwrap(); - let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let frontend = snapshot.frontend("localhost", local, false).unwrap(); - assert!(frontend.backend_header); - assert!(frontend.backend_timing_header); -} - - -#[test] -fn path_boundaries() { - assert!(prefix_matches("/api", "/api")); - assert!(prefix_matches("/api", "/api/users")); - assert!(!prefix_matches("/api", "/apix")); - assert!(prefix_matches("/", "/anything")); -} -#[test] -fn invalid_per_backend_health_policy_rejected_without_global_override() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { - mode: crate::balance::HealthMode::Http, - interval_ms: 300, - timeout_ms: 400, - slow_response_ms: 0, - ..Default::default() - }); - assert!(compile(c.clone(), 1, &policy()).is_err()); - c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200; - assert!(compile(c, 1, &policy()).is_ok()); -} -#[test] -fn private_requires_opt_in_and_metadata_always_denied() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - assert!(compile(c.clone(), 1, &policy()).is_ok()); - c.vhosts[0].backends[0].allow_private = false; - assert!(compile(c.clone(), 1, &policy()).is_err()); - c.vhosts[0].backends[0].allow_private = true; - for target in [ - "http://169.254.169.254", - "http://100.100.100.200", - "http://0.0.0.0", - "http://127.0.0.1:9090", - "http://[::ffff:169.254.169.254]", - "http://[fe80::1]", - "http://user:pass@127.0.0.1:9000", - ] { - c.vhosts[0].backends[0].url = target.into(); - assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}"); + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let (_host, rule) = snapshot + .redirect_on("old.localhost", "/a", local, false) + .unwrap(); + assert_eq!(rule.id, "root"); + assert!(snapshot + .select_on("old.localhost", "/a", local, false) + .is_none()); } - c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into(); - c.vhosts[0].backends[0].accept_local_listener_risk = true; - assert!(compile(c.clone(), 1, &policy()).is_ok()); - c.vhosts[0].backends[0].allow_private = false; - assert!(compile(c, 1, &policy()).is_err()); -} -#[test] -fn longest_prefix_and_aliases() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - let mut specific = c.vhosts[0].routes[0].clone(); - specific.id = "api".into(); - specific.path_prefix = "/api".into(); - c.vhosts[0].routes.push(specific); - let s = compile(c, 7, &policy()).unwrap(); - assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api"); - assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main"); - assert!(s.select("unknown", "/").is_none()); - assert_eq!(s.envelope.revision, 7); -} -#[test] -fn nginx_style_location_precedence_exact_priority_regex_prefix() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].routes[0].id = "root".into(); - let mut prefix = c.vhosts[0].routes[0].clone(); - prefix.id = "api-prefix".into(); - prefix.path_prefix = "/api".into(); + #[test] + fn custom_locations_override_but_main_vhost_remains_fallback() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut custom = c.vhosts[0].routes[0].clone(); + custom.id = "test".into(); + custom.path_prefix = "/test".into(); + custom.response = Some(crate::vhost::LocationResponse { + status: 200, + content_type: "text/plain".into(), + body: "ok".into(), + }); + c.vhosts[0].routes.push(custom); + let snapshot = compile(c, 1, &policy()).unwrap(); - let mut regex = c.vhosts[0].routes[0].clone(); - regex.id = "api-regex".into(); - regex.match_type = crate::vhost::PathMatch::Regex; - regex.path_prefix = r"^/api/v[0-9]+/".into(); - - let mut exact = c.vhosts[0].routes[0].clone(); - exact.id = "api-exact".into(); - exact.match_type = crate::vhost::PathMatch::Exact; - exact.path_prefix = "/api/v1/fixed".into(); - - let mut trailing_exact = c.vhosts[0].routes[0].clone(); - trailing_exact.id = "trailing-exact".into(); - trailing_exact.match_type = crate::vhost::PathMatch::Exact; - trailing_exact.path_prefix = "/trailing/".into(); - - c.vhosts[0] - .routes - .extend([prefix.clone(), regex, exact, trailing_exact]); - let snapshot = compile(c.clone(), 1, &policy()).unwrap(); - assert_eq!( - snapshot.select("localhost", "/api/v1/fixed").unwrap().id, - "demo/api-exact" - ); - assert_eq!( - snapshot.select("localhost", "/api/v2/users").unwrap().id, - "demo/api-regex" - ); - assert_eq!( - snapshot.select("localhost", "/api/plain").unwrap().id, - "demo/api-prefix" - ); - assert_eq!( - snapshot.select("localhost", "/trailing/").unwrap().id, - "demo/trailing-exact" - ); - assert_eq!( - snapshot.select("localhost", "/trailing").unwrap().id, - "demo/main" - ); - - c.vhosts[0].routes.retain(|route| route.id != "api-prefix"); - prefix.match_type = crate::vhost::PathMatch::PrefixPriority; - prefix.id = "api-priority".into(); - c.vhosts[0].routes.push(prefix); - let snapshot = compile(c, 2, &policy()).unwrap(); - assert_eq!( - snapshot.select("localhost", "/api/v2/users").unwrap().id, - "demo/api-priority" - ); -} -#[test] -fn location_rewrites_are_normalized_and_regex_captures_expand() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - let route = &mut c.vhosts[0].routes[0]; - route.match_type = crate::vhost::PathMatch::Regex; - route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into(); - route.rewrite = crate::vhost::PathRewrite { - mode: crate::vhost::RewriteMode::Regex, - value: "/service/${1}${2}".into(), - }; - let snapshot = compile(c, 1, &policy()).unwrap(); - let selected = snapshot.select("localhost", "/api/v42/items").unwrap(); - assert_eq!( - selected - .rewritten_path("/api/v42/items") - .unwrap() - .as_deref(), - Some("/service/42/items") - ); - assert_eq!( - selected - .rewritten_path("/api/v42/Ubuntu 14.04 LTS") - .unwrap() - .as_deref(), - Some("/service/42/Ubuntu 14.04 LTS") - ); -} -#[test] -fn custom_location_response_is_validated() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse { - status: 200, - content_type: "application/json".into(), - body: r#"{"ok":true}"#.into(), - }); - assert!(compile(c.clone(), 1, &policy()).is_ok()); - c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199; - assert!(compile(c.clone(), 1, &policy()).is_err()); - c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200; - c.vhosts[0].routes[0] - .response - .as_mut() - .unwrap() - .content_type = "text/plain\r\nx-bad: 1".into(); - assert!(compile(c, 1, &policy()).is_err()); -} -#[test] -fn local_response_hosts_reserve_frontends_and_do_not_require_backends() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.not_found_hosts = serde_json::from_value(serde_json::json!([{ - "id":"missing", - "name":"Missing site", - "frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]}, - "page":{"kind":"html","status":404,"html":"

Not found

"} - }])) - .unwrap(); - let snapshot = compile(c.clone(), 1, &policy()).unwrap(); - let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); - let host = snapshot - .not_found_host_on("missing.localhost", local, false) - .unwrap(); - assert_eq!(host.id, "missing"); - assert_eq!(host.page.status, Some(404)); - - c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()]; - assert!(compile(c, 2, &policy()).is_err()); -} - -#[test] -fn conflicts_are_rejected() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - // Keep both entries as real Custom Locations; the untouched legacy root - // is intentionally normalized away before duplicate-matcher validation. - c.vhosts[0].routes[0].path_prefix = "/api".into(); - let mut duplicate = c.vhosts[0].routes[0].clone(); - duplicate.id = "other".into(); - c.vhosts[0].routes.push(duplicate); - assert!(compile(c, 0, &policy()).is_err()); -} -#[test] -fn tls12_only_vhost_override_is_valid_without_http3_listener() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy { - tls12: true, - tls13: false, - cipher_suites: vec![], - }); - assert!(compile(c, 1, &policy()).is_ok()); -} - -#[test] -fn persisted_remote_administration_requires_tls() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.administration.listen = Some("192.0.2.10:9090".parse().unwrap()); - assert!(compile(c.clone(), 1, &policy()).is_err()); - c.administration.tls = true; - assert!(compile(c, 1, &policy()).is_ok()); -} -#[test] -fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() { - let mut p = policy(); - p.admin_addr = "0.0.0.0:9090".parse().unwrap(); - let c = demo_config("127.0.0.1:9000".parse().unwrap()); - assert!(c.administration.listen.is_none()); - assert!(compile(c, 1, &p).is_ok()); -} - -#[test] -fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].frontend.hosts = vec!["example.test".into()]; - c.vhosts[0].www_redirect = true; - let snapshot = compile(c, 1, &policy()).unwrap(); - assert!(snapshot.select("www.example.test", "/path").is_some()); - let frontend = snapshot - .frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false) - .unwrap(); - assert_eq!( - frontend.www_redirect.as_ref(), - Some(&("www.example.test".into(), "example.test".into())) - ); -} - -#[test] -fn multiple_domains_share_vhost_and_selected_canonical_redirect() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].frontend.hosts = vec![ - "suba.local".into(), "subb.local".into(), "site.test".into(), - ]; - c.vhosts[0].www_redirect = true; - c.vhosts[0].www_redirect_target = Some("site.test".into()); - let snapshot = compile(c, 1, &policy()).unwrap(); - for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] { - assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}"); + assert_eq!( + snapshot.select("localhost", "/test").unwrap().id, + "demo/test" + ); + assert_eq!( + snapshot.select("localhost", "/test/a").unwrap().id, + "demo/test" + ); + assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main"); + assert_eq!( + snapshot.select("localhost", "/abc").unwrap().id, + "demo/main" + ); + assert_eq!( + snapshot.select("localhost", "/static/a.js").unwrap().id, + "demo/main" + ); + assert!(snapshot.envelope.config.vhosts[0] + .routes + .iter() + .all(|route| !route.is_legacy_main_fallback())); } - for hostname in ["suba.local", "subb.local", "www.site.test"] { - let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap(); - assert_eq!(frontend.www_redirect.as_ref(), - Some(&("www.site.test".into(), "site.test".into()))); + + #[test] + fn deliberate_root_custom_location_can_override_main_fallback() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let root = &mut c.vhosts[0].routes[0]; + root.response = Some(crate::vhost::LocationResponse { + status: 204, + content_type: "text/plain".into(), + body: String::new(), + }); + let snapshot = compile(c, 1, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/anything").unwrap().id, + "demo/root" + ); } -} -#[test] -fn implicit_www_target_is_claimed_and_other_domain_stays_independent() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()]; - c.vhosts[0].www_redirect = true; - c.vhosts[0].www_redirect_target = Some("www.subb.local".into()); - let snapshot = compile(c, 1, &policy()).unwrap(); - for host in ["suba.local", "subb.local", "www.subb.local"] { - assert!(snapshot.select(host, "/path").is_some(), "{host}"); + #[test] + fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.administration.listen = Some("0.0.0.0:9090".parse().unwrap()); + c.administration.tls = true; + c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].accept_local_listener_risk = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + assert!(snapshot.pools[0].members[0].backend.targets_admin_listener); } - assert!(snapshot.select("www.suba.local", "/path").is_none()); - let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap(); - assert_eq!(frontend.www_redirect.as_ref(), - Some(&(String::from("subb.local"), String::from("www.subb.local")))); -} -#[test] -fn selected_www_redirect_target_must_belong_to_vhost() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].frontend.hosts = vec!["suba.local".into()]; - c.vhosts[0].www_redirect = true; - c.vhosts[0].www_redirect_target = Some("elsewhere.local".into()); - assert!(compile(c, 1, &policy()).is_err()); -} + #[test] + fn backend_diagnostic_flags_compile_to_frontend() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backend_header = true; + c.vhosts[0].backend_timing_header = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let frontend = snapshot.frontend("localhost", local, false).unwrap(); + assert!(frontend.backend_header); + assert!(frontend.backend_timing_header); + } -#[test] -fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; - c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http; - c.vhosts[0].load_balancing.health_check.host = None; - let snapshot = compile(c, 1, &policy()).unwrap(); - assert_eq!( - snapshot.pools[0].settings.health_check.host.as_deref(), - Some("app.example.test") - ); -} - -#[test] -fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; - // demo_config explicitly disables probes; this case exercises TCP fallback. - c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Tcp; - assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp); - c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { - mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default() - }); - let snapshot = compile(c, 1, &policy()).unwrap(); - let pool = &snapshot.pools[0]; - assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test")); -} -#[test] -fn explicit_http_health_check_host_override_wins_over_vhost_hostname() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; - c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http; - c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into()); - let snapshot = compile(c, 1, &policy()).unwrap(); - assert_eq!( - snapshot.pools[0].settings.health_check.host.as_deref(), - Some("health.example.test") - ); -} - -#[test] -fn custom_location_redirect_supports_separate_capture_regex_and_query() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.vhosts[0].routes = vec![crate::vhost::PathRoute { - id: "archives".into(), - path_prefix: r"\.(?:tar\.gz|zip?)$".into(), - match_type: crate::vhost::PathMatch::RegexInsensitive, - response: None, - redirect: Some(crate::vhost::LocationRedirect { - status: 301, - target: "https://storage.example.test/o/$1".into(), - rewrite_pattern: Some(r"^/(.*)$".into()), - preserve_query: true, - }), - ..Default::default() - }]; - let snapshot = compile(c, 1, &policy()).unwrap(); - let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap(); - let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap(); - let location = route - .redirect_location("/repo/file.tar.gz", &uri) - .unwrap() - .unwrap(); - assert_eq!( - location, - "https://storage.example.test/o/repo/file.tar.gz?download=1" - ); -} - -#[test] -fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.php"), b") - } else if pattern == "*.example.test" { - Some(Some("wrong-fallback")) - } else { - None + #[test] + fn private_requires_opt_in_and_metadata_always_denied() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].backends[0].allow_private = false; + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].allow_private = true; + for target in [ + "http://169.254.169.254", + "http://100.100.100.200", + "http://0.0.0.0", + "http://127.0.0.1:9090", + "http://[::ffff:169.254.169.254]", + "http://[fe80::1]", + "http://user:pass@127.0.0.1:9000", + ] { + c.vhosts[0].backends[0].url = target.into(); + assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}"); } - }) - .flatten(); - assert_eq!(match_found, None); - assert_eq!(visited, vec!["api.example.test"]); -} + c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into(); + c.vhosts[0].backends[0].accept_local_listener_risk = true; + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].backends[0].allow_private = false; + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn longest_prefix_and_aliases() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut specific = c.vhosts[0].routes[0].clone(); + specific.id = "api".into(); + specific.path_prefix = "/api".into(); + c.vhosts[0].routes.push(specific); + let s = compile(c, 7, &policy()).unwrap(); + assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api"); + assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main"); + assert!(s.select("unknown", "/").is_none()); + assert_eq!(s.envelope.revision, 7); + } + #[test] + fn nginx_style_location_precedence_exact_priority_regex_prefix() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes[0].id = "root".into(); -#[test] -fn stack_host_lookup_preserves_long_internal_host_fallback() { - // Normalized runtime hosts are <=253 bytes. Direct internal callers with - // longer strings still follow the previous pattern order without panics. - let long = format!("a.{}.sub.example.test", "x".repeat(260)); - let mut patterns = Vec::new(); - let found = Snapshot::find_host_pattern(&long, |pattern| { - patterns.push(pattern.to_owned()); - (pattern == "*.sub.example.test").then_some(true) - }); - assert_eq!(found, Some(true)); - assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260))); - assert_eq!(patterns[2], "*.sub.example.test"); -} -#[test] -fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.acl_profiles = serde_json::from_value(serde_json::json!([ + let mut prefix = c.vhosts[0].routes[0].clone(); + prefix.id = "api-prefix".into(); + prefix.path_prefix = "/api".into(); + + let mut regex = c.vhosts[0].routes[0].clone(); + regex.id = "api-regex".into(); + regex.match_type = crate::vhost::PathMatch::Regex; + regex.path_prefix = r"^/api/v[0-9]+/".into(); + + let mut exact = c.vhosts[0].routes[0].clone(); + exact.id = "api-exact".into(); + exact.match_type = crate::vhost::PathMatch::Exact; + exact.path_prefix = "/api/v1/fixed".into(); + + let mut trailing_exact = c.vhosts[0].routes[0].clone(); + trailing_exact.id = "trailing-exact".into(); + trailing_exact.match_type = crate::vhost::PathMatch::Exact; + trailing_exact.path_prefix = "/trailing/".into(); + + c.vhosts[0] + .routes + .extend([prefix.clone(), regex, exact, trailing_exact]); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/api/v1/fixed").unwrap().id, + "demo/api-exact" + ); + assert_eq!( + snapshot.select("localhost", "/api/v2/users").unwrap().id, + "demo/api-regex" + ); + assert_eq!( + snapshot.select("localhost", "/api/plain").unwrap().id, + "demo/api-prefix" + ); + assert_eq!( + snapshot.select("localhost", "/trailing/").unwrap().id, + "demo/trailing-exact" + ); + assert_eq!( + snapshot.select("localhost", "/trailing").unwrap().id, + "demo/main" + ); + + c.vhosts[0].routes.retain(|route| route.id != "api-prefix"); + prefix.match_type = crate::vhost::PathMatch::PrefixPriority; + prefix.id = "api-priority".into(); + c.vhosts[0].routes.push(prefix); + let snapshot = compile(c, 2, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/api/v2/users").unwrap().id, + "demo/api-priority" + ); + } + #[test] + fn location_rewrites_are_normalized_and_regex_captures_expand() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let route = &mut c.vhosts[0].routes[0]; + route.match_type = crate::vhost::PathMatch::Regex; + route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into(); + route.rewrite = crate::vhost::PathRewrite { + mode: crate::vhost::RewriteMode::Regex, + value: "/service/${1}${2}".into(), + }; + let snapshot = compile(c, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", "/api/v42/items").unwrap(); + assert_eq!( + selected + .rewritten_path("/api/v42/items") + .unwrap() + .as_deref(), + Some("/service/42/items") + ); + assert_eq!( + selected + .rewritten_path("/api/v42/Ubuntu 14.04 LTS") + .unwrap() + .as_deref(), + Some("/service/42/Ubuntu 14.04 LTS") + ); + } + #[test] + fn custom_location_response_is_validated() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse { + status: 200, + content_type: "application/json".into(), + body: r#"{"ok":true}"#.into(), + }); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199; + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200; + c.vhosts[0].routes[0] + .response + .as_mut() + .unwrap() + .content_type = "text/plain\r\nx-bad: 1".into(); + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn local_response_hosts_reserve_frontends_and_do_not_require_backends() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.not_found_hosts = serde_json::from_value(serde_json::json!([{ + "id":"missing", + "name":"Missing site", + "frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]}, + "page":{"kind":"html","status":404,"html":"

Not found

"} + }])) + .unwrap(); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let host = snapshot + .not_found_host_on("missing.localhost", local, false) + .unwrap(); + assert_eq!(host.id, "missing"); + assert_eq!(host.page.status, Some(404)); + + c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()]; + assert!(compile(c, 2, &policy()).is_err()); + } + + #[test] + fn conflicts_are_rejected() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes[0].path_prefix = "/api".into(); + let mut duplicate = c.vhosts[0].routes[0].clone(); + duplicate.id = "other".into(); + c.vhosts[0].routes.push(duplicate); + assert!(compile(c, 0, &policy()).is_err()); + } + #[test] + fn tls12_only_vhost_override_is_valid_without_http3_listener() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy { + tls12: true, + tls13: false, + cipher_suites: vec![], + }); + assert!(compile(c, 1, &policy()).is_ok()); + } + + #[test] + fn persisted_remote_administration_requires_tls() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.administration.listen = Some("192.0.2.10:9090".parse().unwrap()); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.administration.tls = true; + assert!(compile(c, 1, &policy()).is_ok()); + } + #[test] + fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() { + let mut p = policy(); + p.admin_addr = "0.0.0.0:9090".parse().unwrap(); + let c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(c.administration.listen.is_none()); + assert!(compile(c, 1, &p).is_ok()); + } + + #[test] + fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["example.test".into()]; + c.vhosts[0].www_redirect = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + assert!(snapshot.select("www.example.test", "/path").is_some()); + let frontend = snapshot + .frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!( + frontend.www_redirect.as_ref(), + Some(&("www.example.test".into(), "example.test".into())) + ); + } + + #[test] + fn multiple_domains_share_vhost_and_selected_canonical_redirect() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = + vec!["suba.local".into(), "subb.local".into(), "site.test".into()]; + c.vhosts[0].www_redirect = true; + c.vhosts[0].www_redirect_target = Some("site.test".into()); + let snapshot = compile(c, 1, &policy()).unwrap(); + for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] { + assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}"); + } + for hostname in ["suba.local", "subb.local", "www.site.test"] { + let frontend = snapshot + .frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!( + frontend.www_redirect.as_ref(), + Some(&("www.site.test".into(), "site.test".into())) + ); + } + } + + #[test] + fn implicit_www_target_is_claimed_and_other_domain_stays_independent() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()]; + c.vhosts[0].www_redirect = true; + c.vhosts[0].www_redirect_target = Some("www.subb.local".into()); + let snapshot = compile(c, 1, &policy()).unwrap(); + for host in ["suba.local", "subb.local", "www.subb.local"] { + assert!(snapshot.select(host, "/path").is_some(), "{host}"); + } + assert!(snapshot.select("www.suba.local", "/path").is_none()); + let frontend = snapshot + .frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!( + frontend.www_redirect.as_ref(), + Some(&(String::from("subb.local"), String::from("www.subb.local"))) + ); + } + + #[test] + fn selected_www_redirect_target_must_belong_to_vhost() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["suba.local".into()]; + c.vhosts[0].www_redirect = true; + c.vhosts[0].www_redirect_target = Some("elsewhere.local".into()); + assert!(compile(c, 1, &policy()).is_err()); + } + + #[test] + fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; + c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http; + c.vhosts[0].load_balancing.health_check.host = None; + let snapshot = compile(c, 1, &policy()).unwrap(); + assert_eq!( + snapshot.pools[0].settings.health_check.host.as_deref(), + Some("app.example.test") + ); + } + + #[test] + fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; + c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Tcp; + assert_eq!( + c.vhosts[0].load_balancing.health_check.mode, + crate::balance::HealthMode::Tcp + ); + c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { + mode: crate::balance::HealthMode::Http, + path: "/healthz".into(), + ..Default::default() + }); + let snapshot = compile(c, 1, &policy()).unwrap(); + let pool = &snapshot.pools[0]; + assert_eq!( + pool.settings + .health_for(&pool.members[0].definition) + .host + .as_deref(), + Some("app.example.test") + ); + } + #[test] + fn explicit_http_health_check_host_override_wins_over_vhost_hostname() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; + c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http; + c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into()); + let snapshot = compile(c, 1, &policy()).unwrap(); + assert_eq!( + snapshot.pools[0].settings.health_check.host.as_deref(), + Some("health.example.test") + ); + } + + #[test] + fn custom_location_redirect_supports_separate_capture_regex_and_query() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes = vec![crate::vhost::PathRoute { + id: "archives".into(), + path_prefix: r"\.(?:tar\.gz|zip?)$".into(), + match_type: crate::vhost::PathMatch::RegexInsensitive, + response: None, + redirect: Some(crate::vhost::LocationRedirect { + status: 301, + target: "https://storage.example.test/o/$1".into(), + rewrite_pattern: Some(r"^/(.*)$".into()), + preserve_query: true, + }), + ..Default::default() + }]; + let snapshot = compile(c, 1, &policy()).unwrap(); + let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap(); + let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap(); + let location = route + .redirect_location("/repo/file.tar.gz", &uri) + .unwrap() + .unwrap(); + assert_eq!( + location, + "https://storage.example.test/o/repo/file.tar.gz?download=1" + ); + } + + #[test] + fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.php"), b") + } else if pattern == "*.example.test" { + Some(Some("wrong-fallback")) + } else { + None + } + }) + .flatten(); + assert_eq!(match_found, None); + assert_eq!(visited, vec!["api.example.test"]); + } + + #[test] + fn stack_host_lookup_preserves_long_internal_host_fallback() { + let long = format!("a.{}.sub.example.test", "x".repeat(260)); + let mut patterns = Vec::new(); + let found = Snapshot::find_host_pattern(&long, |pattern| { + patterns.push(pattern.to_owned()); + (pattern == "*.sub.example.test").then_some(true) + }); + assert_eq!(found, Some(true)); + assert_eq!( + patterns[1], + format!("*.{}.sub.example.test", "x".repeat(260)) + ); + assert_eq!(patterns[2], "*.sub.example.test"); + } + #[test] + fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.acl_profiles = serde_json::from_value(serde_json::json!([ {"id":"office","name":"Office","acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}}, {"id":"home","name":"Home","acl":{"default":"deny","rules":[{"network":"192.0.2.0/24","action":"allow"}]}} ])).unwrap(); - c.vhosts[0].acl_profile_ids = vec!["office".into(), "home".into()]; - c.vhosts[0].acl = Some(crate::acl::Acl { - default: crate::acl::Action::Deny, - rules: vec![crate::acl::Rule { network: "10.0.0.0/8".into(), action: crate::acl::Action::Allow }], - }); - let snapshot = compile(c.clone(), 1, &policy()).unwrap(); - let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap(); - assert_eq!(frontend.profile_policies.len(), 2); - assert!(crate::acl::allows_profiles_with_country(&frontend.profile_policies, "192.0.2.5".parse().unwrap(), None)); - assert!(!frontend.acl.as_ref().unwrap().allows("192.0.2.5".parse().unwrap())); + c.vhosts[0].acl_profile_ids = vec!["office".into(), "home".into()]; + c.vhosts[0].acl = Some(crate::acl::Acl { + default: crate::acl::Action::Deny, + rules: vec![crate::acl::Rule { + network: "10.0.0.0/8".into(), + action: crate::acl::Action::Allow, + }], + }); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + let frontend = snapshot + .frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!(frontend.profile_policies.len(), 2); + assert!(crate::acl::allows_profiles_with_country( + &frontend.profile_policies, + "192.0.2.5".parse().unwrap(), + None + )); + assert!(!frontend + .acl + .as_ref() + .unwrap() + .allows("192.0.2.5".parse().unwrap())); - c.vhosts[0].acl_profile_ids = vec!["office".into(), "unknown".into()]; - assert!(compile(c.clone(), 2, &policy()).is_err()); - c.vhosts[0].acl_profile_ids = vec!["office".into(), "office".into()]; - assert!(compile(c, 3, &policy()).is_err()); -} - -#[test] -fn old_vhost_profile_field_survives_json_roundtrip() { - let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); - c.acl_profiles = serde_json::from_value(serde_json::json!([{"id":"office","name":"Office"}])).unwrap(); - c.vhosts[0].acl_profile_id = Some("office".into()); - let json = serde_json::to_string(&c).unwrap(); - let decoded: Config = serde_json::from_str(&json).unwrap(); - assert!(decoded.vhosts[0].acl_profile_ids.is_empty()); - let snapshot = compile(decoded, 1, &policy()).unwrap(); - let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap(); - assert_eq!(frontend.profile_policies.len(), 1); - assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); -} + c.vhosts[0].acl_profile_ids = vec!["office".into(), "unknown".into()]; + assert!(compile(c.clone(), 2, &policy()).is_err()); + c.vhosts[0].acl_profile_ids = vec!["office".into(), "office".into()]; + assert!(compile(c, 3, &policy()).is_err()); + } + #[test] + fn old_vhost_profile_field_survives_json_roundtrip() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.acl_profiles = + serde_json::from_value(serde_json::json!([{"id":"office","name":"Office"}])).unwrap(); + c.vhosts[0].acl_profile_id = Some("office".into()); + let json = serde_json::to_string(&c).unwrap(); + let decoded: Config = serde_json::from_str(&json).unwrap(); + assert!(decoded.vhosts[0].acl_profile_ids.is_empty()); + let snapshot = compile(decoded, 1, &policy()).unwrap(); + let frontend = snapshot + .frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!(frontend.profile_policies.len(), 1); + assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); + } } diff --git a/src/config/transport_tests.rs b/src/config/transport_tests.rs new file mode 100644 index 0000000..3c18b05 --- /dev/null +++ b/src/config/transport_tests.rs @@ -0,0 +1,51 @@ +mod transport_tests { + use crate::config::{compile, demo_config, ValidationPolicy}; + + fn policy() -> ValidationPolicy { + ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } + } + + #[test] + fn local_listener_backend_requires_explicit_risk_acceptance() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + let backend = &mut config.vhosts[0].backends[0]; + backend.url = "http://127.0.0.1:9090".into(); + backend.allow_private = true; + backend.accept_local_listener_risk = false; + + let error = compile(config.clone(), 1, &policy()) + .expect_err("local administration listener must be rejected by default"); + let detail = format!("{error:#}"); + assert!( + detail.contains("accept_local_listener_risk=true"), + "{detail}" + ); + + config.vhosts[0].backends[0].accept_local_listener_risk = true; + compile(config, 2, &policy()) + .expect("explicit risk acceptance should permit the local listener target"); + } + + #[test] + fn rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].websockets = true; + config.vhosts[0].websocket_rfc8441 = true; + config.vhosts[0].http2_enabled = true; + + let error = compile(config.clone(), 10, &policy()) + .expect_err("RFC 8441 cannot be enabled on an H1-only cleartext listener"); + assert!(error.to_string().contains("HTTP/2-capable listener")); + + config.listeners.endpoints[0].binding.h2c = true; + let snapshot = compile(config, 11, &policy()) + .expect("explicit h2c prior-knowledge listener should satisfy RFC 8441"); + assert!(snapshot.websocket_rfc8441_on("127.0.0.1:8080".parse().unwrap(), false)); + } +} diff --git a/src/db.rs b/src/db.rs index 8f4991f..1703a7e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,5 +1,3 @@ -//! SQLite stores desired configuration, administrator state and bounded audit history. -//! No database call is made from the public HTTP forwarding path. use crate::config::{Config, Envelope}; mod certificate_api; mod dashboard_presets; @@ -61,7 +59,6 @@ pub(crate) async fn audit_tx( action: &str, detail: &str, ) -> Result<()> { - // Callers only supply allowlisted actions and non-secret, bounded metadata. sqlx::query("INSERT INTO admin_audit(time, actor, action, detail) VALUES(?, ?, ?, ?)") .bind(crate::auth::now()) .bind(actor) @@ -212,7 +209,6 @@ impl Store { pub async fn replace(&self, expected: u64, config: &Config) -> Result { self.replace_as(expected, config, "local").await } - /// Compare-and-swap and audit are one transaction. Publication is handled by App. pub async fn replace_as(&self, expected: u64, config: &Config, actor: &str) -> Result { self.replace_as_action(expected, config, actor, "config.apply", None) .await diff --git a/src/db/api_tokens.rs b/src/db/api_tokens.rs index ef9b4a4..7ed15cc 100644 --- a/src/db/api_tokens.rs +++ b/src/db/api_tokens.rs @@ -15,37 +15,68 @@ pub struct ApiTokenMetadata { pub last_used_at: Option, } #[derive(Debug)] -pub struct ApiTokenGrant { pub id: String, pub permission: String } +pub struct ApiTokenGrant { + pub id: String, + pub permission: String, +} impl Store { pub async fn api_tokens(&self) -> Result> { let rows = sqlx::query("SELECT id,permission,comment,allowed_cidrs,created_at,last_used_at FROM administration_api_tokens ORDER BY created_at DESC,id") .fetch_all(&self.pool).await?; - rows.into_iter().map(|row| { - let cidrs: String = row.try_get("allowed_cidrs")?; - Ok(ApiTokenMetadata { id: row.try_get("id")?, permission: row.try_get("permission")?, - comment: row.try_get("comment")?, allowed_cidrs: serde_json::from_str(&cidrs)?, - created_at: row.try_get("created_at")?, last_used_at: row.try_get("last_used_at")? }) - }).collect() + rows.into_iter() + .map(|row| { + let cidrs: String = row.try_get("allowed_cidrs")?; + Ok(ApiTokenMetadata { + id: row.try_get("id")?, + permission: row.try_get("permission")?, + comment: row.try_get("comment")?, + allowed_cidrs: serde_json::from_str(&cidrs)?, + created_at: row.try_get("created_at")?, + last_used_at: row.try_get("last_used_at")?, + }) + }) + .collect() } - pub async fn create_api_token(&self, id: &str, hash: &str, permission: &str, - comment: &str, cidrs: &[String], actor: &str) -> Result { + pub async fn create_api_token( + &self, + id: &str, + hash: &str, + permission: &str, + comment: &str, + cidrs: &[String], + actor: &str, + ) -> Result { let mut tx = self.pool.begin().await?; - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM administration_api_tokens").fetch_one(&mut *tx).await?; + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM administration_api_tokens") + .fetch_one(&mut *tx) + .await?; ensure!(count < API_TOKEN_CAPACITY, "API token limit reached"); let result = sqlx::query("INSERT OR IGNORE INTO administration_api_tokens(id,token_hash,permission,comment,allowed_cidrs,created_at) VALUES(?,?,?,?,?,?)") .bind(id).bind(hash).bind(permission).bind(comment).bind(serde_json::to_string(cidrs)?) .bind(crate::auth::now()).execute(&mut *tx).await?; - if result.rows_affected() != 1 { return Ok(false); } - audit_tx(&mut tx, actor, "api_token.create", &format!("id={id} permission={permission}")).await?; + if result.rows_affected() != 1 { + return Ok(false); + } + audit_tx( + &mut tx, + actor, + "api_token.create", + &format!("id={id} permission={permission}"), + ) + .await?; tx.commit().await?; Ok(true) } pub async fn revoke_api_token(&self, id: &str, actor: &str) -> Result { let mut tx = self.pool.begin().await?; let result = sqlx::query("DELETE FROM administration_api_tokens WHERE id = ?") - .bind(id).execute(&mut *tx).await?; - if result.rows_affected() == 0 { return Ok(false); } + .bind(id) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Ok(false); + } audit_tx(&mut tx, actor, "api_token.revoke", &format!("id={id}")).await?; tx.commit().await?; Ok(true) @@ -54,39 +85,63 @@ impl Store { let Some(row) = sqlx::query("SELECT id,permission,allowed_cidrs FROM administration_api_tokens WHERE token_hash = ?") .bind(hash).fetch_optional(&self.pool).await? else { return Ok(None); }; let cidrs: String = row.try_get("allowed_cidrs")?; - let allowed: Vec = serde_json::from_str(&cidrs).context("invalid stored CIDR list")?; - if !allowed.iter().filter_map(|s| s.parse::().ok()).any(|net| net.contains(&ip)) { + let allowed: Vec = + serde_json::from_str(&cidrs).context("invalid stored CIDR list")?; + if !allowed + .iter() + .filter_map(|s| s.parse::().ok()) + .any(|net| net.contains(&ip)) + { return Ok(None); } let id: String = row.try_get("id")?; let permission: String = row.try_get("permission")?; sqlx::query("UPDATE administration_api_tokens SET last_used_at = ? WHERE id = ?") - .bind(crate::auth::now()).bind(&id).execute(&self.pool).await?; + .bind(crate::auth::now()) + .bind(&id) + .execute(&self.pool) + .await?; Ok(Some(ApiTokenGrant { id, permission })) } pub async fn prometheus_token_hash(&self) -> Result> { - Ok(sqlx::query_scalar("SELECT token_hash FROM prometheus_credential WHERE id = 1") - .fetch_optional(&self.pool).await?) + Ok( + sqlx::query_scalar("SELECT token_hash FROM prometheus_credential WHERE id = 1") + .fetch_optional(&self.pool) + .await?, + ) } pub async fn prometheus_token_status(&self) -> Result> { - Ok(sqlx::query_scalar("SELECT created_at FROM prometheus_credential WHERE id = 1 AND token_hash != 'revoked'") - .fetch_optional(&self.pool).await?) + Ok(sqlx::query_scalar( + "SELECT created_at FROM prometheus_credential WHERE id = 1 AND token_hash != 'revoked'", + ) + .fetch_optional(&self.pool) + .await?) } pub async fn rotate_prometheus_token(&self, hash: &str, actor: &str) -> Result<()> { let mut tx = self.pool.begin().await?; sqlx::query("INSERT INTO prometheus_credential(id,token_hash,created_at) VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET token_hash=excluded.token_hash,created_at=excluded.created_at") .bind(hash).bind(crate::auth::now()).execute(&mut *tx).await?; - audit_tx(&mut tx, actor, "prometheus_token.rotate", "exporter token rotated").await?; + audit_tx( + &mut tx, + actor, + "prometheus_token.rotate", + "exporter token rotated", + ) + .await?; tx.commit().await?; Ok(()) } pub async fn revoke_prometheus_token(&self, actor: &str) -> Result<()> { let mut tx = self.pool.begin().await?; - // Keep a disabled marker so an old environment token does not silently - // become valid again after the operator clicks Revoke in the panel. sqlx::query("INSERT INTO prometheus_credential(id,token_hash,created_at) VALUES(1,'revoked',?) ON CONFLICT(id) DO UPDATE SET token_hash='revoked',created_at=excluded.created_at") .bind(crate::auth::now()).execute(&mut *tx).await?; - audit_tx(&mut tx, actor, "prometheus_token.revoke", "exporter token revoked").await?; + audit_tx( + &mut tx, + actor, + "prometheus_token.revoke", + "exporter token revoked", + ) + .await?; tx.commit().await?; Ok(()) } diff --git a/src/db/certificate_api.rs b/src/db/certificate_api.rs index fbe3a10..c68b45a 100644 --- a/src/db/certificate_api.rs +++ b/src/db/certificate_api.rs @@ -5,7 +5,10 @@ use super::{ use anyhow::{ensure, Context, Result}; use sqlx::Row; -fn normalized_certificate_api_ids(comment: &str, certificate_ids: &[String]) -> Result> { +fn normalized_certificate_api_ids( + comment: &str, + certificate_ids: &[String], +) -> Result> { ensure!( comment.len() <= 256 && !comment.chars().any(char::is_control), "invalid certificate API comment" @@ -18,7 +21,9 @@ fn normalized_certificate_api_ids(comment: &str, certificate_ids: &[String]) -> normalized.sort(); normalized.dedup(); ensure!( - normalized.iter().all(|value| value != "*" && crate::config::valid_id(value)), + normalized + .iter() + .all(|value| value != "*" && crate::config::valid_id(value)), "invalid certificate API certificate id" ); ensure!( @@ -36,7 +41,8 @@ fn credential_from_row(row: sqlx::sqlite::SqliteRow) -> Result("allow_deploy")? != 0, - certificate_ids: serde_json::from_str(&raw_ids).context("invalid certificate API grant ids")?, + certificate_ids: serde_json::from_str(&raw_ids) + .context("invalid certificate API grant ids")?, }) } @@ -135,7 +141,6 @@ impl Store { }) } - // Metadata access never selects token_hash, including for a single credential. pub async fn certificate_api_credential( &self, key_id: &str, @@ -154,7 +159,6 @@ impl Store { row.map(credential_from_row).transpose() } - // Do not touch token_hash, created_at, or last_used_at while editing settings. pub async fn edit_certificate_api_credential( &self, key_id: &str, @@ -170,11 +174,12 @@ impl Store { let normalized_ids = normalized_certificate_api_ids(comment, certificate_ids)?; let ids_json = serde_json::to_string(&normalized_ids)?; let mut tx = self.pool.begin().await?; - let changed = sqlx::query("UPDATE certificate_api_credentials SET comment = ? WHERE key_id = ?") - .bind(comment) - .bind(key_id) - .execute(&mut *tx) - .await?; + let changed = + sqlx::query("UPDATE certificate_api_credentials SET comment = ? WHERE key_id = ?") + .bind(comment) + .bind(key_id) + .execute(&mut *tx) + .await?; if changed.rows_affected() == 0 { return Ok(None); } @@ -193,7 +198,11 @@ impl Store { "certificate_api.edit", &format!( "key_id={key_id}; scope={}; certificate_ids={}", - if allow_deploy { "deploy+reload" } else { "reload" }, + if allow_deploy { + "deploy+reload" + } else { + "reload" + }, normalized_ids.join(",") ), ) @@ -202,7 +211,6 @@ impl Store { self.certificate_api_credential(key_id).await } - // Hash-only rotation preserves comment/grants and invalidates the old token at commit. pub async fn regenerate_certificate_api_token( &self, key_id: &str, @@ -231,8 +239,13 @@ impl Store { if changed.rows_affected() == 0 { return Ok(None); } - audit_tx(&mut tx, actor, "certificate_api.regenerate", &format!("key_id={key_id}")) - .await?; + audit_tx( + &mut tx, + actor, + "certificate_api.regenerate", + &format!("key_id={key_id}"), + ) + .await?; tx.commit().await?; self.certificate_api_credential(key_id).await } diff --git a/src/db/dashboard_presets.rs b/src/db/dashboard_presets.rs index 8f0bec5..a4bd3e1 100644 --- a/src/db/dashboard_presets.rs +++ b/src/db/dashboard_presets.rs @@ -1,4 +1,3 @@ -//! SQLite persistence for user-named Overview snapshots (control plane only). use super::Store; use anyhow::{ensure, Result}; use sqlx::Row; @@ -31,8 +30,6 @@ impl Store { document.len() >= 2 && document.len() <= 8192, "invalid dashboard preset size" ); - // Limit to 16 user-named presets. The cap and upsert are checked in one - // SQLite statement, including under concurrent requests. let result = sqlx::query( "INSERT INTO dashboard_presets(administrator_id, name, document, updated_at) \ SELECT a.id, ?, ?, ? FROM administrator a WHERE a.id = 1 AND a.username = ? \ @@ -65,7 +62,6 @@ impl Store { old: &str, new: &str, ) -> Result { - // UPDATE OR IGNORE ensures an existing target name is never overwritten. let result = sqlx::query( "UPDATE OR IGNORE dashboard_presets SET name = ?, updated_at = ? \ WHERE administrator_id = (SELECT id FROM administrator WHERE id = 1 AND username = ?) \ diff --git a/src/demo.rs b/src/demo.rs index a1f5e7e..a3a9a4c 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -1,4 +1,3 @@ -//! Separate opt-in HTTP backend, not a shortcut inside the proxy handler. use axum::{ body::Body, extract::Request, @@ -90,7 +89,6 @@ async fn websocket(ws: axum::extract::ws::WebSocketUpgrade) -> Response { }) } -/// Explicitly public demo object for manual cache/compression checks; no request secrets. async fn cache_demo() -> Response { let now = crate::logging::now_ms(); let text = format!( diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 17e0846..2cac37a 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -1,4 +1,3 @@ -//! Low-frequency OS sampling, off the request path. Unknown values stay null. use crate::system_stats::{self, Counters, View}; use std::{ sync::{Arc, RwLock}, @@ -148,9 +147,6 @@ impl Diagnostics { } } -/// Return free glibc allocator arenas/pages to the OS. This does not evict live -/// RustProxy objects; it only asks the allocator to release already-free pages. -/// On non-glibc platforms this is a no-op. pub fn trim_allocator() -> bool { #[cfg(all(target_os = "linux", target_env = "gnu"))] unsafe { diff --git a/src/docker.rs b/src/docker.rs index 2d472bb..b794ce0 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -1,7 +1,3 @@ -//! Optional Docker host discovery through the standalone RustProxy Docker Agent. -//! Desired assignments are persisted in the normal configuration; observations -//! stay in memory and are reconciled into ephemeral vhosts without SQLite writes. - include!("docker/model.rs"); include!("docker/client.rs"); include!("docker/reconcile.rs"); diff --git a/src/docker/model.rs b/src/docker/model.rs index 610d22e..5119b78 100644 --- a/src/docker/model.rs +++ b/src/docker/model.rs @@ -35,7 +35,9 @@ const MAX_SERVICES: usize = 512; const MAX_ADDRESSES: usize = 64; const MAX_BINDINGS: usize = 32; -fn yes() -> bool { true } +fn yes() -> bool { + true +} #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -111,20 +113,13 @@ pub struct Assignment { pub enabled: bool, pub scheme: BackendScheme, pub preserve_host: bool, - /// None preserves the pre-0.18.8 behavior for assignments saved before - /// the per-app redirect switch existed. New UI assignments persist true. pub force_ssl: Option, pub websockets: bool, - /// Frontend HTTP/2 opt-out. Old assignments inherit the enabled default. #[serde(default = "yes")] pub http2_enabled: bool, - /// Frontend HTTP/3 opt-out. Requires a configured QUIC listener to be offered. #[serde(default = "yes")] pub http3_enabled: bool, - /// Single Docker-vhost dynamic-compression switch. None inherits the global policy. pub compression_enabled: Option, - /// Legacy pre-release 0.27.0 fields kept for saved-config compatibility. Docker - /// reconciliation ignores per-codec overrides; codecs are selected globally. pub compression_gzip: Option, pub compression_br: Option, pub compression_zstd: Option, @@ -134,7 +129,6 @@ pub struct Assignment { pub acl_profile_id: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub acl_profile_ids: Vec, - /// Opt in to a deliberately mismatched public certificate for this hostname. pub allow_certificate_mismatch: bool, pub acl: Option, pub proxy_timeouts: crate::vhost::ProxyTimeouts, @@ -302,8 +296,11 @@ impl Settings { if let Some(ip) = agent.default_target_ip { usable_target_ip(ip).context("invalid Docker agent default target IP")?; } - crate::acl::validate_profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) - .context("invalid Docker agent ACL profiles")?; + crate::acl::validate_profile_ids( + agent.acl_profile_id.as_deref(), + &agent.acl_profile_ids, + ) + .context("invalid Docker agent ACL profiles")?; ensure!( agent.listener_ids.len() <= 16, "Docker agent allows at most 16 listener references" @@ -340,8 +337,10 @@ impl Settings { usable_target_ip(ip).context("invalid Docker assignment target IP")?; } crate::acl::validate_profile_ids( - assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids, - ).context("invalid Docker assignment ACL profiles")?; + assignment.acl_profile_id.as_deref(), + &assignment.acl_profile_ids, + ) + .context("invalid Docker assignment ACL profiles")?; ensure!( !assignment.allow_certificate_mismatch || agent.certificate_id.is_some(), "Docker certificate mismatch override requires a selected agent certificate" @@ -424,7 +423,6 @@ pub struct Service { pub protocol: String, #[serde(default)] pub bindings: Vec, - // Compatibility with the first 0.18 Docker agent during rolling upgrades. #[serde(default, skip_serializing)] pub public_port: u16, #[serde(default, skip_serializing)] diff --git a/src/docker/reconcile.rs b/src/docker/reconcile.rs index 78cbf30..55609c3 100644 --- a/src/docker/reconcile.rs +++ b/src/docker/reconcile.rs @@ -60,14 +60,13 @@ pub fn effective_config( observed: &BTreeMap, ) -> Result { for agent in &desired.docker_agents.agents { - // Published assignments outlive transient discovery state. A disabled - // agent stops polling, but its still-published hostnames remain claimed - // and return 503 until the agent is re-enabled or the assignment is - // explicitly unpublished. A failed enabled-agent poll may retain the - // last successful inventory. let inventory = agent .enabled - .then(|| observed.get(&agent.id).and_then(|item| item.inventory.as_ref())) + .then(|| { + observed + .get(&agent.id) + .and_then(|item| item.inventory.as_ref()) + }) .flatten(); for assignment in agent .assignments @@ -81,9 +80,7 @@ pub fn effective_config( .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)?; @@ -111,9 +108,8 @@ pub fn effective_config( })] }) .unwrap_or_default(); - // A per-publication selection overrides the agent default; an empty - // selection inherits it, preserving legacy configuration behavior. - let override_profiles = assignment.acl_profile_id.is_some() || !assignment.acl_profile_ids.is_empty(); + let override_profiles = + assignment.acl_profile_id.is_some() || !assignment.acl_profile_ids.is_empty(); let (acl_profile_id, acl_profile_ids) = if override_profiles { (&assignment.acl_profile_id, &assignment.acl_profile_ids) } else { @@ -128,8 +124,6 @@ pub fn effective_config( "http2_enabled": assignment.http2_enabled, "http3_enabled": assignment.http3_enabled, "backends": backends, - // Docker publications intentionally use one compression switch. The enabled - // codec set is inherited from global Response settings. "compression": {"enabled": assignment.compression_enabled}, "cache": {"enabled": assignment.cache_enabled}, "transform": {"enabled": assignment.transform_enabled}, diff --git a/src/docker/tests.rs b/src/docker/tests.rs index 1cc824a..f6734b3 100644 --- a/src/docker/tests.rs +++ b/src/docker/tests.rs @@ -48,7 +48,8 @@ mod tests { fn docker_protocol_flags_default_on_but_can_be_disabled_and_round_trip() { let legacy: Assignment = serde_json::from_value(serde_json::json!({ "service_key": "container:app:8080/tcp", "hostname": "app" - })).unwrap(); + })) + .unwrap(); assert!(legacy.http2_enabled && legacy.http3_enabled); let mut configured = legacy; configured.http2_enabled = false; @@ -285,7 +286,14 @@ mod tests { ..Default::default() }); let mut observed = observation(service(), vec![]); - observed.get_mut("node1").unwrap().inventory.as_mut().unwrap().services.clear(); + observed + .get_mut("node1") + .unwrap() + .inventory + .as_mut() + .unwrap() + .services + .clear(); let effective = effective_config(desired, &observed).unwrap(); assert_eq!(effective.vhosts.len(), 1); assert!(effective.vhosts[0].backends.is_empty()); @@ -329,7 +337,6 @@ mod tests { service_key: "container:app:8080/tcp".into(), hostname: "app".into(), compression_enabled: Some(true), - // Legacy per-codec values must not affect Docker vhosts. compression_gzip: Some(false), compression_br: Some(true), ..Default::default() @@ -571,14 +578,21 @@ mod tests { }); let observed = observation(service(), vec![]); let inherited = effective_config(desired.clone(), &observed).unwrap(); - assert_eq!(inherited.vhosts[0].acl_profile_id.as_deref(), Some("legacy")); + assert_eq!( + inherited.vhosts[0].acl_profile_id.as_deref(), + Some("legacy") + ); assert_eq!(inherited.vhosts[0].acl_profile_ids, vec!["work", "vpn"]); assert!(inherited.vhosts[0].allow_certificate_mismatch); - desired.docker_agents.agents[0].assignments[0].acl_profile_ids = vec!["guest".into(), "branch".into()]; + desired.docker_agents.agents[0].assignments[0].acl_profile_ids = + vec!["guest".into(), "branch".into()]; desired.docker_agents.agents[0].assignments[0].allow_certificate_mismatch = false; let overridden = effective_config(desired, &observed).unwrap(); assert_eq!(overridden.vhosts[0].acl_profile_id, None); - assert_eq!(overridden.vhosts[0].acl_profile_ids, vec!["guest", "branch"]); + assert_eq!( + overridden.vhosts[0].acl_profile_ids, + vec!["guest", "branch"] + ); assert!(!overridden.vhosts[0].allow_certificate_mismatch); } @@ -586,10 +600,10 @@ mod tests { fn older_docker_assignment_json_has_safe_defaults() { let assignment: Assignment = serde_json::from_value(serde_json::json!({ "service_key": "container:app:8080/tcp", "hostname": "app", "acl_profile_id": "legacy" - })).unwrap(); + })) + .unwrap(); assert_eq!(assignment.acl_profile_id.as_deref(), Some("legacy")); assert!(assignment.acl_profile_ids.is_empty()); assert!(!assignment.allow_certificate_mismatch); } - } diff --git a/src/forwarding.rs b/src/forwarding.rs index 4cdafae..b54ace0 100644 --- a/src/forwarding.rs +++ b/src/forwarding.rs @@ -1,5 +1,3 @@ -//! Single trust boundary for TCP, TLS and QUIC. Incoming proxy headers are never -//! authority for routing, listener selection or identity without a trusted peer. use anyhow::{ensure, Context as _, Result}; use http::{HeaderMap, HeaderValue, Uri}; use ipnet::IpNet; @@ -13,7 +11,6 @@ use std::{ #[serde(default, deny_unknown_fields)] pub struct Settings { pub trusted_proxies: Vec, - /// Enable only when the trusted last hop sanitizes X-Forwarded-Proto. pub trust_forwarded_proto: bool, } #[derive(Debug)] @@ -97,8 +94,6 @@ fn cloudflare_peer(peer: SocketAddr) -> bool { .any(|network| network.contains(&peer)) } -/// Return CF-Connecting-IP only when the TCP peer itself is in Cloudflare's -/// published edge ranges. The header is otherwise untrusted user input. pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option { if !cloudflare_peer(peer) { return None; @@ -117,8 +112,6 @@ pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option Option { if !cloudflare_peer(peer) { return None; @@ -202,9 +195,6 @@ impl Compiled { pub fn trusted(&self, ip: IpAddr) -> bool { self.networks.iter().any(|net| net.contains(&canonical(ip))) } - /// Whether this peer may define the externally visible request scheme. - /// Keeping this separate from `trusted` preserves the explicit - /// `trust_forwarded_proto` opt-in used by the public proxy path. pub fn trusted_forwarded_proto(&self, ip: IpAddr) -> bool { self.trust_proto && self.trusted(ip) } @@ -220,9 +210,6 @@ impl Compiled { self.context_with_original_url(incoming, peer, local, tls, authority, uri, true) } - /// The absolute original URL is only consumed by the optional Tinyauth - /// verifier. Ordinary proxy requests need the exact forwarding metadata, - /// but do not need to allocate this extra diagnostic URL. pub fn context_with_original_url( &self, incoming: &HeaderMap, @@ -239,9 +226,6 @@ impl Compiled { ) } - /// Only the host/path validation and formatting data from the same request - /// and accepted connection are reused. Dynamic trust and XFF are evaluated - /// on every request, including after an in-place configuration reload. #[allow(clippy::too_many_arguments)] pub(crate) fn context_with_peer( &self, @@ -268,9 +252,6 @@ impl Compiled { } else { String::new() }; - // Repeated HTTP/1.1 requests generally share Host and TLS state. - // Cache only the untrusted-peer metadata for this accepted socket; - // X-Forwarded-Uri, original URL and all trust decisions remain per-request. let target_header: HeaderValue = target.parse()?; let mut values = if let Some(values) = cached.and_then(|peer| peer.cached_untrusted(authority, tls)) @@ -321,7 +302,6 @@ impl Compiled { } let mut ips = chain(incoming).unwrap_or_default(); ips.push(peer_ip); - // Discard everything left of the first untrusted hop walking from us. let first = ips.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0); ips.drain(..first); let client_ip = ips[0]; @@ -340,7 +320,6 @@ impl Compiled { }; } let authority_text = authority.to_str()?; - // Validate before inserting authority into Forwarded's quoted host field. if !validated_host { crate::config::request_host(authority_text)?; } let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/"); let original_url = if include_original_url { @@ -400,8 +379,6 @@ impl Compiled { } } impl Context { - /// Replace the effective client with a previously authenticated edge header. - /// Existing proxy-chain metadata is retained behind the visitor address. pub fn override_client_ip(&mut self, client: IpAddr) -> Result<()> { let client = canonical(client); let client_text = client.to_string(); @@ -442,10 +419,6 @@ impl Context { self.apply_clean(headers, true); } fn apply_clean(&self, headers: &mut HeaderMap, include_client_ip: bool) { - // HeaderMap::insert replaces all values for a known canonical name. - // Remove unknown/spoofed forwarding names while retaining the valid ones. - // The normal request has no extra spoofed header names. Allocate - // a removal list only for requests which actually need sanitization. let mut remove = None; for name in headers.keys() { let text = name.as_str(); @@ -472,10 +445,6 @@ impl Context { } } - /// Apply trusted forwarding metadata to an application request. When client-IP - /// forwarding is disabled, preserve scheme/host/port/URI metadata but withhold - /// the visitor address so frameworks such as ProxyFix see the RustProxy socket peer. - /// Authentication verifiers use `apply` directly and still receive the real client IP. pub fn apply_upstream(&self, headers: &mut HeaderMap, forward_client_ip: bool) { self.apply_clean(headers, forward_client_ip); } diff --git a/src/forwarding/peer_formatting.rs b/src/forwarding/peer_formatting.rs index 1bb99b7..6dfcd75 100644 --- a/src/forwarding/peer_formatting.rs +++ b/src/forwarding/peer_formatting.rs @@ -1,5 +1,3 @@ -/// Socket formatting shared by all HTTP/1 requests on one accepted socket. -/// No routing or trust decision is cached: those follow the current snapshot. #[derive(Debug)] pub(crate) struct PeerFormatting { peer: SocketAddr, @@ -9,14 +7,12 @@ pub(crate) struct PeerFormatting { peer_node: String, by_node: String, port_header: HeaderValue, - // One-entry, per-accepted-socket cache. No policy or client identity is cached. untrusted_headers: std::sync::Mutex>, } #[derive(Debug)] struct UntrustedHeaders { authority: HeaderValue, tls: bool, - // The URI is intentionally excluded: it can change on every request. base: HeaderMap, } impl PeerFormatting { diff --git a/src/forwarding/tests.rs b/src/forwarding/tests.rs index 0af2b05..3adf68a 100644 --- a/src/forwarding/tests.rs +++ b/src/forwarding/tests.rs @@ -139,8 +139,6 @@ mod tests { assert_eq!(direct_headers, cached_headers); } } - // When the peer becomes trusted after a config swap, the single-entry - // untrusted cache must never override the incoming proxy chain. if peer.is_ipv4() { let trusted = Settings { trusted_proxies: vec!["192.0.2.0/24".to_owned()], diff --git a/src/geoip.rs b/src/geoip.rs index 70096a4..d43de40 100644 --- a/src/geoip.rs +++ b/src/geoip.rs @@ -1,5 +1,3 @@ -//! Bounded country GeoIP reader for direct CSV pairs and native MaxMind DB files. -//! Managed downloads use direct HTTPS sources; publisher checksum URLs are optional. mod mmdb; mod update; mod update_http; @@ -29,8 +27,6 @@ struct V4Range { } #[derive(Debug, Clone, Copy)] struct V6Range { - // Network byte order preserves the numeric ordering of IPv6 addresses - // without the 16-byte alignment/padding of u128 fields. start: [u8; 16], end: [u8; 16], country: [u8; 2], @@ -83,8 +79,6 @@ impl Database { let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?; let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?; ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty"); - // Convert to exactly sized immutable slices: the CSV parser may have - // doubled Vec capacity many times during loading. Ok(Self { backend: Backend::Csv { ipv4: ipv4.into_boxed_slice(), ipv6: ipv6.into_boxed_slice() } }) } DatabaseFormat::Mmdb => Ok(Self { diff --git a/src/headers.rs b/src/headers/mod.rs similarity index 94% rename from src/headers.rs rename to src/headers/mod.rs index 5658fec..d18feca 100644 --- a/src/headers.rs +++ b/src/headers/mod.rs @@ -1,4 +1,3 @@ -//! Generic, compiled request/response header transformations. No app-specific exceptions. use anyhow::{bail, ensure, Context, Result}; use http::{HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; @@ -130,8 +129,6 @@ fn apply_rule(headers: &mut HeaderMap, rule: &CompiledRule) { } pub fn apply(headers: &mut HeaderMap, rules: &[CompiledRule]) { for rule in rules { - // Response Age rules are a wire-only presentation override. Applying - // them here would let a configured value enter cache freshness/storage. if !rule.wire_only { apply_rule(headers, rule); } @@ -145,4 +142,4 @@ pub fn apply_wire_age(headers: &mut HeaderMap, rules: &[CompiledRule]) { } } #[cfg(test)] -include!("headers/tests.rs"); +include!("tests.rs"); diff --git a/src/http3.rs b/src/http3.rs index b514779..1a3756d 100644 --- a/src/http3.rs +++ b/src/http3.rs @@ -1,6 +1,3 @@ -//! RFC 9114 transport adapter: Quinn (QUIC/TLS 1.3) + h3 (HTTP/3/QPACK). -//! Every request uses the SAME router, ACLs, auth, cache and response policies. -//! No full-body buffering or 0-RTT replay. RFC 9220 WebSocket Extended CONNECT is supported; WebTransport is not. use crate::{ metrics::Metrics, runtime::App, @@ -63,9 +60,6 @@ pub struct Bound { address: SocketAddr, settings: Settings, } -// Set the actual QUIC UDP socket buffers, not only sysctl maxima. Linux -// doubles SO_RCVBUF/SO_SNDBUF values returned by getsockopt(2), which `ss` -// reports as rb/tb. Target the effective buffer size shown by `ss`. fn requested_socket_buffer_size(effective_bytes: u32) -> usize { #[cfg(target_os = "linux")] { @@ -159,9 +153,6 @@ pub fn bind(listener: Listener, state: &App) -> Result { settings, }) } -/// An established UDP socket is immutable until restart. Use the committed -/// *desired* transport policy for new QUIC connections on that same socket; -/// never apply settings from a listener planned on a different IP/port. fn desired_for_bound(address: SocketAddr, tls: bool, desired: &[Listener]) -> Option { desired .iter() @@ -169,9 +160,6 @@ fn desired_for_bound(address: SocketAddr, tls: bool, desired: &[Listener]) -> Op .and_then(|listener| listener.http3) } -/// Compare immutable listener properties. H3 limits other than an increase in -/// the startup connection capacity are hot-applied for *new* connections. -/// UDP socket creation/removal (and H1/H2 TLS/ALPN settings) still need restart. pub fn listeners_require_restart(active: &[Listener], desired: &[Listener]) -> bool { if active.len() != desired.len() { return true; @@ -199,9 +187,6 @@ pub fn listeners_require_restart(active: &[Listener], desired: &[Listener]) -> b }) } -/// Origin-supplied Alt-Svc is never trusted. An HTTP/3 opt-out emits `clear` -/// on TLS responses while the existing QUIC socket is still bound, so clients -/// are told to discard their cached alternative service immediately. pub fn withdraw_advertisement( headers: &mut HeaderMap, local: Option, @@ -220,9 +205,6 @@ pub fn withdraw_advertisement( } } -/// Advertise only an actually active H3 UDP listener, using the newest -/// committed Alt-Svc lifetime. Disabling H3 for the listener or vhost publishes -/// `Alt-Svc: clear` without waiting for a restart. pub fn advertise( headers: &mut HeaderMap, local: Option, @@ -241,7 +223,6 @@ pub fn advertise( let Some(bound) = active.iter().find(|binding| binding.matches(local, true)) else { return; }; - // A desired H3 enable on a socket with no UDP listener is pending restart. if bound.http3.is_none() { return; } @@ -271,17 +252,11 @@ pub async fn serve(bound: Bound, router: Router, state: Arc, grace: Duratio let Some(incoming)=incoming else {break;}; let peer=incoming.remote_address(); if !incoming.remote_address_validated() {let _=incoming.retry();continue;} - // Listener setting saves are already published through ArcSwap. - // Apply new limits to fresh QUIC handshakes on this bound UDP - // address, without mutating established connections. let desired = { let snapshot = state.config.load(); desired_for_bound(bound.address, true, &snapshot.listeners) }; let Some(settings) = desired else { incoming.refuse(); continue; }; - // Startup permits and Quinn's incoming capacity cannot be grown - // without restarting the UDP endpoint. A lower limit can be - // enforced dynamically without releasing live connection leases. let open = bound.settings.max_concurrent_connections as usize - slots.available_permits(); let capacity = (settings.max_concurrent_connections as usize) .min(bound.settings.max_concurrent_connections as usize); @@ -316,9 +291,6 @@ pub async fn serve(bound: Bound, router: Router, state: Arc, grace: Duratio }; config.transport = Arc::new(transport); let Some(crypto)=state.config.load().quic_tls.clone() else {incoming.refuse();continue;}; - // Existing QUIC sessions keep their negotiated transport limits. - // New sessions get current limits and certificate without - // regenerating the Retry token key. config.crypto=crypto; let connecting=match incoming.accept_with(Arc::new(config)) { Ok(c)=>c, @@ -353,7 +325,6 @@ pub async fn serve(bound: Bound, router: Router, state: Arc, grace: Duratio }, } }; - // A wildcard without destination metadata must fail closed. let local=match connection.local_ip() { Some(ip)=>SocketAddr::new(ip,address.port()), None if !address.ip().is_unspecified()=>address, @@ -376,8 +347,6 @@ pub async fn serve(bound: Bound, router: Router, state: Arc, grace: Duratio } } } - // Connection tasks send GOAWAY and drain; only close the endpoint after the - // grace period, not immediately after a response FIN (which can lose data). let _ = tokio::time::timeout(grace, async { while tasks.join_next().await.is_some() {} }).await; tasks.abort_all(); while tasks.join_next().await.is_some() {} @@ -396,8 +365,6 @@ async fn connection_loop( ) -> Result<()> { let mut builder = h3::server::builder(); builder.max_field_section_size(32 * 1024); - // RFC 9220 remains an explicit experimental opt-in. Avoid advertising - // Extended CONNECT on listeners that only use classic HTTP/1.1 WebSockets. builder.enable_extended_connect(state.config.load().websocket_rfc9220_on(local)); let mut h3 = builder .build(h3::quinn::Connection::new(connection.clone())) @@ -417,7 +384,6 @@ async fn connection_loop( _=requests.join_next(),if !requests.is_empty()=>{}, next=h3.accept()=>match next { Ok(Some(resolver))=>{ - // Completed JoinSet entries are drained before accepting more. if requests.len()>=limit { log_protocol_error( &state, "http3_stream_limit_rejected", "HTTP/3 concurrent request stream limit reached", @@ -433,8 +399,6 @@ async fn connection_loop( Ok(Ok(request))=>request, Ok(Err(error))=>{ if client_cancelled_request(&error) { - // Record the cancellation with status=0 (no HTTP response) - // for iOS navigation investigations, never synthetic 400. log_protocol_error( &state, "http3_peer_cancelled_before_headers", format!("phase=request_headers stream_status=cancelled error={error}"), @@ -462,11 +426,8 @@ async fn connection_loop( let (mut send,receive)=stream.split(); let stream_id=format!("{:?}",send.send_id()); let diagnostic_method=request.method().as_str().to_owned(); - // Do not record query strings or cookies in transport diagnostics. let diagnostic_path=request.uri().path().chars().take(160).collect::(); let (diagnostic_authority, diagnostic_host)=request_identity(&request); - // GET / is a full document navigation (the original logo). - // Emit only query-free, bounded navigation metadata. let root_navigation=diagnostic_method=="GET" && diagnostic_path=="/"; if root_navigation { log_root_navigation( "started", peer, local, &tls_sni, &stream_id, "request_headers", 0, 0, false, @@ -487,14 +448,9 @@ async fn connection_loop( let expected=request.headers().get("content-length").and_then(|v|v.to_str().ok()).and_then(|v|v.parse::().ok()); let mut tunnel_receive=None; let mut request=if websocket { - // RFC 9220 DATA belongs to the WebSocket byte stream, not - // to an HTTP request body. Keep the receive half for the - // transport bridge after the router accepts the tunnel. tunnel_receive=Some(receive); request.map(|_|Body::empty()) } else if extended_connect { - // Unsupported Extended CONNECT protocols are answered by - // the router (501). They are not interpreted as HTTP bodies. drop(receive); request.map(|_|Body::empty()) } else { @@ -522,17 +478,11 @@ async fn connection_loop( }; drop(body); if let Err(error)=crate::proxy::websocket::run_h3(state.clone(),&mut send,&mut receive,tunnel).await { - // run_h3 owns RFC 9220 tunnel shutdown/reset semantics. - // Do not overwrite its H3_REQUEST_CANCELLED with a - // second generic H3_INTERNAL_ERROR here. tracing::debug!(%error,"HTTP/3 WebSocket tunnel closed"); } return Ok::<(),anyhow::Error>(()); } if let Some(mut receive)=tunnel_receive.take() { - // The WebSocket bootstrap was rejected before becoming - // a tunnel. Stop accepting request DATA after sending the - // ordinary HTTP error response. receive.stop_sending(Code::H3_REQUEST_CANCELLED); } while let Some(frame)=body.frame().await { @@ -569,8 +519,6 @@ async fn connection_loop( response_status, sent_body_bytes, cancelled, ); } if !cancelled { - // Never mark a truncated upstream response as success. - // Reset only this response stream, not sibling requests. send.stop_stream(Code::H3_INTERNAL_ERROR); } } else if root_navigation { @@ -579,9 +527,6 @@ async fn connection_loop( } else { log_h3_response_complete(peer, &stream_id, response_status, sent_body_bytes); } - // Connection-level cumulative counters (not per-stream - // deltas). Useful for distinguishing QUIC loss/cwnd/MTU - // limitations from application CPU when benchmarking. if sent_body_bytes >= 8 * 1024 * 1024 || expected.is_some_and(|n| n >= 8 * 1024 * 1024) { let stats = quic_diagnostics.stats(); tracing::debug!( @@ -604,10 +549,6 @@ async fn connection_loop( }, Ok(None)=>break, Err(error)=>{ - // The parser/QUIC transport can stop accepting after a peer - // refresh while unrelated response streams are still alive. - // Abort_all here reset those streams (including a new GET /). - // Stop accepting and allow the bounded drain below instead. log_protocol_error( &state, "http3_accept_error", &error, connection.remote_address(), local, 0, None, None, tls_sni.clone(), @@ -624,7 +565,6 @@ async fn connection_loop( .await; requests.abort_all(); while requests.join_next().await.is_some() {} - // Retain the transport until the client closes or the bounded drain expires. if draining { let _ = tokio::time::timeout(grace, connection.closed()).await; } diff --git a/src/http3/body.rs b/src/http3/body.rs index 7ab5a85..e19a08e 100644 --- a/src/http3/body.rs +++ b/src/http3/body.rs @@ -93,8 +93,6 @@ impl HttpBody for ReceiveBody { self.done } fn size_hint(&self) -> SizeHint { - // A declared zero is not proof that the peer ended its stream. Unknown - // lengths are verified by the cache's bounded empty-body probe. let mut hint = SizeHint::new(); if self.done { hint.set_exact(0); @@ -109,9 +107,5 @@ impl HttpBody for ReceiveBody { } } impl Drop for ReceiveBody { - fn drop(&mut self) { - // Do not call stop_sending here: h3/quinn can still have a pending - // receive read and has previously panicked on that cancellation path. - // Protocol framing violations explicitly stop only from poll_frame. - } + fn drop(&mut self) {} } diff --git a/src/http3/diagnostics.rs b/src/http3/diagnostics.rs index 07ff703..1d38f37 100644 --- a/src/http3/diagnostics.rs +++ b/src/http3/diagnostics.rs @@ -50,8 +50,6 @@ fn log_protocol_error( ); } -/// Service-level breadcrumbs for a full HTTP/3 GET / navigation. User-provided -/// query strings, headers and error details must not reach this diagnostic. #[allow(clippy::too_many_arguments)] fn log_root_navigation( stage: &'static str, @@ -83,7 +81,12 @@ fn log_root_navigation( } } -fn log_h3_response_complete(peer: SocketAddr, stream_id: &str, response_status: u16, body_bytes_sent: u64) { +fn log_h3_response_complete( + peer: SocketAddr, + stream_id: &str, + response_status: u16, + body_bytes_sent: u64, +) { tracing::debug!( peer=%peer, stream_id=%stream_id, response_status, body_bytes_sent, "HTTP/3 response completed" diff --git a/src/http3/identity.rs b/src/http3/identity.rs index e93c000..88ff0bb 100644 --- a/src/http3/identity.rs +++ b/src/http3/identity.rs @@ -1,7 +1,3 @@ -/// A client can cancel an obsolete in-flight request during navigation/F5. -/// RFC 9114 section 4.1.1 gives this a stream reset code, not an HTTP 400. -/// Treat only peer-originated cancellation as benign; local protocol errors -/// and other remote reset codes remain visible to operators. fn client_cancelled_code(code: Code) -> bool { code == Code::H3_REQUEST_CANCELLED || code == Code::H3_NO_ERROR } diff --git a/src/http3/settings.rs b/src/http3/settings.rs index 070ed5f..3d41548 100644 --- a/src/http3/settings.rs +++ b/src/http3/settings.rs @@ -5,11 +5,8 @@ pub struct Settings { pub max_concurrent_connections: u32, pub max_concurrent_bidistream: u32, pub max_concurrent_unistream: u32, - /// QUIC idle timeout in seconds; zero disables the transport idle timeout. pub max_idle_timeout: u64, - /// Desired effective UDP socket receive buffer (bytes). 0 = OS default. pub udp_receive_buffer_bytes: u32, - /// Desired effective UDP socket send buffer (bytes). 0 = OS default. pub udp_send_buffer_bytes: u32, } impl Default for Settings { diff --git a/src/http3/tests.rs b/src/http3/tests.rs index 024f582..f51e21b 100644 --- a/src/http3/tests.rs +++ b/src/http3/tests.rs @@ -41,12 +41,24 @@ mod tests { } .validate() .is_err()); - assert!(Settings { udp_receive_buffer_bytes: 1, ..Default::default() } - .validate().is_err()); - assert!(Settings { udp_send_buffer_bytes: 16 * 1024 * 1024 + 1, ..Default::default() } - .validate().is_err()); - assert!(Settings { udp_send_buffer_bytes: 0, ..Default::default() } - .validate().is_ok()); + assert!(Settings { + udp_receive_buffer_bytes: 1, + ..Default::default() + } + .validate() + .is_err()); + assert!(Settings { + udp_send_buffer_bytes: 16 * 1024 * 1024 + 1, + ..Default::default() + } + .validate() + .is_err()); + assert!(Settings { + udp_send_buffer_bytes: 0, + ..Default::default() + } + .validate() + .is_ok()); } #[test] fn alt_svc_uses_latest_committed_lifetime_and_only_bound_udp() { @@ -66,18 +78,45 @@ mod tests { let mut updated = binding; updated.http3.as_mut().unwrap().alt_svc_max_age = 19; - advertise(&mut h, Some(binding.address), true, &active, &[updated], true); + advertise( + &mut h, + Some(binding.address), + true, + &active, + &[updated], + true, + ); assert_eq!(h["alt-svc"], "h3=\":8443\"; ma=19"); - advertise(&mut h, Some(binding.address), false, &active, &[updated], true); + advertise( + &mut h, + Some(binding.address), + false, + &active, + &[updated], + true, + ); assert!(!h.contains_key("alt-svc")); - advertise(&mut h, Some("127.0.0.2:8443".parse().unwrap()), true, &active, &[updated], true); + advertise( + &mut h, + Some("127.0.0.2:8443".parse().unwrap()), + true, + &active, + &[updated], + true, + ); assert!(!h.contains_key("alt-svc")); - // An intended but not bound H3 socket MUST NOT be advertised. let mut previously_h2_only = binding; previously_h2_only.http3 = None; - advertise(&mut h, Some(binding.address), true, &[previously_h2_only], &[updated], true); + advertise( + &mut h, + Some(binding.address), + true, + &[previously_h2_only], + &[updated], + true, + ); assert!(!h.contains_key("alt-svc")); } #[test] @@ -92,11 +131,8 @@ mod tests { }; let local = Some("192.0.2.2:443".parse().unwrap()); let mut h = HeaderMap::new(); - // Vhost opt-out: immediate Alt-Svc: clear without affecting another host. advertise(&mut h, local, true, &[binding], &[binding], false); assert_eq!(h["alt-svc"], "clear"); - // Listener opt-out: listener removal requires restart but the header - // is withdrawn immediately, while existing H3 sessions may survive. let mut disabled = binding; disabled.http3 = None; advertise(&mut h, local, true, &[binding], &[disabled], true); @@ -127,7 +163,6 @@ mod tests { h3.max_concurrent_unistream = 30; h3.max_idle_timeout = 90; assert!(!listeners_require_restart(&[active], &[next])); - // A capacity increase above the startup socket/permit cap needs restart. next.http3.as_mut().unwrap().max_concurrent_connections = 513; assert!(listeners_require_restart(&[active], &[next])); next = active; @@ -153,7 +188,10 @@ mod tests { let settings: Settings = serde_json::from_str(old).unwrap(); assert_eq!(settings.udp_receive_buffer_bytes, 4 * 1024 * 1024); assert_eq!(settings.udp_send_buffer_bytes, 4 * 1024 * 1024); - assert_eq!(serde_json::from_str::(&serde_json::to_string(&settings).unwrap()).unwrap(), settings); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&settings).unwrap()).unwrap(), + settings + ); } #[test] fn udp_socket_tuning_produces_readable_socket_options() { @@ -166,8 +204,6 @@ mod tests { } #[test] fn client_cancellation_is_a_stream_event_not_an_http_400() { - // Exercise the production classifier; RemoteTerminate is - // non-exhaustive in hpx-h3 and cannot be constructed by this crate. assert!(client_cancelled_code(Code::H3_REQUEST_CANCELLED)); assert!(client_cancelled_code(Code::H3_NO_ERROR)); assert!(!client_cancelled_code(Code::H3_MESSAGE_ERROR)); diff --git a/src/logging.rs b/src/logging.rs index 123b4d1..815e68b 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,7 +1,3 @@ -//! Bounded live access ring plus optional asynchronous structured-log sinks. -//! Request handling only performs a try-lock/try-send; file/network I/O lives on -//! the background worker and is bounded by a fixed queue. - include!("logging/model.rs"); include!("logging/error_runtime.rs"); include!("logging/format.rs"); diff --git a/src/logging/error_runtime.rs b/src/logging/error_runtime.rs index 56f5363..8e57b33 100644 --- a/src/logging/error_runtime.rs +++ b/src/logging/error_runtime.rs @@ -65,15 +65,11 @@ fn is_secret_field(name: &str) -> bool { "private_key", "master_key", ]; - // ASCII-insensitive matching has the same semantics as to_ascii_lowercase, - // without allocating a new key for every field in every JSON access log. FIELDS.iter().any(|field| name.eq_ignore_ascii_case(field)) } fn redact_text_cow(input: &str) -> std::borrow::Cow<'_, str> { let mut value = std::borrow::Cow::Borrowed(input); - // Keep the original order: a replacement can expose a pattern handled by - // the next rule. Ordinary, non-secret values stay borrowed (no six copies). for (pattern, replacement) in [ ("pem", "[REDACTED PRIVATE KEY]"), ("auth", "$1[REDACTED]"), @@ -211,8 +207,6 @@ fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec { } fn write_error_fallback(event: &ErrorEvent, format: ErrorLogFormat) { - // File unavailable or the bounded queue is full: preserve the redacted - // diagnostic on stderr rather than dropping it into a black hole. let line = format_error_event(event, format); let mut stderr = io::stderr().lock(); let _ = stderr.write_all(&line); @@ -222,7 +216,10 @@ fn warn_error_sink_failure(path: &std::path::Path, error: &dyn std::fmt::Display let path = redact_text(&path.display().to_string()); let error = redact_text(&error.to_string()); let mut stderr = io::stderr().lock(); - let _ = writeln!(stderr, "RustProxy error log {action} ({path}): {error}; using stderr fallback"); + let _ = writeln!( + stderr, + "RustProxy error log {action} ({path}): {error}; using stderr fallback" + ); } async fn error_log_loop( @@ -249,8 +246,6 @@ async fn error_log_loop( let config = settings.load_full(); let path = PathBuf::from(&config.path); if !config.enabled || open_path.as_ref() != Some(&path) { - // Dropping Tokio's File closes the descriptor without blocking - // the request path. A changed path is reopened on the next event. file = None; open_path = None; retry_after = None; @@ -264,8 +259,6 @@ async fn error_log_loop( file = None; open_path = None; retry_after = None; - // A queued event may outlive a live settings change. It was - // suppressed on the console when queued; do not lose it now. write_error_fallback(&event, config.format); continue; } @@ -296,8 +289,6 @@ async fn error_log_loop( continue; }; let line = format_error_event(&event, config.format); - // Tokio's File buffers writes; flush so diagnostics are visible to - // log readers promptly and a later shutdown cannot strand a record. let write = async { opened.write_all(&line).await?; opened.flush().await @@ -317,4 +308,3 @@ async fn error_log_loop( } } } - diff --git a/src/logging/format.rs b/src/logging/format.rs index 6832861..b73fb75 100644 --- a/src/logging/format.rs +++ b/src/logging/format.rs @@ -49,9 +49,6 @@ pub struct SinkStats { pub errors: u64, } -// History owns entries only when there is no external sink. With a sink, -// both the queue and history share one immutable record instead of copying -// every allocated string on each request. enum HistoryEntry { Owned(LogEntry), Shared(Arc), @@ -164,7 +161,6 @@ impl Logs { self.dropped.fetch_add(1, Ordering::Relaxed); None }; - // Evicted record destruction must happen outside the history mutex. drop(evicted); } pub fn push(&self, entry: LogEntry) { @@ -181,8 +177,6 @@ impl Logs { None }; if let Some(permit) = sink_permit { - // One Arc allocation replaces a full LogEntry clone: no per-field - // String allocations for the active file/network logging path. let shared = Arc::new(entry); let live = has_viewers.then(|| shared.as_ref().clone()); self.retain(HistoryEntry::Shared(shared.clone())); @@ -191,7 +185,6 @@ impl Logs { } permit.send(shared); } else { - // History-only operation retains the original no-Arc/no-copy path. let live = has_viewers.then(|| entry.clone()); self.retain(HistoryEntry::Owned(entry)); if let Some(live) = live { @@ -203,11 +196,12 @@ impl Logs { if self.settings.load().enabled() { match self.sink.try_reserve() { Ok(permit) => permit.send(Arc::new(entry)), - Err(_) => { self.sink_dropped.fetch_add(1, Ordering::Relaxed); } + Err(_) => { + self.sink_dropped.fetch_add(1, Ordering::Relaxed); + } } } } - /// Persist transport/protocol failures without polluting the bounded Live Logs buffer. pub fn push_protocol_error( &self, kind: &'static str, @@ -220,7 +214,16 @@ impl Logs { tls_sni: Option, ) { self.push_protocol_error_attributed( - kind, detail, client, local, transport, http_version, status, tls_sni, None, None, + kind, + detail, + client, + local, + transport, + http_version, + status, + tls_sni, + None, + None, "none", ); } @@ -349,11 +352,7 @@ fn value_for(entry: &LogEntry, name: &str) -> Value { .clone() .map(Value::String) .unwrap_or(Value::Null), - "sni" => entry - .sni - .clone() - .map(Value::String) - .unwrap_or(Value::Null), + "sni" => entry.sni.clone().map(Value::String).unwrap_or(Value::Null), "authority" => entry .authority .clone() diff --git a/src/logging/model.rs b/src/logging/model.rs index e960ceb..554d095 100644 --- a/src/logging/model.rs +++ b/src/logging/model.rs @@ -42,11 +42,8 @@ const SINK_TIMEOUT: Duration = Duration::from_secs(5); pub struct FileSink { pub enabled: bool, pub path: String, - /// Write one JSONL file per resolved vhost instead of one combined file. #[serde(default)] pub per_vhost: bool, - /// Directory used by per-vhost mode. Files are named `.jsonl`; - /// unmatched requests are written to `_unmatched.jsonl`. #[serde(default = "default_vhost_log_directory")] pub directory: String, } @@ -101,7 +98,6 @@ impl Default for LokiSink { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct JsonFormat { - /// output field -> LogEntry field. Empty means the complete standard entry. pub fields: BTreeMap, pub static_fields: BTreeMap, } @@ -109,10 +105,7 @@ pub struct JsonFormat { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Settings { - /// Capture a bounded User-Agent value in access logs and traffic analytics. pub capture_user_agent: bool, - /// Resolve the effective client IP through the loaded GeoIP database. - /// Disabled by default because this adds per-request lookup work. pub capture_country: bool, pub file: FileSink, pub logstash: LogstashSink, @@ -370,9 +363,6 @@ impl ErrorLogController { self.settings.store(Arc::new(settings)); self.settings_changed.send_replace(()); } - /// All diagnostics go to stderr when file logging is disabled. With a file - /// configured, warnings/errors go only to that file (unless the sink fails). - /// Informational startup messages remain on the console. pub fn console_enabled_for(&self, level: &Level) -> bool { !matches!(*level, Level::WARN | Level::ERROR) || !self.settings.load().enabled } @@ -383,9 +373,6 @@ impl ErrorLogController { } if let Err(error) = self.sender.try_send(event) { self.dropped.fetch_add(1, Ordering::Relaxed); - // An overloaded/closed sink must not silently swallow a diagnostic - // suppressed by the console filter. This emergency path is bounded - // to one event and only runs on a failed non-blocking enqueue. write_error_fallback(&error.into_inner(), config.format); } } diff --git a/src/logging/runtime.rs b/src/logging/runtime.rs index cece702..50ad7ee 100644 --- a/src/logging/runtime.rs +++ b/src/logging/runtime.rs @@ -41,10 +41,6 @@ fn format_entry(entry: &LogEntry, format: &JsonFormat) -> Value { Value::Object(object) } -// A request with ordinary values cannot match any redaction rule. Every -// supported rule (including private PEM and escaped JSON) contains one of -// these case-insensitive ASCII markers. If any marker is present, fall back -// to the original full recursive redaction, even for a false positive. fn may_require_redaction(text: &str) -> bool { let data = text.as_bytes(); for (i, byte) in data.iter().enumerate() { @@ -57,7 +53,10 @@ fn may_require_redaction(text: &str) -> bool { b'c' => b"credential", _ => continue, }; - if data.get(i..i + marker.len()).is_some_and(|value| value.eq_ignore_ascii_case(marker)) { + if data + .get(i..i + marker.len()) + .is_some_and(|value| value.eq_ignore_ascii_case(marker)) + { return true; } } @@ -66,27 +65,44 @@ fn may_require_redaction(text: &str) -> bool { fn entry_requires_redaction(entry: &LogEntry) -> bool { [ - entry.event, entry.protocol_error.unwrap_or(""), entry.reason.unwrap_or(""), - entry.error_detail.as_deref().unwrap_or(""), entry.error.as_deref().unwrap_or(""), - entry.local_addr.as_deref().unwrap_or(""), entry.tls_sni.as_deref().unwrap_or(""), - entry.sni.as_deref().unwrap_or(""), entry.authority.as_deref().unwrap_or(""), - entry.matched_by.unwrap_or(""), entry.vhost.as_deref().unwrap_or(""), - entry.route.as_deref().unwrap_or(""), entry.backend.as_deref().unwrap_or(""), - &entry.request_id, &entry.method, &entry.host, &entry.path, - entry.http_version, entry.transport, entry.websocket_bootstrap.unwrap_or(""), - entry.websocket_phase.unwrap_or(""), entry.websocket_error.unwrap_or(""), - entry.h3_reset_code.unwrap_or(""), entry.upstream_error.as_deref().unwrap_or(""), - entry.client_ip.as_deref().unwrap_or(""), entry.user_agent.as_deref().unwrap_or(""), - entry.country.as_deref().unwrap_or(""), entry.outcome, entry.cache, entry.cache_reason, - ].iter().any(|value| may_require_redaction(value)) + entry.event, + entry.protocol_error.unwrap_or(""), + entry.reason.unwrap_or(""), + entry.error_detail.as_deref().unwrap_or(""), + entry.error.as_deref().unwrap_or(""), + entry.local_addr.as_deref().unwrap_or(""), + entry.tls_sni.as_deref().unwrap_or(""), + entry.sni.as_deref().unwrap_or(""), + entry.authority.as_deref().unwrap_or(""), + entry.matched_by.unwrap_or(""), + entry.vhost.as_deref().unwrap_or(""), + entry.route.as_deref().unwrap_or(""), + entry.backend.as_deref().unwrap_or(""), + &entry.request_id, + &entry.method, + &entry.host, + &entry.path, + entry.http_version, + entry.transport, + entry.websocket_bootstrap.unwrap_or(""), + entry.websocket_phase.unwrap_or(""), + entry.websocket_error.unwrap_or(""), + entry.h3_reset_code.unwrap_or(""), + entry.upstream_error.as_deref().unwrap_or(""), + entry.client_ip.as_deref().unwrap_or(""), + entry.user_agent.as_deref().unwrap_or(""), + entry.country.as_deref().unwrap_or(""), + entry.outcome, + entry.cache, + entry.cache_reason, + ] + .iter() + .any(|value| may_require_redaction(value)) } -// Serialize into the existing batch buffer: no temporary Vec per record. -// The ordinary default format also skips the intermediate serde_json::Value -// tree and its many field allocations. Sensitive/custom records retain the -// exact previous formatting and recursive redaction behavior. fn append_jsonl(entry: &LogEntry, format: &JsonFormat, buffer: &mut Vec) { - if format.fields.is_empty() && format.static_fields.is_empty() + if format.fields.is_empty() + && format.static_fields.is_empty() && !entry_requires_redaction(entry) { serde_json::to_writer(&mut *buffer, entry).expect("LogEntry JSON serialization"); @@ -98,7 +114,6 @@ fn append_jsonl(entry: &LogEntry, format: &JsonFormat, buffer: &mut Vec) { buffer.push(b'\n'); } -// Bound both write size and event count; never wait for the batch to fill. const FILE_BATCH_EVENTS: u64 = 64; const FILE_BATCH_BYTES: usize = 128 * 1024; @@ -187,18 +202,20 @@ async fn sink_loop( } }; let config = settings.load_full(); - // The common local-file-only sink has no per-event network or routing - // dependency. Coalesce consecutive JSONL records into one bounded write. - // Other sink combinations retain their original per-event semantics. - if config.file.enabled && !config.file.per_vhost - && !config.logstash.enabled && !config.loki.enabled + if config.file.enabled + && !config.file.per_vhost + && !config.logstash.enabled + && !config.loki.enabled { vhost_files.clear(); vhost_directory = None; let path = PathBuf::from(&config.file.path); if file.as_ref().is_none_or(|state| state.path != path) { file = match open_access_file(path.clone()).await { - Ok(opened) => Some(FileState { path: path.clone(), file: opened }), + Ok(opened) => Some(FileState { + path: path.clone(), + file: opened, + }), Err(error) => { tracing::warn!(%error, path=%path.display(), "access log file unavailable"); None @@ -219,8 +236,6 @@ async fn sink_loop( next = Some(item); } Ok(item) => { - // A pending entry must be handled with the new snapshot; - // a batch never silently crosses a configuration change. deferred_entry = Some(item); break; } @@ -239,8 +254,6 @@ async fn sink_loop( } continue; } - // File/Logstash share one record; Loki-only needs a JSON object only - // when the HTTP request is actually built. let mut jsonl_line = Vec::new(); if config.file.enabled || config.logstash.enabled { append_jsonl(&entry, &config.format, &mut jsonl_line); @@ -353,10 +366,6 @@ async fn sink_loop( } else { config.loki.labels.clone() }; - // Loki needs the same redacted JSON event as the file/Logstash - // sinks, only without the JSONL terminator. Reuse the already - // serialized record when another sink is active; avoid a second - // full serde_json pass for every high-volume access event. let event_json = if jsonl_line.is_empty() { let mut payload = format_entry(&entry, &config.format); redact_value(&mut payload); diff --git a/src/logging/tests.rs b/src/logging/tests.rs index b32f488..dd6e690 100644 --- a/src/logging/tests.rs +++ b/src/logging/tests.rs @@ -211,16 +211,23 @@ mod tests { #[test] fn error_redaction_preserves_json_and_debug_escaping_without_leaking_secret_suffixes() { - for secret in ["pem-data", "left\"right", "left\\right", "ending\\", "left\\\"right", "first\nsecond"] { + for secret in [ + "pem-data", + "left\"right", + "left\\right", + "ending\\", + "left\\\"right", + "first\nsecond", + ] { let plain = serde_json::to_string(&serde_json::json!({ "private_key": secret, "visible": "keep" - })).unwrap(); + })) + .unwrap(); let redacted = redact_text(&plain); let value: Value = serde_json::from_str(&redacted).unwrap(); assert_eq!(value["private_key"], "[REDACTED]"); assert_eq!(value["visible"], "keep"); - // EventVisitor::record_debug uses this exact formatting operation. let debug = format!("{plain:?}"); let debug_redacted = redact_text(&debug); let decoded: String = serde_json::from_str(&debug_redacted).unwrap(); @@ -236,8 +243,15 @@ mod tests { #[test] fn error_redaction_removes_private_pem_and_preserves_public_diagnostics() { - for label in ["PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY", "ENCRYPTED PRIVATE KEY"] { - let input = format!("before -----BEGIN {label}-----\nsynthetic-secret\n-----END {label}----- after"); + for label in [ + "PRIVATE KEY", + "RSA PRIVATE KEY", + "EC PRIVATE KEY", + "ENCRYPTED PRIVATE KEY", + ] { + let input = format!( + "before -----BEGIN {label}-----\nsynthetic-secret\n-----END {label}----- after" + ); assert_eq!(redact_text(&input), "before [REDACTED PRIVATE KEY] after"); } let public = "-----BEGIN CERTIFICATE-----\npublic-test-data\n-----END CERTIFICATE-----"; @@ -281,7 +295,8 @@ mod tests { assert!(!controller.console_enabled_for(&Level::ERROR)); assert!(controller.console_enabled_for(&Level::INFO)); - let subscriber = tracing_subscriber::registry().with(ErrorLogLayer::new(controller.clone())); + let subscriber = + tracing_subscriber::registry().with(ErrorLogLayer::new(controller.clone())); tracing::subscriber::with_default(subscriber, || { tracing::warn!(target: "rust_proxy::tests", "PHP-FPM FastCGI stderr"); tracing::error!(target: "rust_proxy::tests", "RustProxy worker panicked"); @@ -347,9 +362,16 @@ mod tests { assert_eq!(retained.len(), HISTORY_CAPACITY); assert_eq!( retained.iter().map(|item| item.id).collect::>(), - (3..HISTORY_CAPACITY + 3).rev().map(|id| id as u64).collect::>() + (3..HISTORY_CAPACITY + 3) + .rev() + .map(|id| id as u64) + .collect::>() ); - for item in [&retained[0], &retained[HISTORY_CAPACITY / 2], retained.last().unwrap()] { + for item in [ + &retained[0], + &retained[HISTORY_CAPACITY / 2], + retained.last().unwrap(), + ] { assert_eq!( serde_json::to_value(item).unwrap(), serde_json::to_value(history_entry(item.id as usize)).unwrap() @@ -391,17 +413,24 @@ mod tests { producer.push(item); finished.send(()).unwrap(); }); - // Release the guard before joining even on failure, so an accidental - // blocking-lock regression fails this test instead of hanging the suite. let completed = completion.recv_timeout(Duration::from_secs(2)); drop(history); worker.join().unwrap(); completed.expect("history contention must not block live/sink delivery"); assert_eq!(logs.dropped.load(Ordering::Relaxed), 1); - assert_eq!(serde_json::to_value(logs.recent()).unwrap(), json!([sentinel])); - assert_eq!(serde_json::to_value(live.try_recv().unwrap()).unwrap(), expected); - assert!(matches!(live.try_recv(), Err(broadcast::error::TryRecvError::Empty))); + assert_eq!( + serde_json::to_value(logs.recent()).unwrap(), + json!([sentinel]) + ); + assert_eq!( + serde_json::to_value(live.try_recv().unwrap()).unwrap(), + expected + ); + assert!(matches!( + live.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); let persisted = tokio::time::timeout(Duration::from_secs(5), async { loop { @@ -412,10 +441,15 @@ mod tests { } tokio::time::sleep(Duration::from_millis(20)).await; } - }).await.expect("contended history entry must still reach the configured file sink"); + }) + .await + .expect("contended history entry must still reach the configured file sink"); shutdown.cancel(); assert_eq!(persisted.lines().count(), 1); - assert_eq!(serde_json::from_str::(persisted.trim_end()).unwrap(), expected); + assert_eq!( + serde_json::from_str::(persisted.trim_end()).unwrap(), + expected + ); let stats = logs.stats(); assert_eq!(stats.queue_capacity, SINK_QUEUE); assert_eq!(stats.queue_dropped, 0); @@ -441,14 +475,15 @@ mod tests { let item = entry(); let expected = serde_json::to_value(&item).unwrap(); logs.push(item); - assert_eq!(serde_json::to_value(live.try_recv().unwrap()).unwrap(), expected); + assert_eq!( + serde_json::to_value(live.try_recv().unwrap()).unwrap(), + expected + ); assert_eq!(serde_json::to_value(&logs.recent()[0]).unwrap(), expected); } #[test] fn enabled_external_sink_retains_existing_queue_drop_accounting() { - // The default test Logs has a closed sink receiver. Delivering to a - // configured external sink must still account for its failure. let logs = Logs::default(); let mut settings = Settings::default(); settings.file.enabled = true; @@ -461,9 +496,15 @@ mod tests { #[test] fn redaction_leaves_ordinary_strings_borrowed_and_preserves_secret_matching() { use std::borrow::Cow; - assert!(matches!(redact_text_cow("GET /index.html"), Cow::Borrowed(_))); + assert!(matches!( + redact_text_cow("GET /index.html"), + Cow::Borrowed(_) + )); assert!(matches!(redact_text_cow("hello € world"), Cow::Borrowed(_))); - assert!(matches!(redact_text_cow("?api_token=topsecret"), Cow::Owned(_))); + assert!(matches!( + redact_text_cow("?api_token=topsecret"), + Cow::Owned(_) + )); let mut value = json!({ "ToKeN": "secret-value", "ordinary": "hello € world", @@ -484,9 +525,18 @@ mod tests { let mut settings = Settings::default(); settings.file.enabled = true; settings.file.path = path.to_string_lossy().into_owned(); - settings.format.fields.insert("request_number".into(), "id".into()); - settings.format.fields.insert("request_path".into(), "path".into()); - settings.format.static_fields.insert("service".into(), "rustproxy".into()); + settings + .format + .fields + .insert("request_number".into(), "id".into()); + settings + .format + .fields + .insert("request_path".into(), "path".into()); + settings + .format + .static_fields + .insert("service".into(), "rustproxy".into()); let stop = CancellationToken::new(); let logs = Logs::start(settings, stop.clone()); const COUNT: usize = 180; @@ -507,7 +557,9 @@ mod tests { } tokio::time::sleep(Duration::from_millis(10)).await; } - }).await.expect("all accepted local file records must be written"); + }) + .await + .expect("all accepted local file records must be written"); stop.cancel(); assert_eq!(logs.stats().queue_dropped, 0); assert_eq!(logs.stats().errors, 0); @@ -515,7 +567,10 @@ mod tests { for (id, line) in lines.lines().enumerate() { let record: Value = serde_json::from_str(line).unwrap(); assert_eq!(record["request_number"], id as u64); - assert_eq!(record["request_path"], format!("/test/{id}?token=[REDACTED]")); + assert_eq!( + record["request_path"], + format!("/test/{id}?token=[REDACTED]") + ); assert_eq!(record["service"], "rustproxy"); assert_eq!(record.as_object().unwrap().len(), 3); } @@ -539,7 +594,9 @@ mod tests { mapped.fields.insert("http_status".into(), "status".into()); mapped.static_fields.insert("service".into(), "edge".into()); let mut nested = JsonFormat::default(); - nested.static_fields.insert("token".into(), "secret-value".into()); + nested + .static_fields + .insert("token".into(), "secret-value".into()); let defaults = JsonFormat::default(); for (record, format) in [ (&clean, &defaults), @@ -577,16 +634,20 @@ mod tests { assert!(matches!(history.back(), Some(HistoryEntry::Shared(_)))); assert_eq!(history.back().unwrap().entry().id, sample.id); } - assert_eq!(serde_json::to_value(logs.recent()).unwrap(), json!([sample])); + assert_eq!( + serde_json::to_value(logs.recent()).unwrap(), + json!([sample]) + ); tokio::time::timeout(Duration::from_secs(10), async { while logs.stats().sent == 0 { tokio::time::sleep(Duration::from_millis(5)).await; } - }).await.unwrap(); + }) + .await + .unwrap(); shutdown.cancel(); assert_eq!(logs.stats().queue_dropped, 0); let jsonl = tokio::fs::read_to_string(path).await.unwrap(); assert_eq!(jsonl.lines().count(), 1); } - } diff --git a/src/main.rs b/src/main.rs index 5447746..2cbbbba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,4 +2,4 @@ include!("main/bootstrap.rs"); include!("main/admin_runtime.rs"); include!("main/proxy_runtime.rs"); include!("main/tasks.rs"); -include!("main/shutdown.rs"); +include!("main/shutdown/mod.rs"); diff --git a/src/main/admin_runtime.rs b/src/main/admin_runtime.rs index 3f8d19f..9a3647d 100644 --- a/src/main/admin_runtime.rs +++ b/src/main/admin_runtime.rs @@ -68,7 +68,6 @@ async fn run( listeners: initial_listeners, ..Config::default() }; - // Only a NEW database receives this neutral landing page. config.pages.direct_ip = Some(Default::default()); config.pages.unknown_host = Some(Default::default()); config @@ -76,14 +75,24 @@ async fn run( let store = Store::open(&data_dir.join("config.sqlite3"), &initial).await?; let mut saved = store.load().await?; let external = if let Some(path) = file_config { - Some((path.clone(), rust_proxy::runtime::external_config::read(&path)?)) - } else { None }; + Some(( + path.clone(), + rust_proxy::runtime::external_config::read(&path)?, + )) + } else { + None + }; let saved_revision = saved.revision; let stored_document = serde_json::to_value(&saved.config)?; - if let Some((_, (config, _))) = &external { saved.config = config.clone(); } + if let Some((_, (config, _))) = &external { + saved.config = config.clone(); + } startup_locks.ensure_config(&mut saved.config); - let changed_on_disk = external.is_some() && serde_json::to_value(&saved.config)? != stored_document; - if changed_on_disk { saved.revision = saved.revision.checked_add(1).context("revision overflow")?; } + let changed_on_disk = + external.is_some() && serde_json::to_value(&saved.config)? != stored_document; + if changed_on_disk { + saved.revision = saved.revision.checked_add(1).context("revision overflow")?; + } startup_locks .validate(&saved.config, startup_admin_addr) .context("stored configuration conflicts with a listener fixed by systemd/startup")?; @@ -110,8 +119,6 @@ async fn run( data_dir: Some(data_dir.clone()), }; saved.config.listeners.validate(&resolved_policy)?; - // The certificate library is available even on an HTTP-only installation. - // Initializing private files does not bind or expose a TLS listener. let directory = data_dir.join("certs"); let initialize = directory.clone(); tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??; @@ -139,8 +146,12 @@ async fn run( .await? .context("stored configuration is invalid; no automatic reset was performed")?; if changed_on_disk { - ensure!(store.replace_as(saved_revision, &snapshot.envelope.config, "file-startup").await?, - "configuration history changed while loading --config"); + ensure!( + store + .replace_as(saved_revision, &snapshot.envelope.config, "file-startup") + .await?, + "configuration history changed while loading --config" + ); } rust_proxy::logging::configure_error_log(snapshot.envelope.config.error_log.clone()); let admin_tls_config = { @@ -159,7 +170,10 @@ async fn run( } let renewal_observations = store.load_certificate_renewals().await?; let geoip_format = snapshot.envelope.config.geoip_updates.format; - let geoip = match rust_proxy::geoip::Database::load_format(&data_dir.join("geoip"), geoip_format) { + let geoip = match rust_proxy::geoip::Database::load_format( + &data_dir.join("geoip"), + geoip_format, + ) { Ok(database) => std::sync::Arc::new(database), Err(error) => { tracing::warn!(%error, "GeoIP country database unavailable; country allow lists fail closed"); @@ -179,11 +193,13 @@ async fn run( startup_locks, geoip, ); - if let Some((path, (_, hash))) = external { state.set_config_file(path, hash).await; } - state.prometheus_token_hash.store(store.prometheus_token_hash().await?.map(Arc::new)); + if let Some((path, (_, hash))) = external { + state.set_config_file(path, hash).await; + } + state + .prometheus_token_hash + .store(store.prometheus_token_hash().await?.map(Arc::new)); state.acme.restore_renewals(renewal_observations); - // Bind EVERY listener before spawning servers: a missing private IP or - // occupied port fails startup atomically, never falling back to 0.0.0.0. let mut public_listeners = Vec::new(); for binding in &state.active_listeners { if options.demo { @@ -269,9 +285,6 @@ async fn run( } else { None }; - // Release checks are control-plane best effort only. Start them after every - // socket has bound successfully so DNS/network failures can never obscure or - // interfere with listener startup failures. state.start_geoip_updates(); state.start_dns_acl_updates(); state.start_update_check(update_check); @@ -448,7 +461,10 @@ async fn run( grace }; if managed_update { - tracing::info!(budget_seconds = cache_flush_budget.as_secs(), "managed-update hot-to-cold cache handoff started"); + tracing::info!( + budget_seconds = cache_flush_budget.as_secs(), + "managed-update hot-to-cold cache handoff started" + ); } let flush_result = if managed_update { tokio::time::timeout( @@ -460,10 +476,9 @@ async fn run( tokio::time::timeout(cache_flush_budget, state.cache.flush_hot_to_cold()).await }; match flush_result { - Ok(moved) if managed_update => tracing::info!( - moved, - "managed-update hot-to-cold cache handoff completed" - ), + Ok(moved) if managed_update => { + tracing::info!(moved, "managed-update hot-to-cold cache handoff completed") + } Ok(moved) if moved > 0 => tracing::info!( moved, "flushed hot cache objects to cold storage before shutdown" diff --git a/src/main/bootstrap.rs b/src/main/bootstrap.rs index e7b9230..9565ae5 100644 --- a/src/main/bootstrap.rs +++ b/src/main/bootstrap.rs @@ -51,7 +51,6 @@ struct Cli { default_value = "/var/lib/rustproxy" )] data_dir: PathBuf, - /// Use an existing JSON file as canonical desired configuration. #[arg(long, global = true, env = "RUST_PROXY_CONFIG")] config: Option, #[arg( @@ -65,13 +64,10 @@ struct Cli { update_repository_url: Option, #[arg(long, global = true, env = "RUST_PROXY_UPDATE_VERSION_URL")] update_version_url: Option, - /// Seconds between release checks. Set to 0 to disable automatic checks. #[arg(long, global = true, env = "RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS")] update_check_interval_secs: Option, - /// Deprecated compatibility no-op kept so older systemd units continue to start. #[arg(long = "accept-remote-admin-risk", global = true, hide = true)] _accept_remote_admin_risk: bool, - /// Interactive varnishlog-style live request view from all vhosts. #[arg(long, global = true)] livelogs: bool, #[command(subcommand)] @@ -79,28 +75,21 @@ struct Cli { } #[derive(Subcommand)] enum Command { - /// Start proxy and local administration listener. Serve(ServeOptions), - /// Soft reload configuration and certificate files in the running service. Reload, - /// Compatibility alias for `admin setup-token`; never an API access token. Token, - /// Local administrator setup, password recovery and session management. Admin { #[command(subcommand)] action: AdminCommand, }, - /// Manage local certificate files and hot-reload certificate bindings. Tls { #[command(subcommand)] action: TlsCommand, }, - /// Read or replace configuration through the running admin API. Config { #[command(subcommand)] action: ConfigCommand, }, - /// Create or restore a password-encrypted full application backup. Backup { #[command(subcommand)] action: BackupCommand, @@ -108,58 +97,46 @@ enum Command { } #[derive(Subcommand)] enum AdminCommand { - /// Create the sole administrator. Requires ownership of the private data directory. Init { #[arg(long)] username: Option, #[arg(long)] password_stdin: bool, }, - /// Replace the administrator password and revoke all sessions, online or offline. ResetPassword { #[arg(long)] password_stdin: bool, }, - /// Generate or print the one-time setup capability, only before an administrator exists. SetupToken, - /// Report whether administrator setup is complete (no credentials printed). Status, - /// Revoke all browser/CLI sessions without changing the password. LogoutAll, } #[derive(Subcommand)] enum ConfigCommand { - /// Print desired configuration as JSON (suitable for `config apply`). Get, - /// Revalidate and hot-publish the current configuration and certificate files. Reload, - /// Atomically validate, save and apply a full configuration document. Apply { file: PathBuf }, - /// Validate JSON, ACLs, headers and backend policy without changing the database. Validate { file: PathBuf }, } #[derive(Subcommand)] enum BackupCommand { - /// Create an encrypted full backup. Safe while RustProxy is serving. Create { - /// Destination .rpb file. Existing files are never overwritten. #[arg(long)] output: Option, }, - /// Restore an encrypted full backup atomically. Stop the RustProxy service first. - Restore { archive: PathBuf }, + Restore { + archive: PathBuf, + }, } #[derive(Subcommand)] enum TlsCommand { - /// Generate a self-signed development certificate. Existing files are never overwritten. Generate { #[arg(long)] name: String, #[arg(long, value_delimiter = ',', required = true)] hosts: Vec, }, - /// Import a matching PEM certificate/key pair under a new name. Import { #[arg(long)] name: String, @@ -168,8 +145,6 @@ enum TlsCommand { #[arg(long)] key: PathBuf, }, - /// Stage an externally issued pair (for example Certbot) and atomically - /// switch an existing manual certificate id to it through the running API. Deploy { #[arg(long)] id: String, @@ -178,12 +153,10 @@ enum TlsCommand { #[arg(long)] key: PathBuf, }, - /// Reload configured certificate files without restarting listeners. Reload, } #[derive(Args)] struct ServeOptions { - /// Optional HTTPS listener. dev.sh enables 127.0.0.1:8443 unless RUST_PROXY_TLS=0. #[arg(long, env = "RUST_PROXY_TLS_LISTEN")] tls_listen: Option, #[arg( @@ -200,25 +173,19 @@ struct ServeOptions { max_connections: usize, #[arg(long, default_value_t = 256)] max_inflight: usize, - /// Includes upload time until upstream response headers arrive. #[arg(long, default_value_t = 120)] upstream_timeout_secs: u64, #[arg(long, default_value_t = 60)] body_idle_timeout_secs: u64, - /// Inactivity in either direction; independent of ordinary HTTP bodies. #[arg(long, default_value_t = 300)] websocket_idle_timeout_secs: u64, #[arg(long, default_value_t = 10)] shutdown_timeout_secs: u64, - /// Permit an administration listener on an unspecified address such as 0.0.0.0. #[arg(long)] allow_remote_admin: bool, } #[cfg(target_os = "linux")] fn disable_transparent_huge_pages() { - // Keep THP policy local to RustProxy. Large 2 MiB anonymous pages amplify - // allocator fragmentation/retention for the long-running proxy workload, so - // opt this process out before Tokio creates worker threads and their heaps. let result = unsafe { libc::prctl(libc::PR_SET_THP_DISABLE, 1, 0, 0, 0) }; if result != 0 { eprintln!( @@ -237,8 +204,6 @@ fn main() -> Result<()> { .enable_all() .build()?; let result = runtime.block_on(application()); - // OS getaddrinfo workers cannot be killed by cancelling their future. - // Bound runtime shutdown after listeners and application tasks have drained. runtime.shutdown_timeout(Duration::from_secs(2)); result } @@ -293,9 +258,6 @@ async fn application() -> Result<()> { ) .with(rust_proxy::logging::ErrorLogLayer::new(error_log)) .init(); - // Rust's default panic hook writes directly to stderr, bypassing tracing - // and the configured error file. Route worker panics through the same - // WARN/ERROR sink as other diagnostics without duplicate journald output. std::panic::set_hook(Box::new(|info| { let payload = if let Some(value) = info.payload().downcast_ref::<&str>() { (*value).to_owned() @@ -355,7 +317,12 @@ async fn application() -> Result<()> { .context("a subcommand is required (or use --livelogs)")?; match command { Command::Reload => { - config_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), cli.admin_listen, ConfigCommand::Reload).await + config_command( + resolve_installed_data_dir(cli.data_dir, data_dir_explicit), + cli.admin_listen, + ConfigCommand::Reload, + ) + .await } Command::Token => { admin_command( @@ -400,8 +367,6 @@ async fn application() -> Result<()> { cli.update_version_url, cli.update_check_interval_secs, )?; - // Server startup never guesses a data directory. Package/service/plugin - // installation must pass its configured path explicitly. run( cli.data_dir, cli.admin_listen, diff --git a/src/main/shutdown.rs b/src/main/shutdown/mod.rs similarity index 97% rename from src/main/shutdown.rs rename to src/main/shutdown/mod.rs index 0efe799..713e06d 100644 --- a/src/main/shutdown.rs +++ b/src/main/shutdown/mod.rs @@ -1,7 +1,5 @@ async fn read_external_pem(file: PathBuf, private: bool) -> Result>> { tokio::task::spawn_blocking(move || { - // Certbot's live/*.pem files are symlinks by design. Resolve them once, - // then open the final regular file without following another symlink. let canonical = std::fs::canonicalize(&file) .with_context(|| format!("cannot resolve external PEM {}", file.display()))?; let mut options = std::fs::OpenOptions::new(); @@ -79,7 +77,6 @@ async fn config_command(dir: PathBuf, addr: SocketAddr, action: ConfigCommand) - let admin = LocalAdminClient::connect(&dir, addr).await?; let result = async { let record = store.admin_record().await?.context("create the administrator first")?; - // OS file ownership is the local recovery/management authority, not a permanent API token. let session = store.create_session(record.generation, "cli", None).await?.context("credentials changed; retry CLI operation")?; let result = async { let current: Envelope = serde_json::from_str(&api(&admin, &session, Method::GET, "/api/config", None).await?)?; @@ -235,4 +232,4 @@ async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Resu .await? } #[cfg(test)] -include!("shutdown/tests.rs"); +include!("tests.rs"); diff --git a/src/main/tasks.rs b/src/main/tasks.rs index 2e8bfe9..50d02ce 100644 --- a/src/main/tasks.rs +++ b/src/main/tasks.rs @@ -264,10 +264,6 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> { Ok(Err(error)) => { eprintln!("live-log stream unavailable: {error}; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; } Err(_) => { eprintln!("live-log stream connect timeout; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; } }; - // The server subscribes before the SSE response is returned. Read the - // retained history only after that point, so requests cannot disappear - // between the initial snapshot and the live subscription. Buffered live - // duplicates are ignored by request id below. let history_limit = if last_id == 0 { 25 } else { 1024 }; if let Ok(snapshot) = api( &admin, @@ -404,7 +400,6 @@ async fn read_config_file(file: PathBuf) -> Result { tokio::fs::metadata(&file).await?.len() <= config::MAX_CONFIG_BYTES as u64, "config file exceeds 256 KiB" ); - // Bound actual reads as well as metadata; replacement/append races cannot grow memory. let data = tokio::task::spawn_blocking(move || { let file = std::fs::File::open(file)?; let mut bytes = Vec::new(); diff --git a/src/metrics.rs b/src/metrics.rs index 76c11cb..84e59c4 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,5 +1,3 @@ -//! Aggregate metrics: no attacker-controlled labels or unbounded series. - include!("metrics/global.rs"); include!("metrics/rate_sampling.rs"); -include!("metrics/vhost.rs"); +include!("metrics/vhost/mod.rs"); diff --git a/src/metrics/global.rs b/src/metrics/global.rs index 7c81ae2..3e5b551 100644 --- a/src/metrics/global.rs +++ b/src/metrics/global.rs @@ -411,8 +411,6 @@ impl Metrics { } } -/// Fixed-cardinality per-vhost counters. Retained across config updates by ID, -/// released when a vhost is removed, and finalized after complete body lifetime. pub const DURATION_BUCKETS_US: [u64; 10] = [ 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000, 2_500_000, 5_000_000, ]; diff --git a/src/metrics/rate_sampling.rs b/src/metrics/rate_sampling.rs index 7e2a004..36b60e0 100644 --- a/src/metrics/rate_sampling.rs +++ b/src/metrics/rate_sampling.rs @@ -1,5 +1,3 @@ -// Bounded pull-driven rate sampling for the JSON summary. No per-client state, -// background task, or unbounded history. A cached sample is shared by readers. use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -34,8 +32,6 @@ impl RateSampler { let mut state = self.0.lock().unwrap_or_else(|error| error.into_inner()); if let Some((previous_at, previous)) = state.baseline { let elapsed = now.saturating_duration_since(previous_at); - // Do not turn rapid parallel polls into zero/spiky rates. Reuse - // the last measured rate until a useful sampling interval passes. if elapsed < Duration::from_secs(1) { return state.latest; } @@ -47,14 +43,19 @@ impl RateSampler { if continuous { let seconds = elapsed.as_secs_f64(); state.latest = SummaryRates { - requests_per_second: Some((counters.requests - previous.requests) as f64 / seconds), + requests_per_second: Some( + (counters.requests - previous.requests) as f64 / seconds, + ), errors_per_second: Some((counters.errors - previous.errors) as f64 / seconds), - bytes_in_per_second: Some((counters.bytes_in - previous.bytes_in) as f64 / seconds), - bytes_out_per_second: Some((counters.bytes_out - previous.bytes_out) as f64 / seconds), + bytes_in_per_second: Some( + (counters.bytes_in - previous.bytes_in) as f64 / seconds, + ), + bytes_out_per_second: Some( + (counters.bytes_out - previous.bytes_out) as f64 / seconds, + ), sample_interval_seconds: Some(seconds), }; } else { - // Long polling gaps or counter resets invalidate the baseline. state.latest = SummaryRates::default(); } } @@ -65,12 +66,15 @@ impl RateSampler { impl Metrics { pub fn summary_rates(&self) -> SummaryRates { - self.summary_rate_sampler.sample(SummaryCounters { - requests: self.requests.load(Ordering::Relaxed), - errors: self.errors.load(Ordering::Relaxed), - bytes_in: self.bytes_in.load(Ordering::Relaxed), - bytes_out: self.bytes_out.load(Ordering::Relaxed), - }, Instant::now()) + self.summary_rate_sampler.sample( + SummaryCounters { + requests: self.requests.load(Ordering::Relaxed), + errors: self.errors.load(Ordering::Relaxed), + bytes_in: self.bytes_in.load(Ordering::Relaxed), + bytes_out: self.bytes_out.load(Ordering::Relaxed), + }, + Instant::now(), + ) } } @@ -83,18 +87,40 @@ mod summary_rate_tests { let sampler = RateSampler::default(); let at = Instant::now(); let counts = |requests, errors, bytes_in, bytes_out| SummaryCounters { - requests, errors, bytes_in, bytes_out + requests, + errors, + bytes_in, + bytes_out, }; - assert!(sampler.sample(counts(10, 2, 100, 200), at).requests_per_second.is_none()); + assert!(sampler + .sample(counts(10, 2, 100, 200), at) + .requests_per_second + .is_none()); let rate = sampler.sample(counts(20, 4, 300, 500), at + Duration::from_secs(2)); assert_eq!(rate.requests_per_second, Some(5.0)); assert_eq!(rate.errors_per_second, Some(1.0)); assert_eq!(rate.bytes_in_per_second, Some(100.0)); assert_eq!(rate.bytes_out_per_second, Some(150.0)); assert_eq!(rate.sample_interval_seconds, Some(2.0)); - assert_eq!(sampler.sample(counts(21, 4, 310, 520), at + Duration::from_millis(2300)).requests_per_second, Some(5.0)); - assert!(sampler.sample(counts(22, 4, 330, 550), at + Duration::from_secs(40)).requests_per_second.is_none()); - assert!(sampler.sample(counts(1, 0, 0, 0), at + Duration::from_secs(42)).requests_per_second.is_none()); - assert_eq!(sampler.sample(counts(5, 0, 200, 300), at + Duration::from_secs(44)).requests_per_second, Some(2.0)); + assert_eq!( + sampler + .sample(counts(21, 4, 310, 520), at + Duration::from_millis(2300)) + .requests_per_second, + Some(5.0) + ); + assert!(sampler + .sample(counts(22, 4, 330, 550), at + Duration::from_secs(40)) + .requests_per_second + .is_none()); + assert!(sampler + .sample(counts(1, 0, 0, 0), at + Duration::from_secs(42)) + .requests_per_second + .is_none()); + assert_eq!( + sampler + .sample(counts(5, 0, 200, 300), at + Duration::from_secs(44)) + .requests_per_second, + Some(2.0) + ); } } diff --git a/src/metrics/vhost.rs b/src/metrics/vhost/mod.rs similarity index 88% rename from src/metrics/vhost.rs rename to src/metrics/vhost/mod.rs index 27cde63..51e7493 100644 --- a/src/metrics/vhost.rs +++ b/src/metrics/vhost/mod.rs @@ -62,15 +62,10 @@ impl VhostMetrics { Err(observed) => current = observed, } } - // Write exactly one exclusive histogram bin per exchange. Prometheus - // cumulative le=... buckets are reconstructed only when scraped. - // This removes up to 11 contended atomics for short 1 KiB responses. let bucket = DURATION_BUCKETS_US.partition_point(|threshold| micros > *threshold); self.buckets[bucket].fetch_add(1, Ordering::Relaxed); } - /// A scrape-consistent cumulative representation of exclusive bins. The - /// sum of atomic snapshots is monotonic and the final count includes +Inf. pub fn duration_histogram(&self) -> ([u64; DURATION_BUCKETS_US.len()], u64) { let mut cumulative = [0u64; DURATION_BUCKETS_US.len()]; let mut count = 0u64; @@ -104,4 +99,4 @@ impl VhostMetrics { } } #[cfg(test)] -include!("vhost/tests.rs"); +include!("tests.rs"); diff --git a/src/metrics/vhost/tests.rs b/src/metrics/vhost/tests.rs index cd059f4..a9473b2 100644 --- a/src/metrics/vhost/tests.rs +++ b/src/metrics/vhost/tests.rs @@ -5,7 +5,13 @@ mod vhost_tests { let m = VhostMetrics::default(); m.active.store(5, Ordering::Relaxed); for micros in [1, 5_000, 5_001, 25_000, 5_000_001] { - m.complete(200, false, std::time::Duration::from_micros(micros), 0, 1040); + m.complete( + 200, + false, + std::time::Duration::from_micros(micros), + 0, + 1040, + ); } let (cumulative, total) = m.duration_histogram(); assert_eq!(total, 5); @@ -28,7 +34,7 @@ mod vhost_tests { assert_eq!(n(&m.status[4]), 1); assert_eq!(n(&m.buckets[1]), 0); assert_eq!(n(&m.buckets[2]), 1); - assert_eq!(n(&m.buckets[10]), 0); // exclusive +Inf bin + assert_eq!(n(&m.buckets[10]), 0); let (buckets, count) = m.duration_histogram(); assert_eq!(buckets[1], 0); assert_eq!(buckets[2], 1); diff --git a/src/not_found.rs b/src/not_found/mod.rs similarity index 86% rename from src/not_found.rs rename to src/not_found/mod.rs index f5a7e6f..3b4a6f2 100644 --- a/src/not_found.rs +++ b/src/not_found/mod.rs @@ -1,12 +1,12 @@ -//! Listener-aware host-owned local responses for domains that intentionally do -//! not proxy to an upstream. These hosts reserve their frontend names exactly -//! like vhosts/redirects and can serve a built-in placeholder, empty status, or -//! administrator-provided bounded HTML. use anyhow::{ensure, Result}; use serde::{Deserialize, Serialize}; -fn yes() -> bool { true } -fn default_status() -> u16 { 404 } +fn yes() -> bool { + true +} +fn default_status() -> u16 { + 404 +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -43,7 +43,9 @@ impl Host { } self.name = self.name.trim().to_owned(); ensure!( - !self.name.is_empty() && self.name.len() <= 96 && !self.name.chars().any(char::is_control), + !self.name.is_empty() + && self.name.len() <= 96 + && !self.name.chars().any(char::is_control), "404 host name must be 1..96 bytes without controls" ); ensure!( @@ -99,4 +101,4 @@ pub fn compile(host: &Host, listeners: &crate::settings::Listeners) -> Result Option<&'static str> { match status { - StatusCode::BAD_REQUEST => Some(include_str!("admin/web/error_400.html")), - StatusCode::UNAUTHORIZED => Some(include_str!("admin/web/error_401.html")), - StatusCode::FORBIDDEN => Some(include_str!("admin/web/error_403.html")), - StatusCode::NOT_FOUND => Some(include_str!("admin/web/error_404.html")), - StatusCode::REQUEST_TIMEOUT => Some(include_str!("admin/web/error_408.html")), - StatusCode::GONE => Some(include_str!("admin/web/error_410.html")), - StatusCode::PAYLOAD_TOO_LARGE => Some(include_str!("admin/web/error_413.html")), - StatusCode::TOO_MANY_REQUESTS => Some(include_str!("admin/web/error_429.html")), - StatusCode::INTERNAL_SERVER_ERROR => Some(include_str!("admin/web/error_500.html")), - StatusCode::BAD_GATEWAY => Some(include_str!("admin/web/error_502.html")), - StatusCode::SERVICE_UNAVAILABLE => Some(include_str!("admin/web/error_503.html")), - StatusCode::GATEWAY_TIMEOUT => Some(include_str!("admin/web/error_504.html")), + StatusCode::BAD_REQUEST => Some(include_str!("../admin/web/error_400.html")), + StatusCode::UNAUTHORIZED => Some(include_str!("../admin/web/error_401.html")), + StatusCode::FORBIDDEN => Some(include_str!("../admin/web/error_403.html")), + StatusCode::NOT_FOUND => Some(include_str!("../admin/web/error_404.html")), + StatusCode::REQUEST_TIMEOUT => Some(include_str!("../admin/web/error_408.html")), + StatusCode::GONE => Some(include_str!("../admin/web/error_410.html")), + StatusCode::PAYLOAD_TOO_LARGE => Some(include_str!("../admin/web/error_413.html")), + StatusCode::TOO_MANY_REQUESTS => Some(include_str!("../admin/web/error_429.html")), + StatusCode::INTERNAL_SERVER_ERROR => Some(include_str!("../admin/web/error_500.html")), + StatusCode::BAD_GATEWAY => Some(include_str!("../admin/web/error_502.html")), + StatusCode::SERVICE_UNAVAILABLE => Some(include_str!("../admin/web/error_503.html")), + StatusCode::GATEWAY_TIMEOUT => Some(include_str!("../admin/web/error_504.html")), _ => None, } } pub(crate) fn builtin_application_page(status: StatusCode) -> String { if status == StatusCode::OK { - return include_str!("admin/web/status_200.html").to_owned(); + return include_str!("../admin/web/status_200.html").to_owned(); } if let Some(page) = builtin_gateway_error(status) { return page.to_owned(); } - // A deliberately generic status still uses the *same* visual language - // as the packaged pages, rather than the former bright bare 200/4xx page. - // Code and canonical reason originate from http::StatusCode, never a request. let reason = status.canonical_reason().unwrap_or("Response"); format!( r#" @@ -80,7 +72,6 @@ pub enum Kind { #[serde(default, deny_unknown_fields)] pub struct Page { pub kind: Kind, - /// None retains an error's original status (or 200 for a static vhost). pub status: Option, pub html: String, } @@ -94,27 +85,16 @@ pub struct GlobalPages { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct VhostPages { - /// Optional status codes whose response body from an application origin is - /// replaced by the vhost/global error page (or RustProxy built-in page). - /// An empty set preserves normal application error bodies. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub intercept_statuses: Vec, - /// A static/maintenance vhost response; no upstream request is made. pub response: Option, - /// Only when the hostname is bound here but no path route matches. pub fallback: Option, pub errors: BTreeMap, } #[derive(Clone)] pub struct PageServed; -/// A completed application-origin response, from a reverse-proxy backend or -/// the built-in Web Server (including PHP-FPM and cache hits). A global error -/// page must not overwrite it when origin-error interception is disabled. #[derive(Clone, Copy)] pub struct OriginProvidedResponse; -/// Marker for the built-in no-application placeholder. The outer proxy layer -/// keeps this response deliberately minimal: no cache storage and no optional -/// server/diagnostic/security/compression decoration. #[derive(Clone)] pub struct MinimalWelcome; @@ -180,7 +160,7 @@ impl Page { match self.kind { Kind::Empty => String::new(), Kind::Html => self.html.clone(), - Kind::Welcome => include_str!("admin/web/demo.html").to_owned(), + Kind::Welcome => include_str!("../admin/web/demo.html").to_owned(), Kind::Builtin => builtin_application_page(status), } } @@ -215,9 +195,6 @@ impl Page { headers.insert("content-length", value); } } - // The built-in placeholder is intentionally lean. `no-store` prevents - // intermediary/browser caching; optional proxy headers are skipped later - // by the response observer. Custom HTML/empty pages retain nosniff. headers.insert("cache-control", HeaderValue::from_static("no-store")); if matches!(self.kind, Kind::Welcome) { response.extensions_mut().insert(MinimalWelcome); @@ -288,8 +265,6 @@ impl VhostPages { Ok(()) } } -/// Replace only the representation. Preserve authentication challenges and other -/// protocol headers; remove old representation metadata and untrusted cookies. pub fn replace_error(response: &mut Response, page: &Page) { if response.extensions().get::().is_some() { return; @@ -307,11 +282,6 @@ pub fn replace_error(response: &mut Response, page: &Page) { } *response = replacement; } -/// Replaces only opted-in application-origin errors, before the original -/// representation can be cached or sent. Local routing/security errors and -/// responses from internal error-fallback locations are not passed here. -/// The origin status is always preserved, independently of the selected -/// page's status override (which applies to locally generated pages only). pub fn intercept_application_error( mut response: Response, vhost_pages: &VhostPages, @@ -355,4 +325,4 @@ pub fn literal_ip_authority(value: &str) -> bool { } #[cfg(test)] -include!("pages/tests.rs"); +include!("tests.rs"); diff --git a/src/pages/tests.rs b/src/pages/tests.rs index 673c304..08757ed 100644 --- a/src/pages/tests.rs +++ b/src/pages/tests.rs @@ -82,7 +82,10 @@ mod tests { (StatusCode::REQUEST_TIMEOUT, "408 - Request timeout"), (StatusCode::GONE, "410 - Gone"), (StatusCode::PAYLOAD_TOO_LARGE, "413 - Payload too large"), - (StatusCode::INTERNAL_SERVER_ERROR, "500 - Internal server error"), + ( + StatusCode::INTERNAL_SERVER_ERROR, + "500 - Internal server error", + ), (StatusCode::BAD_GATEWAY, "502 - Bad gateway"), ] { let page = builtin_gateway_error(code).unwrap(); @@ -181,18 +184,27 @@ mod tests { let mk_origin = || { let mut response = Response::new(Body::from("secret application error")); *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert("content-type", HeaderValue::from_static("text/html")); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("text/html")); response }; let defaults: VhostPages = serde_json::from_str("{}").unwrap(); assert!(defaults.intercept_statuses.is_empty()); - let unchanged = intercept_application_error(mk_origin(), &defaults, &GlobalPages::default()); + let unchanged = + intercept_application_error(mk_origin(), &defaults, &GlobalPages::default()); assert_eq!(unchanged.status(), StatusCode::NOT_FOUND); - assert_eq!(unchanged.into_body().collect().await.unwrap().to_bytes(), - "secret application error"); - let configured = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() }; + assert_eq!( + unchanged.into_body().collect().await.unwrap().to_bytes(), + "secret application error" + ); + let configured = VhostPages { + intercept_statuses: vec![404], + ..VhostPages::default() + }; configured.validate().unwrap(); - let replaced = intercept_application_error(mk_origin(), &configured, &GlobalPages::default()); + let replaced = + intercept_application_error(mk_origin(), &configured, &GlobalPages::default()); assert_eq!(replaced.status(), StatusCode::NOT_FOUND); assert_eq!(replaced.headers().get("cache-control").unwrap(), "no-store"); assert!(replaced.extensions().get::().is_some()); @@ -203,60 +215,126 @@ mod tests { #[tokio::test] async fn interception_replaces_only_the_body_even_when_page_has_a_status_override() { - let mut vhost = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() }; - vhost.errors.insert(404, Page { - kind: Kind::Html, - // Overrides are valid for standalone error pages, not an origin - // response whose HTTP status must remain 404. - status: Some(503), - html: "custom 404".into(), - }); + let mut vhost = VhostPages { + intercept_statuses: vec![404], + ..VhostPages::default() + }; + vhost.errors.insert( + 404, + Page { + kind: Kind::Html, + status: Some(503), + html: "custom 404".into(), + }, + ); let mut origin = Response::new(Body::from("application 404")); *origin.status_mut() = StatusCode::NOT_FOUND; let intercepted = intercept_application_error(origin, &vhost, &GlobalPages::default()); assert_eq!(intercepted.status(), StatusCode::NOT_FOUND); - assert_eq!(intercepted.headers().get("cache-control").unwrap(), "no-store"); - assert_eq!(intercepted.into_body().collect().await.unwrap().to_bytes(), "custom 404"); - // Outside interception the configured status override still applies. - assert_eq!(vhost.errors.get(&404).unwrap().render(StatusCode::NOT_FOUND).status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + intercepted.headers().get("cache-control").unwrap(), + "no-store" + ); + assert_eq!( + intercepted.into_body().collect().await.unwrap().to_bytes(), + "custom 404" + ); + assert_eq!( + vhost + .errors + .get(&404) + .unwrap() + .render(StatusCode::NOT_FOUND) + .status(), + StatusCode::SERVICE_UNAVAILABLE + ); } #[tokio::test] async fn interception_uses_vhost_then_global_page_and_preserves_challenge() { let mut global = GlobalPages::default(); - global.errors.insert(401, Page { kind: Kind::Html, - html: "global denied".into(), status: None }); - let mut vhost = VhostPages { intercept_statuses: vec![401], ..VhostPages::default() }; + global.errors.insert( + 401, + Page { + kind: Kind::Html, + html: "global denied".into(), + status: None, + }, + ); + let mut vhost = VhostPages { + intercept_statuses: vec![401], + ..VhostPages::default() + }; let mut response = Response::new(Body::from("application denied")); *response.status_mut() = StatusCode::UNAUTHORIZED; - response.headers_mut().insert("www-authenticate", HeaderValue::from_static("Basic realm=\"app\"")); - response.headers_mut().insert("set-cookie", HeaderValue::from_static("secret=1")); - response.headers_mut().insert("content-encoding", HeaderValue::from_static("gzip")); + response.headers_mut().insert( + "www-authenticate", + HeaderValue::from_static("Basic realm=\"app\""), + ); + response + .headers_mut() + .insert("set-cookie", HeaderValue::from_static("secret=1")); + response + .headers_mut() + .insert("content-encoding", HeaderValue::from_static("gzip")); let global_response = intercept_application_error(response, &vhost, &global); - assert_eq!(global_response.headers().get("www-authenticate").unwrap(), "Basic realm=\"app\""); + assert_eq!( + global_response.headers().get("www-authenticate").unwrap(), + "Basic realm=\"app\"" + ); assert!(!global_response.headers().contains_key("set-cookie")); assert!(!global_response.headers().contains_key("content-encoding")); - assert_eq!(global_response.into_body().collect().await.unwrap().to_bytes(), "global denied"); + assert_eq!( + global_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + "global denied" + ); - vhost.errors.insert(401, Page { kind: Kind::Html, - html: "local denied".into(), status: None }); + vhost.errors.insert( + 401, + Page { + kind: Kind::Html, + html: "local denied".into(), + status: None, + }, + ); let mut response = Response::new(Body::from("application denied")); *response.status_mut() = StatusCode::UNAUTHORIZED; let local = intercept_application_error(response, &vhost, &global); - assert_eq!(local.into_body().collect().await.unwrap().to_bytes(), "local denied"); + assert_eq!( + local.into_body().collect().await.unwrap().to_bytes(), + "local denied" + ); } #[tokio::test] async fn interception_skips_nonmatching_and_already_served_responses() { - let vhost = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() }; + let vhost = VhostPages { + intercept_statuses: vec![404], + ..VhostPages::default() + }; let mut response = Response::new(Body::from("ordinary ok")); *response.status_mut() = StatusCode::OK; let untouched = intercept_application_error(response, &vhost, &GlobalPages::default()); - assert_eq!(untouched.into_body().collect().await.unwrap().to_bytes(), "ordinary ok"); - let served = Page { kind: Kind::Html, status: Some(404), html: "internal fallback".into() } - .render(StatusCode::NOT_FOUND); + assert_eq!( + untouched.into_body().collect().await.unwrap().to_bytes(), + "ordinary ok" + ); + let served = Page { + kind: Kind::Html, + status: Some(404), + html: "internal fallback".into(), + } + .render(StatusCode::NOT_FOUND); let preserved = intercept_application_error(served, &vhost, &GlobalPages::default()); - assert_eq!(preserved.into_body().collect().await.unwrap().to_bytes(), "internal fallback"); + assert_eq!( + preserved.into_body().collect().await.unwrap().to_bytes(), + "internal fallback" + ); } #[test] @@ -273,6 +351,8 @@ mod tests { assert!(pages.validate().is_err()); pages.intercept_statuses.clear(); pages.validate().unwrap(); - assert!(!serde_json::to_string(&pages).unwrap().contains("intercept_statuses")); + assert!(!serde_json::to_string(&pages) + .unwrap() + .contains("intercept_statuses")); } } diff --git a/src/prometheus.rs b/src/prometheus.rs index 9535263..d06651e 100644 --- a/src/prometheus.rs +++ b/src/prometheus.rs @@ -1,5 +1,3 @@ -//! Dedicated read-only scrape endpoint, independent of administrator sessions. -//! Configured labels only: never export request URLs, queries, users or secrets. include!("prometheus/model.rs"); include!("prometheus/core.rs"); include!("prometheus/traffic.rs"); diff --git a/src/prometheus/docker_misc.rs b/src/prometheus/docker_misc.rs index 5430189..1ed17b9 100644 --- a/src/prometheus/docker_misc.rs +++ b/src/prometheus/docker_misc.rs @@ -55,7 +55,6 @@ fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families) &l, host.max_connections, ); - // Backend labels are bounded by configured stream/backend IDs; never client addresses. for backend in &view.backends { let lb = labels(&[ ("stream", &host.id), @@ -90,7 +89,6 @@ fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families) &lb, u8::from(backend.drain), ); - // A disabled detailed-metrics flag does not affect routing counters or probes. if !backend.detail_enabled { continue; } @@ -161,8 +159,6 @@ fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families) ); } if settings.include_cache_domains { - // Bounded resident inspector. Counter families are gauges deliberately: - // these per-domain values reset on config publication, unlike globals. let dashboard = app.cache.dashboard(None, 0, 1); f.add( "rust_proxy_cache_statistics_revision", @@ -172,7 +168,6 @@ fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families) dashboard.statistics_revision, ); for domain in dashboard.domains { - // Only configured names, never arbitrary Host input or unknown scope. if !config .vhosts .iter() diff --git a/src/prometheus/json.rs b/src/prometheus/json.rs index ab015d2..4a60a19 100644 --- a/src/prometheus/json.rs +++ b/src/prometheus/json.rs @@ -1,6 +1,3 @@ -// Pull-friendly JSON telemetry: a compact chart snapshot plus every numeric -// sample that the Prometheus text exporter currently emits. Both formats use -// the same collector switches and security boundaries. use super::App; use axum::response::{IntoResponse, Response}; use serde::Serialize; @@ -13,8 +10,6 @@ use std::{ #[derive(Default, Serialize)] struct MetricSamples { - // Metadata is stored separately because histogram/summary samples have - // suffixes (_bucket/_sum/_count) that share one Prometheus TYPE family. types: BTreeMap, samples: BTreeMap>, } @@ -25,9 +20,6 @@ struct Sample { value: Value, } -// Read only our own Prometheus exposition, never an untrusted remote payload. -// Preserve large u64 counters exactly; JSON cannot represent NaN or infinity, -// so these values become null rather than breaking the whole API response. fn sample_value(raw: &str) -> Value { if let Ok(value) = raw.parse::() { return json!(value); @@ -41,8 +33,6 @@ fn sample_value(raw: &str) -> Value { .map_or(Value::Null, |value| json!(value)) } -// Prometheus label escaping: \\, \", \n. Scan the quoted value rather than -// splitting on commas (a configured label may contain commas or quotes). fn parse_labels(raw: &str) -> Option> { let bytes = raw.as_bytes(); let mut pos = 0; @@ -145,8 +135,6 @@ fn counts(total: usize, enabled: usize) -> Value { json!({"total": total, "enabled": enabled, "disabled": total - enabled}) } -// A small, field-stable projection that an external dashboard can consume -// without parsing metric families or knowing RustProxy's widget layout. fn percentage(part: u64, total: u64) -> Option { (total > 0).then(|| part as f64 * 100.0 / total as f64) } @@ -399,9 +387,6 @@ fn snapshot(app: &App) -> Value { .map(|stream| { let live = app.stream_metrics.get(&stream.id).map(|m| { let view = m.view(); - // The dashboard's full stream view includes configured backend - // socket addresses and health internals. Metrics consumers only - // need counters/status, never infrastructure addresses. let backends = view .backends .iter() @@ -494,8 +479,6 @@ fn snapshot(app: &App) -> Value { .unwrap_or_default() .as_millis() as u64; - // Split the JSON builders: a single giant json! macro would exceed the - // default macro recursion depth on supported stable Rust toolchains. let requests = json!({ "total": metrics.requests, "completed_total": metrics.completed, "active": metrics.active, "errors_total": metrics.errors, @@ -599,7 +582,6 @@ fn snapshot(app: &App) -> Value { "listeners_active": app.active_listeners.len() } }); - // Share the exact same computed summary between the full and small APIs. detail["summary"] = summary_from_snapshot(&detail, app.metrics.summary_rates()); detail } @@ -621,8 +603,6 @@ pub fn summary_response(app: &App) -> Response { pub fn payload(app: &App) -> Value { let mut result = snapshot(app); - // Use one exporter implementation, so new Prometheus families automatically - // appear here without implementing the same counters a second time. result["prometheus"] = json!(parse_exposition(&super::render(app))); result } diff --git a/src/prometheus/model.rs b/src/prometheus/model.rs index ba31e19..4aa53fa 100644 --- a/src/prometheus/model.rs +++ b/src/prometheus/model.rs @@ -22,10 +22,7 @@ pub struct Settings { pub listen: SocketAddr, pub path: String, pub allowed_cidrs: Vec, - /// Only a variable NAME is persisted; its value is never in config/API. pub bearer_token_env: Option, - /// Allow clients already matched by `allowed_cidrs` to scrape without a bearer token. - /// Intended only for private/trusted monitoring networks. pub allow_trusted_network_without_token: bool, pub include_system: bool, pub include_vhosts: bool, @@ -99,9 +96,6 @@ impl Settings { "metrics: token environment variable must use A-Z, 0-9, underscore" ); } - // Runtime checks a generated credential (stored in SQLite) or the - // legacy environment variable. Without either, scrapes fail closed. - // Non-loopback HTTP still needs a private network/TLS tunnel. Ok(()) } pub fn restart_differs(&self, other: &Self) -> bool { @@ -140,12 +134,10 @@ pub fn router(app: Arc, token: Option>) -> Router { async fn scrape(State(exporter): State>, request: Request) -> Response { let config = exporter.app.config.load_full(); let settings = &config.envelope.config.prometheus; - // JSON is a sibling of the configured path, with identical transport auth. let json_path = format!("{}.json", settings.path); let wants_json = request.uri().path() == json_path.as_str(); let summary_path = format!("{}.summary.json", settings.path); let wants_summary = request.uri().path() == summary_path.as_str(); - // Revocation is immediate even when changing bind/credentials needs restart. if !settings.enabled || settings.restart_differs(&exporter.app.active_prometheus) || (request.uri().path() != settings.path && !wants_json && !wants_summary) diff --git a/src/prometheus/tests.rs b/src/prometheus/tests.rs index 7b9a33e..197ab12 100644 --- a/src/prometheus/tests.rs +++ b/src/prometheus/tests.rs @@ -8,8 +8,6 @@ mod tests { settings.validate().unwrap(); settings.enabled = true; settings.listen = "0.0.0.0:9191".parse().unwrap(); - // With generated tokens validation permits remote binds; without a - // configured credential the exporter itself returns 401, never 200. assert!(settings.validate().is_ok()); settings.bearer_token_env = Some("RUST_PROXY_METRICS_TOKEN".into()); settings.validate().unwrap(); diff --git a/src/prometheus/web_php.rs b/src/prometheus/web_php.rs index 3e40510..ee30ccb 100644 --- a/src/prometheus/web_php.rs +++ b/src/prometheus/web_php.rs @@ -1,4 +1,3 @@ -// Only configured Web Service IDs and bounded enumerated classes/reasons are labels. fn render_web_php(snapshot: &crate::config::Snapshot, f: &mut Families) { use crate::web_server::metrics::PHP_FAILURE_NAMES; let config = &snapshot.envelope.config; diff --git a/src/proxy/body.rs b/src/proxy/body/mod.rs similarity index 89% rename from src/proxy/body.rs rename to src/proxy/body/mod.rs index 3916b84..838cc9c 100644 --- a/src/proxy/body.rs +++ b/src/proxy/body/mod.rs @@ -1,4 +1,3 @@ -//! Passes frames through unchanged, including trailers. No body collection. use crate::{ logging::{now_ms, LogEntry, Logs}, metrics::Metrics, @@ -21,7 +20,6 @@ use tokio::{ time::{sleep_until, Sleep}, }; -/// Compact source tags avoid a mutex on every completed exchange. #[repr(u8)] #[derive(Clone, Copy)] pub enum ExchangeSource { @@ -43,7 +41,6 @@ impl ExchangeSource { } } -// 0 = unknown, 1 = Some(false), 2 = Some(true). Preserve log schema. fn read_effective(value: u8) -> Option { match value { 1 => Some(false), @@ -176,18 +173,26 @@ impl Drop for Exchange { _ => &self.metrics.status_5xx, } .fetch_add(1, Ordering::Relaxed); - // Final Arc drop has exclusive access: move strings without locking. let backend = self .backend .get_mut() .unwrap_or_else(|e| e.into_inner()) .as_ref() .map(|l| l.member.backend.id.clone()); - let websocket_phase = *self.websocket_phase.get_mut().unwrap_or_else(|e| e.into_inner()); - let route = self.route.get_mut().unwrap_or_else(|e| e.into_inner()).take(); + let websocket_phase = *self + .websocket_phase + .get_mut() + .unwrap_or_else(|e| e.into_inner()); + let route = self + .route + .get_mut() + .unwrap_or_else(|e| e.into_inner()) + .take(); let source = ExchangeSource::name(self.source.load(Ordering::Relaxed)); - let cache_effective_enabled = read_effective(self.cache_effective_enabled.load(Ordering::Relaxed)); - let transform_effective_enabled = read_effective(self.transform_effective_enabled.load(Ordering::Relaxed)); + let cache_effective_enabled = + read_effective(self.cache_effective_enabled.load(Ordering::Relaxed)); + let transform_effective_enabled = + read_effective(self.transform_effective_enabled.load(Ordering::Relaxed)); let client_ip = self .client_ip .get_mut() @@ -246,7 +251,10 @@ impl Drop for Exchange { sni: None, authority: None, matched_by: None, - cache_reason: *self.cache_reason.get_mut().unwrap_or_else(|e| e.into_inner()), + cache_reason: *self + .cache_reason + .get_mut() + .unwrap_or_else(|e| e.into_inner()), vhost: self.vhost.take(), route, cache_effective_enabled, @@ -266,7 +274,10 @@ impl Drop for Exchange { .websocket_error .get_mut() .unwrap_or_else(|e| e.into_inner()), - h3_reset_code: *self.h3_reset_code.get_mut().unwrap_or_else(|e| e.into_inner()), + h3_reset_code: *self + .h3_reset_code + .get_mut() + .unwrap_or_else(|e| e.into_inner()), upstream_status: match self.upstream_status.load(Ordering::Relaxed) { 0 => None, value => Some(value), @@ -297,8 +308,6 @@ pub enum Direction { pub struct MeteredBody { inner: Body, exchange: Option>, - /// For a response, backend selection is final before this wrapper is made. - /// Keep the selected lease alive and avoid locking Exchange.backend per frame. response_backend: Option>, request_backend: Option>, direction: Direction, @@ -311,7 +320,13 @@ impl MeteredBody { pub fn new(inner: Body, exchange: Arc, direction: Direction, idle: Duration) -> Self { let finished = inner.is_end_stream(); let response_backend = matches!(direction, Direction::Response) - .then(|| exchange.backend.lock().unwrap_or_else(|e| e.into_inner()).clone()) + .then(|| { + exchange + .backend + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + }) .flatten(); Self { inner, @@ -319,8 +334,6 @@ impl MeteredBody { response_backend, request_backend: None, direction, - // Ready/empty bodies never need a timer allocation. Keep the - // original deadline so a delayed first poll still times out. timer: None, deadline: tokio::time::Instant::now() + idle, idle, @@ -353,11 +366,12 @@ impl HttpBody for MeteredBody { let count = data.len() as u64; match this.direction { Direction::Request => { - // Backend is selected before a non-empty upstream - // upload is polled. No retry of a streamed write. if this.request_backend.is_none() { - this.request_backend = exchange.backend - .lock().unwrap_or_else(|e| e.into_inner()).clone(); + this.request_backend = exchange + .backend + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); } if let Some(lease) = &this.request_backend { lease.bytes(count, true); @@ -455,10 +469,8 @@ impl Drop for MeteredBody { } #[cfg(test)] -include!("body/tests.rs"); +include!("tests.rs"); -/// Retains the first inspected frame without collecting the rest of a request. -/// A trailer/empty DATA frame still prevents storing a request-body variant. pub struct ReplayBody { head: Option>, tail: axum::body::Body, diff --git a/src/proxy/body/tests.rs b/src/proxy/body/tests.rs index bdab23e..cb37155 100644 --- a/src/proxy/body/tests.rs +++ b/src/proxy/body/tests.rs @@ -168,8 +168,6 @@ mod tests { Direction::Request, Duration::from_secs(1), ); - // Move the saved initial deadline into the past without wall-clock - // sleeps: the first Pending must create an already-expired timer. body.deadline = tokio::time::Instant::now() - Duration::from_secs(1); assert!(body.timer.is_none()); let result = tokio::time::timeout(Duration::from_millis(200), body.frame()).await; diff --git a/src/proxy/cache_helpers.rs b/src/proxy/cache_helpers.rs index 4a572e2..32f438b 100644 --- a/src/proxy/cache_helpers.rs +++ b/src/proxy/cache_helpers.rs @@ -114,8 +114,6 @@ fn error(status: StatusCode, detail: &'static str) -> Response { ); response } else { - // For statuses without a built-in error page (405, 421, 501, ...), - // emit the HTTP status instead of implementation-specific diagnostics. let body = format!( "{} {}\n", status.as_u16(), @@ -188,8 +186,6 @@ fn response_context(snapshot: &crate::config::Snapshot, request: &Request) -> Se .select_on_arc(&host, path.as_ref(), local, tls) .cloned() }); - // An ordinary path borrows the original URI in forward_request. Only - // a decoded path needs to be moved between middleware and forwarding. let decoded_path = path.and_then(|path| match path { std::borrow::Cow::Borrowed(_) => None, std::borrow::Cow::Owned(path) => Some(path), @@ -211,11 +207,6 @@ fn response_context(snapshot: &crate::config::Snapshot, request: &Request) -> Se }) } -/// Reserved administration-session cookies may cross the proxy boundary only -/// for the explicit self-admin deployment: HTTPS, a specifically allowed -/// administration hostname (extra DNS list or pinned public HTTPS origin), -/// a backend that directly targets the configured -/// administration listener, and the existing local-listener risk opt-in. fn admin_session_passthrough( snapshot: &crate::config::Snapshot, host: &str, @@ -263,7 +254,6 @@ fn apply_response_age_rules( crate::headers::apply_wire_age(headers, &route.headers.response); } } -/// Final policies apply to cache hits, errors, static pages and ALL transports. fn finish_headers( response: &mut Response, snapshot: &crate::config::Snapshot, @@ -284,7 +274,6 @@ fn finish_headers( .map(|f| &f.diagnostic_headers) .unwrap_or(&snapshot.envelope.config.diagnostic_headers) .apply(response.headers_mut(), request_id); - // Authenticated content must not leak through an external shared cache either. if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) { response.headers_mut().insert( "cache-control", @@ -328,8 +317,6 @@ fn apply_timing_headers( } } -/// Opt-in backend diagnostics are applied after response rules and cache handling so -/// an origin cannot spoof them and a cache hit never reports a stale backend. fn apply_backend_diagnostic_values( response: &mut Response, backend_header: bool, @@ -367,8 +354,6 @@ fn apply_backend_diagnostic_headers( frontend.is_some_and(|value| value.backend_timing_header), ); } -/// Peek at one bounded frame when a multiplexed request has no exact size hint. -/// An absent Content-Length on HTTP/2 or HTTP/3 is NOT proof of an empty body. async fn empty_for_cache(request: &mut Request) -> Result { if request.body().is_end_stream() { return Ok(true); @@ -376,11 +361,6 @@ async fn empty_for_cache(request: &mut Request) -> Result { if request.body().size_hint().lower() > 0 { return Ok(false); } - // Multiplexed transports may end a body with an empty DATA frame. - // On H3 document GET/HEAD, waiting without a bound can stall the entire - // cache/404-routing stage before proxy forwarding even starts. A timed - // probe that finds no FIN conservatively bypasses caching; it never drops - // or consumes a nonempty request body. let bounded_h3_navigation = request.version() == Version::HTTP_3 && (request.method() == Method::GET || request.method() == Method::HEAD); for _ in 0..8 { @@ -420,14 +400,6 @@ async fn empty_for_cache(request: &mut Request) -> Result { Ok(false) } -/// Resolve the framing ambiguity of an HTTP/3 request with no declared length -/// before handing it to an HTTP/1.1 upstream. Hyper encodes an unknown-length -/// HTTP/1.1 request as chunked for methods other than GET/HEAD/CONNECT. That is -/// correct HTTP/1.1, but an actually empty H3 stream can be represented more -/// precisely as a zero-length body once its end is observed. -/// -/// Only the first semantically significant frame is inspected; non-empty bodies -/// remain streaming through ReplayBody and are never fully buffered. async fn normalize_h3_h1_unknown_body( request: &mut Request, upstream_version: Version, @@ -442,16 +414,6 @@ async fn normalize_h3_h1_unknown_body( return Ok(()); } - // Ignore a small number of empty DATA frames while looking for either the - // stream end or the first frame that carries request semantics. The body - // idle timer in MeteredBody still bounds a stalled peer. - // A full page navigation (e.g. clicking the RustProxy logo) is a GET. - // H3 HEADERS can precede FIN on a document GET. Resolve the receive half - // when FIN arrives promptly, before handing the request to an H1 origin. - // Hyper already special-cases GET/HEAD H1 encoding; this probe targets - // receive-stream completion, not a presumed chunked GET on the wire. - // When the peer is slow or sends a real request body, retain streaming and - // do not delay navigation by the general upload/body-idle deadline. let navigation_method = method == Method::GET || method == Method::HEAD; for _ in 0..8 { let next = if navigation_method { diff --git a/src/proxy/cache_http_invalidation.rs b/src/proxy/cache_http_invalidation.rs index 898629d..2da14ed 100644 --- a/src/proxy/cache_http_invalidation.rs +++ b/src/proxy/cache_http_invalidation.rs @@ -1,6 +1,3 @@ -// Varnish-like HTTP BAN/PURGE on public vhost listeners. No new admin -// endpoint or session token: security rests on a strict, opt-in client IP/CIDR -// allowlist plus normal frontend/host ACLs and rate limits. Never forward. fn cache_http_invalidation_method(method: &Method) -> bool { method.as_str() == "BAN" || method.as_str() == "PURGE" } @@ -12,8 +9,6 @@ fn cache_http_invalidation_request( scheme: &str, request: &Request, ) -> Result { - // Allow a single explicit Content-Length: 0 (used by some PURGE clients), - // but reject ambiguous lengths and all transfer-encoded bodies. let mut lengths = request.headers().get_all(http::header::CONTENT_LENGTH).iter(); if let Some(length) = lengths.next() { if lengths.next().is_some() || length.as_bytes() != b"0" { @@ -26,8 +21,6 @@ fn cache_http_invalidation_request( { return Err("BAN/PURGE must not contain a request body or upgrade"); } - // Only this vhost and Host are permitted; no client-supplied cross-vhost - // selectors, regex, or arbitrary Cache-Tag from the public listener. let target = request.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); if target.len() > 4096 || !target.starts_with('/') { return Err("invalid BAN/PURGE target"); @@ -42,8 +35,6 @@ fn cache_http_invalidation_request( } else { crate::cache::InvalidationSelector::Exact }; - // Never resolve ambiguous duplicate maintenance headers differently from - // intermediaries or audit logs. Reject them before selecting a target. if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1 || request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1 { @@ -71,9 +62,6 @@ fn cache_http_invalidation_request( if method.as_str() != "BAN" && request.headers().contains_key("x-rustproxy-confirm-wide") { return Err("wide confirmation is only supported for BAN"); } - // Explicitly scope maintenance to the externally visible scheme. The - // ordinary /path matcher would also invalidate the opposite scheme on - // a vhost shared between HTTP and HTTPS. if !matches!(scheme, "http" | "https") { return Err("invalid request scheme"); } @@ -99,13 +87,9 @@ async fn cache_http_invalidation( peer_ip: std::net::IpAddr, scheme: &str, ) -> Response { - // Fail closed even when the vhost cache is disabled; never pass commands - // through to the backend, and never trust arbitrary X-Forwarded-For. let Some(frontend) = frontend else { return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n"); }; - // A trusted proxy missing a usable client chain must not inherit its own - // allowlist permission for every external visitor. if snapshot.forwarding.trusted(peer_ip) && crate::vhost::canonical_ip(peer_ip) == client_ip { @@ -118,13 +102,8 @@ async fn cache_http_invalidation( Ok(requested) => requested, Err(reason) => return error(StatusCode::BAD_REQUEST, reason), }; - // Axum's Request is Send but its Body is not Sync. Never keep - // a shared `&Request` inside the handler future across an .await. - // Release the unused body before the asynchronous audit/cache work. let method = request.method().clone(); drop(request); - // Allocate the independent maintenance slot before even writing an audit - // row, so a burst from an allowed IP cannot pile up DB writes and workers. let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else { return error(StatusCode::TOO_MANY_REQUESTS, "RustProxy: cache maintenance busy\n"); }; @@ -136,9 +115,6 @@ async fn cache_http_invalidation( tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed"); return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n"); } - // Rate-limit the expensive maintenance path independently of the optional - // per-vhost traffic limiter. The permit lives inside the blocking worker - // even if a client disconnects while the cache scan is running. let cache = state.cache.clone(); let result = tokio::task::spawn_blocking(move || { let _permit = permit; diff --git a/src/proxy/tests/http_invalidation_tests.rs b/src/proxy/cache_http_invalidation_tests.rs similarity index 100% rename from src/proxy/tests/http_invalidation_tests.rs rename to src/proxy/cache_http_invalidation_tests.rs diff --git a/src/proxy/cache_plan.rs b/src/proxy/cache_plan/mod.rs similarity index 67% rename from src/proxy/cache_plan.rs rename to src/proxy/cache_plan/mod.rs index 0d1b2ba..d02be9d 100644 --- a/src/proxy/cache_plan.rs +++ b/src/proxy/cache_plan/mod.rs @@ -1,15 +1,13 @@ -//! Request-local cache decisions derived from the already validated route. -//! A disabled plan retains its diagnostic reason without preparing cache-only -//! policy, cookie matching or request metadata. Access and origin policy stay -//! in the common forwarding path and must never depend on this plan. - use crate::cache::{RequestPolicy, Settings}; use http::{HeaderMap, Method}; #[derive(Debug, Clone, Copy)] pub(super) enum CachePlan { Disabled(&'static str), - Enabled { force_cache: bool, ignore_cookie: bool }, + Enabled { + force_cache: bool, + ignore_cookie: bool, + }, } impl CachePlan { @@ -30,11 +28,23 @@ impl CachePlan { } pub(super) fn force_cache(self) -> bool { - matches!(self, Self::Enabled { force_cache: true, .. }) + matches!( + self, + Self::Enabled { + force_cache: true, + .. + } + ) } pub(super) fn ignore_cookie(self) -> bool { - matches!(self, Self::Enabled { ignore_cookie: true, .. }) + matches!( + self, + Self::Enabled { + ignore_cookie: true, + .. + } + ) } pub(super) fn request_policy( @@ -48,16 +58,16 @@ impl CachePlan { ) -> RequestPolicy { match self { Self::Disabled(reason) => RequestPolicy::Bypass(reason), - Self::Enabled { force_cache: true, .. } => - crate::cache::forced_request_policy(method, headers, body_empty), - Self::Enabled { .. } => - crate::cache::request_policy(method, headers, body_empty, path, has_query, settings), + Self::Enabled { + force_cache: true, .. + } => crate::cache::forced_request_policy(method, headers, body_empty), + Self::Enabled { .. } => { + crate::cache::request_policy(method, headers, body_empty, path, has_query, settings) + } } } } -/// Header rules may only make a request less cacheable. An original bypass is -/// final, so there is no reason to parse the rewritten headers in that case. pub(super) fn combine_policy( original: RequestPolicy, rewritten: impl FnOnce() -> RequestPolicy, @@ -73,8 +83,6 @@ pub(super) fn combine_policy( } } -/// Origin Range semantics also disable transformation/compression when the -/// cache is off. Only a cache-eligible request needs byte-range parsing. pub(super) fn range_selection( cache_eligible: bool, method: &Method, diff --git a/src/proxy/error_fallback.rs b/src/proxy/error_fallback.rs index 1edb820..5fa40cc 100644 --- a/src/proxy/error_fallback.rs +++ b/src/proxy/error_fallback.rs @@ -8,8 +8,6 @@ struct InternalFallbackRoute { #[derive(Clone)] struct FinalRouteContext(Arc); -/// The named location has already produced the terminal HTTP response. -/// In particular, do not replace its 404 HTML with a global/vhost error page. #[derive(Clone, Copy)] struct ErrorFallbackResponse; @@ -93,11 +91,6 @@ async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Re return Ok(ErrorFallbackBody::Replayable(Bytes::new())); } let Some(length) = request.body().size_hint().exact() else { - // HTTP/2 and HTTP/3 frequently expose an *empty* GET/HEAD body as a - // stream with no exact size hint. Refusing to inspect that stream - // silently disables the configured 404 fallback. Only peek for safe - // bodyless methods; an actual payload is replayed back into the - // original request and is never discarded or forwarded twice. if request.method() == Method::GET || request.method() == Method::HEAD { return match empty_for_cache(request).await { Ok(true) => Ok(ErrorFallbackBody::Replayable(Bytes::new())), @@ -183,16 +176,11 @@ async fn execute_error_fallback( Some(response) } -/// Called only for a response supplied by the Web Server or an HTTP origin. -/// Routing/security failures and internal fallback responses are excluded. fn intercept_route_origin_error( response: Response, route: &crate::config::CompiledRoute, snapshot: &crate::config::Snapshot, ) -> Response { - // An internal named location is the final, administrator-selected fallback - // representation. Interception inside its nested forward_request would - // otherwise erase its 404 HTML before ErrorFallbackResponse is attached. if route.internal { return response; } diff --git a/src/proxy/forward/cache_fast_path_tests.rs b/src/proxy/forward/cache_fast_path_tests.rs new file mode 100644 index 0000000..06581e0 --- /dev/null +++ b/src/proxy/forward/cache_fast_path_tests.rs @@ -0,0 +1,658 @@ +mod cache_fast_path_tests { + use super::*; + use crate::{ + auth::Auth, + config::{self, Config, Update, ValidationPolicy}, + db::Store, + }; + use std::net::SocketAddr; + use tempfile::TempDir; + use tower::ServiceExt; + + const CSS: &[u8] = b"/* origin comment */\nbody { color: red; margin: 0px; }\n"; + + struct Fixture { + app: Arc, + _directory: TempDir, + } + struct Received { + status: StatusCode, + headers: HeaderMap, + body: Bytes, + } + + impl Fixture { + async fn new(config: Config) -> Self { + let directory = TempDir::new().unwrap(); + let policy = 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(), + }; + let store = Store::open(&directory.path().join("db"), &config) + .await + .unwrap(); + let auth = Auth::new(store.clone(), directory.path().to_owned()) + .await + .unwrap(); + let snapshot = config::compile(config, 0, &policy).unwrap(); + let app = App::new( + store, + snapshot, + policy, + auth, + 32, + Duration::from_secs(5), + Duration::from_secs(5), + Duration::from_secs(30), + ); + Self { + app, + _directory: directory, + } + } + + async fn send( + &self, + method: Method, + path: &str, + headers: &[(&str, &str)], + body: Body, + ) -> Received { + send(self.app.clone(), method, path, headers, body).await + } + + async fn get(&self, path: &str, headers: &[(&str, &str)]) -> Received { + self.send(Method::GET, path, headers, Body::empty()).await + } + + async fn change(&self, edit: impl FnOnce(&mut Config)) { + let current = self.app.config.load_full(); + let mut config = current.envelope.config.clone(); + edit(&mut config); + self.app + .update(Update { + expected_revision: current.envelope.revision, + config, + }) + .await + .unwrap(); + } + + async fn stop(self) { + self.app.shutdown.cancel(); + self.app.tunnels.close(); + self.app.tunnels.wait().await; + self.app.store.close().await; + } + } + + async fn send( + app: Arc, + method: Method, + path: &str, + headers: &[(&str, &str)], + body: Body, + ) -> Received { + let mut builder = Request::builder() + .method(method) + .uri(path) + .header(HOST, "localhost") + .header(USER_AGENT, "cache-plan-regression/1.0"); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let mut request = builder.body(body).unwrap(); + request + .extensions_mut() + .insert(PeerAddr("192.0.2.50:45678".parse().unwrap())); + request + .extensions_mut() + .insert(LocalAddr("127.0.0.1:8080".parse().unwrap())); + let response = router(app).oneshot(request).await.unwrap(); + let (parts, body) = response.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + Received { + status: parts.status, + headers: parts.headers, + body, + } + } + + fn config_for(origin: SocketAddr, enabled: bool) -> Config { + let mut config = config::demo_config(origin); + config.cache.enabled = enabled; + config.cache.expose_status = true; + config.cache.expose_hits = true; + config.cache.expose_cache_status = true; + config.cache.expose_only_hits = false; + config.cache.static_ignore_cookies = false; + config + } + + fn web_config(directory: &TempDir, enabled: bool) -> Config { + std::fs::write(directory.path().join("style.css"), CSS).unwrap(); + let mut config = config_for("127.0.0.1:9000".parse().unwrap(), enabled); + config.web_services.push(crate::web_server::Service { + id: "site".into(), + name: "Site".into(), + document_root: directory.path().to_owned(), + cache_control: Some("public, max-age=600".into()), + ..Default::default() + }); + config.vhosts[0].source = crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }; + config.vhosts[0].backends.clear(); + config.vhosts[0].routes.clear(); + config + } + + #[tokio::test] + async fn content_type_policy_denies_before_local_web_server_and_cache_and_updates_live() { + let directory = TempDir::new().unwrap(); + let mut config = web_config(&directory, true); + config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy { + enabled: true, + blocked: vec!["multipart/form-data".into(), "image/*".into()], + }; + let fixture = Fixture::new(config).await; + assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK); + assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK); + assert_eq!( + fixture + .get( + "/style.css", + &[("content-type", "multipart/form-data; boundary=example")] + ) + .await + .status, + StatusCode::FORBIDDEN + ); + assert_eq!( + fixture + .get("/style.css", &[("content-type", "IMAGE/PNG")]) + .await + .status, + StatusCode::FORBIDDEN + ); + assert_eq!( + fixture + .send( + Method::POST, + "/style.css", + &[("content-type", "multipart/form-data; boundary=x")], + Body::from("sample") + ) + .await + .status, + StatusCode::FORBIDDEN + ); + assert_eq!( + fixture + .get("/style.css", &[("content-type", "text/plain")]) + .await + .status, + StatusCode::OK + ); + assert_eq!( + fixture + .get( + "/style.css", + &[("content-type", "application/json, multipart/form-data")] + ) + .await + .status, + StatusCode::FORBIDDEN + ); + fixture + .change(|config| config.vhosts[0].content_type_policy.enabled = false) + .await; + assert_eq!( + fixture + .get("/style.css", &[("content-type", "multipart/form-data")]) + .await + .status, + StatusCode::OK + ); + fixture.stop().await; + } + + #[tokio::test] + async fn content_type_policy_denies_before_reverse_proxy_upstream_request() { + let origin = origin().await; + let mut config = config_for(origin.addr, false); + config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy { + enabled: true, + blocked: vec!["multipart/form-data".into()], + }; + let fixture = Fixture::new(config).await; + let blocked = fixture + .send( + Method::POST, + "/", + &[("content-type", "Multipart/Form-Data; boundary=abc")], + Body::from("--abc\r\n"), + ) + .await; + assert_eq!(blocked.status, StatusCode::FORBIDDEN); + assert_eq!(origin.requests.load(Ordering::Relaxed), 0); + let allowed = fixture + .get("/", &[("content-type", "application/json")]) + .await; + assert_eq!(allowed.status, StatusCode::OK); + assert_eq!(origin.requests.load(Ordering::Relaxed), 1); + fixture.stop().await; + } + + #[tokio::test] + async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() { + let directory = TempDir::new().unwrap(); + let fixture = Fixture::new(web_config(&directory, false)).await; + let first = fixture.get("/style.css", &[]).await; + assert_eq!(first.status, StatusCode::OK); + assert_eq!(first.body, CSS); + assert!(!first.headers.contains_key("x-cache")); + let etag = first.headers["etag"].to_str().unwrap(); + let head = fixture + .send(Method::HEAD, "/style.css", &[], Body::empty()) + .await; + assert_eq!(head.status, StatusCode::OK); + assert_eq!(head.headers["content-length"], CSS.len().to_string()); + assert!(head.body.is_empty()); + let partial = fixture + .get("/style.css", &[("range", "bytes=0-3"), ("if-range", etag)]) + .await; + assert_eq!(partial.status, StatusCode::PARTIAL_CONTENT); + assert_eq!(partial.body, &CSS[..4]); + let mismatch = fixture + .get( + "/style.css", + &[("range", "bytes=0-3"), ("if-range", "\"different\"")], + ) + .await; + assert_eq!(mismatch.status, StatusCode::OK); + assert_eq!(mismatch.body, CSS); + let conditional = fixture.get("/style.css", &[("if-none-match", etag)]).await; + assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); + assert!(conditional.body.is_empty()); + let invalid = fixture + .get("/style.css", &[("range", "bytes=99999-")]) + .await; + assert_eq!(invalid.status, StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!( + invalid.headers["content-range"], + format!("bytes */{}", CSS.len()) + ); + let only_cached = fixture + .get("/style.css", &[("cache-control", "only-if-cached")]) + .await; + assert_eq!(only_cached.body, CSS); + let logs = fixture.app.logs.recent(); + assert_eq!(logs.len(), 7); + assert!(logs + .iter() + .all(|entry| entry.cache_reason == "cache_disabled")); + assert!(logs.iter().all(|entry| entry.request_id.len() == 64)); + assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 7); + assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0); + assert_eq!(fixture.app.logs.stats().queue_dropped, 0); + fixture.stop().await; + } + + #[tokio::test] + async fn web_cache_hot_toggles_keep_miss_hit_and_bypass_diagnostics() { + let directory = TempDir::new().unwrap(); + let fixture = Fixture::new(web_config(&directory, false)).await; + assert!(!fixture + .get("/style.css", &[]) + .await + .headers + .contains_key("x-cache")); + fixture.change(|config| config.cache.enabled = true).await; + assert_eq!( + fixture.get("/style.css", &[]).await.headers["x-cache"], + "MISS" + ); + let hit = fixture.get("/style.css", &[]).await; + assert_eq!(hit.headers["x-cache"], "HIT"); + assert_eq!(hit.body, CSS); + fixture + .change(|config| { + config.vhosts[0].routes.push(crate::vhost::PathRoute { + id: "uncached".into(), + path_prefix: "/style.css".into(), + cache: Some(false), + source: crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }, + ..Default::default() + }) + }) + .await; + let bypass = fixture.get("/style.css", &[]).await; + assert_eq!(bypass.headers["x-cache"], "BYPASS"); + assert_eq!(bypass.headers["x-cache-reason"], "cache_disabled"); + fixture + .change(|config| { + config.vhosts[0].routes.clear(); + config.vhosts[0].cache.enabled = Some(false); + }) + .await; + assert!(!fixture + .get("/style.css", &[]) + .await + .headers + .contains_key("x-cache")); + fixture + .change(|config| config.vhosts[0].cache.enabled = None) + .await; + assert_eq!( + fixture.get("/style.css", &[]).await.headers["x-cache"], + "MISS" + ); + assert_eq!( + fixture.get("/style.css", &[]).await.headers["x-cache"], + "HIT" + ); + fixture.stop().await; + } + + #[tokio::test] + async fn web_transform_remains_independent_of_disabled_cache_and_range() { + let directory = TempDir::new().unwrap(); + let mut config = web_config(&directory, false); + config.transform.enabled = true; + config.vhosts[0].transform.enabled = true; + let fixture = Fixture::new(config).await; + let normal = fixture.get("/style.css", &[]).await; + assert_eq!(normal.status, StatusCode::OK); + assert!( + normal.body.len() < CSS.len(), + "transform must remain active with cache off" + ); + let range = fixture + .get( + "/style.css", + &[("range", "bytes=0-3"), ("accept-encoding", "gzip")], + ) + .await; + assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); + assert_eq!(range.body, &CSS[..4]); + assert!(!range.headers.contains_key("content-encoding")); + fixture + .change(|config| config.vhosts[0].transform.enabled = false) + .await; + assert_eq!(fixture.get("/style.css", &[]).await.body, CSS); + fixture.stop().await; + } + + #[tokio::test] + async fn cache_fast_paths_keep_global_and_location_access_control() { + let directory = TempDir::new().unwrap(); + for enabled in [false, true] { + let mut config = web_config(&directory, enabled); + let deny = crate::acl::Acl { + default: crate::acl::Action::Deny, + rules: vec![], + }; + config.host_policies.push(config::HostPolicy { + hosts: vec!["localhost".into()], + acl: deny.clone(), + }); + let fixture = Fixture::new(config).await; + assert_eq!( + fixture.get("/style.css", &[]).await.status, + StatusCode::FORBIDDEN + ); + fixture + .change(|config| { + config.host_policies.clear(); + config.vhosts[0].routes.push(crate::vhost::PathRoute { + id: "private".into(), + path_prefix: "/style.css".into(), + acl: Some(deny), + source: crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }, + ..Default::default() + }); + }) + .await; + assert_eq!( + fixture.get("/style.css", &[]).await.status, + StatusCode::FORBIDDEN + ); + fixture.stop().await; + } + } + + struct Origin { + addr: SocketAddr, + requests: Arc, + task: tokio::task::JoinHandle<()>, + } + impl Drop for Origin { + fn drop(&mut self) { + self.task.abort(); + } + } + + async fn origin() -> Origin { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let requests = Arc::new(AtomicU64::new(0)); + let count = requests.clone(); + let app = Router::new().fallback(move |request: Request| { + let count = count.clone(); + async move { + count.fetch_add(1, Ordering::Relaxed); + let (parts, body) = request.into_parts(); + if parts.uri.path() == "/slow" { + tokio::time::sleep(Duration::from_millis(25)).await; + } + let input = body.collect().await.unwrap().to_bytes(); + let variant = parts + .headers + .get("x-variant") + .and_then(|v| v.to_str().ok()) + .unwrap_or("default"); + let output = if parts.method == Method::POST { + input + } else { + Bytes::from(format!("origin-{variant}")) + }; + let mut response = Response::new(Body::from(output.clone())); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin-v1\"")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static(if parts.uri.path() == "/private" { + "private, max-age=600" + } else { + "public, max-age=600" + }), + ); + response + .headers_mut() + .insert("vary", HeaderValue::from_static("X-Variant")); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("text/plain")); + for name in [ + "range", + "if-range", + "if-none-match", + "cookie", + "authorization", + "x-request-id", + ] { + if let Some(value) = parts.headers.get(name) { + let echoed: http::HeaderName = format!("x-seen-{name}").parse().unwrap(); + response.headers_mut().insert(echoed, value.clone()); + } + } + if parts + .headers + .get("if-none-match") + .is_some_and(|v| v == "\"origin-v1\"") + { + *response.status_mut() = StatusCode::NOT_MODIFIED; + *response.body_mut() = Body::empty(); + } else if parts.headers.get("range").is_some_and(|v| v == "bytes=0-2") + && parts + .headers + .get("if-range") + .is_none_or(|v| v == "\"origin-v1\"") + { + *response.status_mut() = StatusCode::PARTIAL_CONTENT; + response.headers_mut().insert( + "content-range", + format!("bytes 0-2/{}", output.len()).parse().unwrap(), + ); + *response.body_mut() = Body::from(output.slice(..3)); + } + response + } + }); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Origin { + addr, + requests, + task, + } + } + + #[tokio::test] + async fn proxy_disabled_cache_preserves_headers_post_and_conditionals() { + let origin = origin().await; + let fixture = Fixture::new(config_for(origin.addr, false)).await; + let range = fixture + .get( + "/inspect", + &[ + ("range", "bytes=0-2"), + ("if-range", "\"origin-v1\""), + ("cookie", "session=one"), + ("authorization", "Bearer example"), + ], + ) + .await; + assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); + assert_eq!(range.body, "ori"); + assert_eq!(range.headers["x-seen-range"], "bytes=0-2"); + assert_eq!(range.headers["x-seen-if-range"], "\"origin-v1\""); + assert_eq!(range.headers["x-seen-cookie"], "session=one"); + assert_eq!(range.headers["x-seen-authorization"], "Bearer example"); + assert_eq!(range.headers["x-seen-x-request-id"].as_bytes().len(), 64); + assert!(!range.headers.contains_key("x-cache")); + let conditional = fixture + .get("/inspect", &[("if-none-match", "\"origin-v1\"")]) + .await; + assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); + assert!(conditional.body.is_empty()); + let input = futures_util::stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"streamed ")), + Ok(Bytes::from_static(b"content")), + ]); + let post = fixture + .send(Method::POST, "/echo", &[], Body::from_stream(input)) + .await; + assert_eq!(post.status, StatusCode::OK); + assert_eq!(post.body, "streamed content"); + assert_eq!(origin.requests.load(Ordering::Relaxed), 3); + assert!(fixture + .app + .logs + .recent() + .iter() + .all(|entry| entry.cache_reason == "cache_disabled")); + fixture.stop().await; + } + + #[tokio::test] + async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() { + let origin = origin().await; + let fixture = Fixture::new(config_for(origin.addr, true)).await; + for variant in ["a", "b"] { + assert_eq!( + fixture + .get("/vary", &[("x-variant", variant)]) + .await + .headers["x-cache"], + "MISS" + ); + } + let hit = fixture.get("/vary", &[("x-variant", "a")]).await; + assert_eq!(hit.headers["x-cache"], "HIT"); + assert_eq!(hit.body, "origin-a"); + let range = fixture + .get( + "/vary", + &[ + ("x-variant", "a"), + ("range", "bytes=0-2"), + ("if-range", "\"origin-v1\""), + ], + ) + .await; + assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); + assert_eq!(range.headers["x-cache"], "HIT"); + assert_eq!(range.body, "ori"); + let validated = fixture + .get( + "/vary", + &[("x-variant", "a"), ("cache-control", "no-cache")], + ) + .await; + assert_eq!(validated.status, StatusCode::OK); + assert_eq!(validated.headers["x-cache"], "HIT"); + assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated"); + assert_eq!(validated.body, "origin-a"); + let conditional = fixture + .get( + "/vary", + &[("x-variant", "a"), ("if-none-match", "\"origin-v1\"")], + ) + .await; + assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); + let bypass = fixture + .get("/vary", &[("authorization", "Bearer example")]) + .await; + assert_eq!(bypass.headers["x-cache-reason"], "request_authorization"); + for _ in 0..2 { + assert_eq!( + fixture.get("/private", &[]).await.headers["x-cache"], + "MISS" + ); + } + assert_eq!(origin.requests.load(Ordering::Relaxed), 6); + fixture.stop().await; + } + + #[tokio::test] + async fn proxy_cache_enabled_concurrent_fill_stays_single_flight() { + let origin = origin().await; + let fixture = Fixture::new(config_for(origin.addr, true)).await; + let mut requests = tokio::task::JoinSet::new(); + for _ in 0..12 { + let app = fixture.app.clone(); + requests + .spawn(async move { send(app, Method::GET, "/slow", &[], Body::empty()).await }); + } + while let Some(result) = requests.join_next().await { + let response = result.unwrap(); + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.body, "origin-default"); + } + assert_eq!(origin.requests.load(Ordering::Relaxed), 1); + assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 12); + assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0); + fixture.stop().await; + } +} diff --git a/src/proxy/forward/cache_lookup.rs b/src/proxy/forward/cache_lookup.rs index ca3a3bc..1c2274c 100644 --- a/src/proxy/forward/cache_lookup.rs +++ b/src/proxy/forward/cache_lookup.rs @@ -133,7 +133,6 @@ if let Some(ticket) = &cache_ticket { .unwrap_or_else(|e| e.into_inner()) = "cache_miss"; } } else if cached_revalidation.is_none() { - // Account a true cache miss even when the client requested validation. let _ = state.cache.get_tiered(ticket, cache_settings).await; state.cache.record(scope, &host, "MISS", "cache_miss"); miss_recorded = true; @@ -199,10 +198,6 @@ if only_cached { return response; } -// Foreground fills are single-flight per base cache key. Range responses are -// never fill leaders because a 206 is not stored as a separate cache object. -// A follower can wake after a different Vary representation was published; in -// that case it competes for the next flight instead of stampeding the origin. let mut fill_leader = None; if handshake.is_none() && matches!(range_selection, crate::cache::RangeSelection::None) diff --git a/src/proxy/tests/h3_navigation_tests.rs b/src/proxy/forward/h3_navigation_tests.rs similarity index 58% rename from src/proxy/tests/h3_navigation_tests.rs rename to src/proxy/forward/h3_navigation_tests.rs index 45f951e..2e004aa 100644 --- a/src/proxy/tests/h3_navigation_tests.rs +++ b/src/proxy/forward/h3_navigation_tests.rs @@ -13,7 +13,9 @@ mod h3_navigation_tests { .body(Body::from_stream(stream)) .unwrap(); assert_eq!(request.body().size_hint().exact(), None); - normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11).await.unwrap(); + normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) + .await + .unwrap(); assert!(request.body().is_end_stream(), "{method}"); assert_eq!(request.body().size_hint().exact(), Some(0), "{method}"); } @@ -22,11 +24,18 @@ mod h3_navigation_tests { #[tokio::test] async fn h3_get_with_payload_retains_payload_for_http1_upstream() { use bytes::Bytes; - let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"unexpected-but-valid"))]); - let mut request = Request::builder().method(Method::GET) - .version(Version::HTTP_3).uri("https://rp.example.test/") - .body(Body::from_stream(stream)).unwrap(); - normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11).await.unwrap(); + let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( + b"unexpected-but-valid", + ))]); + let mut request = Request::builder() + .method(Method::GET) + .version(Version::HTTP_3) + .uri("https://rp.example.test/") + .body(Body::from_stream(stream)) + .unwrap(); + normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) + .await + .unwrap(); let body = request.into_body().collect().await.unwrap().to_bytes(); assert_eq!(body, Bytes::from_static(b"unexpected-but-valid")); } @@ -35,12 +44,19 @@ mod h3_navigation_tests { async fn h3_stalled_cache_probe_bypasses_without_blocking_navigation() { use bytes::Bytes; let stream = futures_util::stream::pending::>(); - let mut request = Request::builder().method(Method::GET) - .version(Version::HTTP_3).uri("https://rp.example.test/") - .body(Body::from_stream(stream)).unwrap(); + let mut request = Request::builder() + .method(Method::GET) + .version(Version::HTTP_3) + .uri("https://rp.example.test/") + .body(Body::from_stream(stream)) + .unwrap(); let empty = tokio::time::timeout( - std::time::Duration::from_millis(750), empty_for_cache(&mut request), - ).await.expect("bounded cache preflight").unwrap(); + std::time::Duration::from_millis(750), + empty_for_cache(&mut request), + ) + .await + .expect("bounded cache preflight") + .unwrap(); assert!(!empty, "unknown body is not cacheable"); assert_eq!(request.body().size_hint().exact(), None); } @@ -48,28 +64,38 @@ mod h3_navigation_tests { #[tokio::test] async fn h3_cache_probe_replays_nonempty_get_without_dropping_bytes() { use bytes::Bytes; - let stream = futures_util::stream::iter([ - Ok::<_, std::io::Error>(Bytes::from_static(b"intact")), - ]); - let mut request = Request::builder().method(Method::GET) - .version(Version::HTTP_3).uri("https://rp.example.test/") - .body(Body::from_stream(stream)).unwrap(); + let stream = + futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"intact"))]); + let mut request = Request::builder() + .method(Method::GET) + .version(Version::HTTP_3) + .uri("https://rp.example.test/") + .body(Body::from_stream(stream)) + .unwrap(); assert!(!empty_for_cache(&mut request).await.unwrap()); - assert_eq!(request.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"intact")); + assert_eq!( + request.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"intact") + ); } #[tokio::test] async fn h3_stalled_empty_get_does_not_block_navigation() { use bytes::Bytes; let stream = futures_util::stream::pending::>(); - let mut request = Request::builder().method(Method::GET) - .version(Version::HTTP_3).uri("https://rp.example.test/") - .body(Body::from_stream(stream)).unwrap(); + let mut request = Request::builder() + .method(Method::GET) + .version(Version::HTTP_3) + .uri("https://rp.example.test/") + .body(Body::from_stream(stream)) + .unwrap(); tokio::time::timeout( std::time::Duration::from_millis(750), normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11), - ).await.expect("bounded GET probe").unwrap(); + ) + .await + .expect("bounded GET probe") + .unwrap(); assert_eq!(request.body().size_hint().exact(), None); } - } diff --git a/src/proxy/forward/response.rs b/src/proxy/forward/response.rs index d52b21a..dc4e5ed 100644 --- a/src/proxy/forward/response.rs +++ b/src/proxy/forward/response.rs @@ -7,10 +7,6 @@ ).await { return fallback; } - // Keep the upstream body as hyper::body::Incoming until the - // HTTP/2 WebSocket and regular response paths have adapted it. - // The opt-in application error page is applied to Response - // in success_prepare.rs, before transformation/cache admission. let (origin_validation_headers, origin_plan, origin_reason, mut response) = include!("success_prepare.rs"); include!("success_finish.rs") @@ -46,9 +42,6 @@ "upstream connection failed" ); } else if graceful_h2_close { - // Correlated GOAWAY(NO_ERROR) is expected connection churn. Keep - // failed requests visible as 502/access-log entries, but avoid - // repeating their verbose diagnostic at DEBUG severity. tracing::trace!( backend = %active_backend.id, origin = %active_backend.origin, diff --git a/src/proxy/forward/route_cache.rs b/src/proxy/forward/route_cache.rs index bd4927b..edf4cda 100644 --- a/src/proxy/forward/route_cache.rs +++ b/src/proxy/forward/route_cache.rs @@ -1,7 +1,4 @@ { -// Global host, profile, vhost and location access policies were already enforced -// before this source/cache path. Do not re-evaluate IP ACLs in isolation here: -// allow-only country entries and explicit IP allows form one policy scope. if let Some(exchange) = request.extensions().get::>() { *exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone()); exchange.set_source(if route.response.is_some() { @@ -15,8 +12,6 @@ if let Some(exchange) = request.extensions().get::>() { } }); } -// Local location responses intentionally still pass host/profile/location ACLs -// and the outer response-header/security pipeline, but never select/open an upstream. if let Some(spec) = &route.response { return local_location_response(spec, request.method()); } @@ -125,9 +120,6 @@ let cache_settings = route let transform_settings = route.frontend.as_ref().map(|f| &f.transform); let scope = route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or(""); if let Some(exchange) = request.extensions().get::>() { - // The resolved route was already recorded before local response, LB and - // ACL decisions. Do not acquire the route mutex and copy its ID twice on - // every reverse-proxy request. exchange.set_cache_enabled(cache_settings.active_for(route.cache)); exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled)); } @@ -169,8 +161,6 @@ let body_empty = if cache_enabled } else { cache_enabled && request.body().is_end_stream() }; -// Keep the original URI alive through forwarding: ordinary targets can be -// borrowed while route rewrites still allocate and are strictly validated. let public_suffix: std::borrow::Cow<'_, str> = std::borrow::Cow::Borrowed( routing_uri.path_and_query().map(|v| v.as_str()).unwrap_or("/") ); @@ -247,9 +237,6 @@ if let Some(frontend) = &route.frontend { crate::headers::apply(request.headers_mut(), &frontend.headers.request); } crate::headers::apply(request.headers_mut(), &route.headers.request); -// Canonical trusted metadata cannot be overwritten by custom request rules. -// A vhost may intentionally withhold only the visitor IP from the application; -// ACL/rate-limit/Tinyauth decisions above still use the authenticated client address. context.apply_upstream( request.headers_mut(), route @@ -289,9 +276,6 @@ let if_range = if effective_cacheable { } else { None }; let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None); -// Match Varnish-style cache identity: a load-balanced backend member must not -// fragment the object cache. The vhost scope + canonical host + request target -// already identify the public resource; Vary handles representation variants. let cache_route = route.id.as_str(); let mut cache_key_headers = None; if effective_cacheable && ignore_static_cookie && request.headers().contains_key("cookie") { diff --git a/src/proxy/forward/success_finish.rs b/src/proxy/forward/success_finish.rs index 880d8fd..0b8da4c 100644 --- a/src/proxy/forward/success_finish.rs +++ b/src/proxy/forward/success_finish.rs @@ -1,7 +1,4 @@ { -// A backend 304 validates the stored representation. Merge the new -// metadata into the cached object and answer the client's original -// condition locally, just like Varnish's conditional delivery path. if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { if let (Some(ticket), Some(cached)) = (cache_ticket.as_ref(), cached_revalidation.as_ref()) @@ -39,9 +36,6 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { cache_settings.annotate_resident_age(response.headers_mut(), resident_age); Some(response) } else if state.cache.ticket_current(ticket) { - // The validation succeeded but the refreshed representation is no - // longer eligible for shared storage (for example new no-store). - // Serve it once, then discard the stored copy. state.cache.discard(ticket); let mut response = if !client_not_modified { if let crate::cache::RangeSelection::Single(range) = range_selection { @@ -91,8 +85,6 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { } } } -// Both origin and transformed policies must be cacheable; hiding a -// Set-Cookie/private header never authorizes storage of private content. let transformed_plan = if origin_plan.is_none() || (ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie")) { diff --git a/src/proxy/forward/success_prepare.rs b/src/proxy/forward/success_prepare.rs index 0fceaab..3303a1f 100644 --- a/src/proxy/forward/success_prepare.rs +++ b/src/proxy/forward/success_prepare.rs @@ -1,5 +1,4 @@ { -// Only a real cache revalidation needs origin headers after response rules. let origin_validation_headers = if revalidation_sent && response.status() == StatusCode::NOT_MODIFIED { @@ -112,9 +111,6 @@ if handshake.as_ref().is_some_and(|handshake| { "RustProxy: HTTP/1.1 WebSocket upstream returned 2xx without switching protocols\n", ); } -// Varnish built-in behavior treats non-GET/HEAD methods as pass: the -// response is never stored, but existing cached GET representations are -// not purged implicitly. Explicit cache purge remains an admin action. let origin_plan = if cache_ticket.is_some() && !(ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie")) { @@ -181,9 +177,6 @@ let mut response = if h3_tunnel { "RustProxy: WebSocket exchange state unavailable\n", ); }; - // Hyper cannot complete the client-side upgrade while the 101 body - // remains owned here. Drop it, then prove that OnUpgrade is ready - // BEFORE returning a successful RFC 9220 response to the H3 layer. drop(body); let stop_token = stop .as_ref() @@ -213,8 +206,6 @@ let mut response = if h3_tunnel { } }; exchange.websocket_phase("tunnel_ready"); - // RFC 8441/9220 uses a successful 2xx response, never 101, and - // Sec-WebSocket-Accept is not part of the downstream handshake. parts.status = StatusCode::OK; parts.headers.remove("sec-websocket-accept"); parts.headers.remove("content-length"); @@ -338,21 +329,11 @@ let mut response = if h3_tunnel { } Response::from_parts(parts, Body::new(body)) }; -// The upstream HTTP client returns Response. Adapt it -// before rendering a RustProxy error page, so HTTP/2 WebSocket tunnels can -// continue using the original Incoming body without type erasure. -// An internal named-location fallback has already won earlier in response.rs. let intercept_origin_error = !route.internal && route.frontend.as_ref().is_some_and(|frontend| { frontend.pages.intercept_statuses.contains(&response.status().as_u16()) }); -// Mark every upstream response as application-supplied, even if this vhost -// does not intercept its status. The observer must not overwrite it merely -// because a global 404/5xx error page exists. response = intercept_route_origin_error(response, route, &snapshot); if intercept_origin_error { - // The earlier response rules modified the original origin headers; - // reapply them to the newly rendered page so the vhost's header policy - // remains effective. Do not allow a custom header rule to cache errors. apply_response_rules( response.headers_mut(), &snapshot, diff --git a/src/proxy/forward/upstream.rs b/src/proxy/forward/upstream.rs index 2f7c4bd..009019d 100644 --- a/src/proxy/forward/upstream.rs +++ b/src/proxy/forward/upstream.rs @@ -7,8 +7,6 @@ if let Some(handshake) = &handshake { request.headers_mut().remove("content-length"); request.headers_mut().remove("transfer-encoding"); if websocket_upstream_h2 { - // Preserve CONNECT + :protocol=websocket for an explicit HTTP/2 - // upstream. HTTP/1.1-only fields are forbidden on this hop. request.headers_mut().remove("connection"); request.headers_mut().remove("upgrade"); request.headers_mut().remove("sec-websocket-key"); @@ -21,8 +19,6 @@ if let Some(handshake) = &handshake { .headers_mut() .insert("upgrade", HeaderValue::from_static("websocket")); if !handshake.h1() { - // Extended CONNECT replaces the RFC 6455 proof headers on the - // downstream hop. Synthesize them only for an HTTP/1.1 backend. *request.method_mut() = Method::GET; request.headers_mut().remove("sec-websocket-key"); request.headers_mut().remove("sec-websocket-accept"); @@ -40,11 +36,8 @@ if let Some(handshake) = &handshake { } } if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) { - // Enforce after all custom request-header rules; they cannot restore credentials. auth.apply_upstream(request.headers_mut(), basic_identity); } -// Transformation operates on the identity representation. Prefer identity from -// the origin, while still decoding a supported encoding if the origin ignores it. if range_requested || transform_settings.is_some_and(|settings| settings.enabled) { request.headers_mut().remove("accept-encoding"); } @@ -54,16 +47,9 @@ if pending_revalidation { revalidation_sent = cached.apply_validators(request.headers_mut()); } } -// Varnish hashes HEAD with GET and turns a regular cacheable backend fetch -// into GET. The outer response middleware still strips the body for the -// original HEAD client, while a MISS can populate the shared GET object. if request.method() == Method::HEAD && cache_ticket.is_some() { *request.method_mut() = Method::GET; } -// Save the pre-normalization headers only for replayable requests that may -// actually be retried. The common one-backend HTTP/1 route has no retry peer -// or H2 GOAWAY handling, so copying HeaderMap for every request was wasted. -// Never replay writes, streaming uploads, auth or WebSocket requests. let retry_method = request.method().clone(); let retry_policy = route.pool.as_ref().map(|pool| &pool.settings.retry); let pool_retry_possible = route.backend_id.is_none() @@ -137,8 +123,6 @@ if websocket_upstream_h2 { *request.body_mut() = websocket::wrap_h2_request_body(body, session.clone(), exchange); h2_direct_session = Some(session); } -// Do not classify a slow client upload as a slow origin response. Probes -// independently measure origin liveness/latency with body-free requests. let request_body_empty = request.body().size_hint().exact() == Some(0); let proactive_base_headers = if cache_ticket.is_some() && cache_settings.proactive_refresh_enabled { request.headers().clone() diff --git a/src/proxy/forward/web_source.rs b/src/proxy/forward/web_source.rs index b7ec0f1..7dcc333 100644 --- a/src/proxy/forward/web_source.rs +++ b/src/proxy/forward/web_source.rs @@ -1,6 +1,4 @@ { - // Web Server is an origin just like a reverse-proxy backend. It must pass - // through the same vhost cache policy, transform stage and response rules. if handshake.is_some() { return error(StatusCode::BAD_REQUEST, "RustProxy: Web Server source does not support WebSocket upgrades\n"); @@ -30,20 +28,13 @@ exchange.set_cache_enabled(enabled); exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled)); } - // The authority is already validated by effective_host. HTTP/2 and HTTP/3 - // requests commonly have :authority but no Host field, which otherwise - // makes PHP-FPM omit HTTP_HOST, breaking WordPress. request.headers_mut().insert(HOST, original_host.clone()); let request_method = request.method().clone(); - // This branch owns local-origin delivery. Move its headers instead of - // cloning them; the request remains only for exchange/body extensions. let mut request_headers = std::mem::take(request.headers_mut()); let request_uri = request.uri().clone(); let request_version = request.version(); let public_suffix = request_uri.path_and_query() .map(|item| item.as_str()).unwrap_or("/").to_owned(); - // H2/H3 may expose an unknown-length empty stream before its final frame. - // Reuse the bounded probe that preserves any nonempty streaming body. let empty_body = if enabled && (request_method == Method::GET || request_method == Method::HEAD) { match empty_for_cache(&mut request).await { Ok(empty) => empty, @@ -62,8 +53,6 @@ &request_method, &request_headers, empty_body, &path, request_uri.query().is_some(), cache_settings, ); - // HEAD can use an existing GET representation but may not fill a cache - // entry with its empty downstream body. let eligible = enabled && policy.bypass_reason().is_none(); let key_headers = if eligible && ignore_cookie && request_headers.contains_key("cookie") { let mut headers = request_headers.clone(); @@ -79,9 +68,6 @@ let range = cache_plan::range_selection(eligible, &request_method, &request_headers); let if_range = if eligible { request_headers.get("if-range").cloned() } else { None }; let range_requested = !matches!(range, crate::cache::RangeSelection::None); - // Unlike the original Web Server path, retain stale and client-forced - // revalidation candidates. A local 304 can refresh them without replacing - // the cached body or resetting X-Cache-Hits (same as Reverse Proxy). let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() { state.cache.revalidation_tiered(ticket).await } else { None }; @@ -91,10 +77,7 @@ if let Some(ticket) = &cache_ticket { if !needs_validation { if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await { - // Filesystem/PHP does not have the reverse-proxy background - // revalidator. Refresh stale representations synchronously. if hit.stale { - // Fall through and fetch from the local origin. } else { let conditional = crate::cache::conditional_not_modified( &request_headers, hit.response.headers()); @@ -130,15 +113,11 @@ return error(StatusCode::GATEWAY_TIMEOUT, "RustProxy: only-if-cached request has no usable Web Server representation\n"); } - // Neither the Request nor the streaming Body is held across a borrowed - // await: the filesystem/PHP source owns the body for its full lifetime. let static_body = std::mem::replace(request.body_mut(), Body::empty()); let (origin_request_headers, revalidation_sent) = if cache_ticket.is_some() { web_cache_revalidation::web_origin_request_headers( &request_headers, cached_revalidation.as_ref()) } else { - // No cache result can need the original conditionals later. Preserve - // all origin HTTP semantics by handing over the untouched header map. (std::mem::take(&mut request_headers), false) }; let started = Instant::now(); @@ -180,8 +159,6 @@ hit.response.extensions_mut().insert(StaticSourceResponse); return intercept_route_origin_error(hit.response, route, &snapshot); } - // A simultaneous PURGE/config change invalidated the ticket. Do - // not accidentally return an origin 304 without a reusable body. return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: Web Server cache entry invalidated during revalidation\n"); } @@ -189,16 +166,10 @@ if cached_revalidation.is_some() && response.status() != StatusCode::NOT_MODIFIED && response.status().as_u16() < 500 { - // A changed response (including a 206, 404 or an uncacheable 200) - // supersedes the old object. Also discard when the old entry has no - // validators and therefore must be fetched unconditionally. A complete - // cacheable GET below can refill the entry. if let Some(ticket) = cache_ticket.as_ref() { state.cache.discard(ticket); } } - // Do not allow a later response-header rewrite to override a private, - // explicitly uncacheable PHP/CGI origin response. let origin_headers = response.headers(); let origin_safe = cache_ticket.is_some() && { let explicit_origin_policy = origin_headers.contains_key("cache-control") @@ -221,14 +192,11 @@ apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(), Some(route), false); if response.extensions().get::().is_some() { - // A vhost header rule must not make a replaced error cacheable. response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store")); } response.extensions_mut().insert(ResponseRulesApplied); response.extensions_mut().insert(StaticSourceResponse); - // Cache representation must reflect vhost response rules and transforms, - // not solely the Web Service's Cache-Control header. let origin_eligible_plan = if origin_safe && !(ignore_cookie && crate::cache::varies_on(response.headers(), "cookie")) { @@ -288,11 +256,6 @@ cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason); let transformed = response.extensions().get::().is_some(); cache_settings.annotate_transformed(response.headers_mut(), transformed); - // Register only AFTER the vhost response policy, cache and transformation - // decisions are final. Never feed synthetic transport placeholders into - // cache, compression or the transformation engine. The ordinary body - // remains the fallback on every other protocol/response and when the - // per-connection bridge has no capacity. #[cfg(target_os = "linux")] if !enabled && identity_request && request_method == Method::GET && !transform_settings.is_some_and(|settings| settings.enabled) diff --git a/src/proxy/forward_entry.rs b/src/proxy/forward_entry.rs index ddc95b2..fa9c38e 100644 --- a/src/proxy/forward_entry.rs +++ b/src/proxy/forward_entry.rs @@ -89,10 +89,6 @@ async fn forward_request( .cloned() .unwrap_or_else(|| state.config.load_full()); let internal_dispatch = request.extensions().get::().cloned(); - // Retain the original URI's shared backing bytes independently of `request`. - // A normalized, already-safe path can then be borrowed for the entire - // forwarding operation, even after the request URI/headers are rewritten. - // Encoded/slash-folded paths still allocate via the strict decoder. let routing_uri = request.uri().clone(); let path = if let Some(path) = decoded_path.as_deref() { std::borrow::Cow::Borrowed(path) @@ -108,12 +104,8 @@ async fn forward_request( let frontend = if let Some(selection) = &selected_context { selection.frontend.as_ref() } else { - // Internal error fallback calls forward_request directly, without - // rerunning the observation middleware. snapshot.frontend(&host, local.0, tls) }; - // Host-level denylist: before cache, custom locations, redirects, local - // Web Server/PHP-FPM, or any origin connection. No request body is parsed. if frontend.is_some_and(|item| item.content_type_policy.blocks(request.headers())) { return error( StatusCode::FORBIDDEN, @@ -132,8 +124,6 @@ async fn forward_request( handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support); let redirect = snapshot.redirect_on(&host, &path, local.0, tls); let local_host = snapshot.not_found_host_on(&host, local.0, tls); - // TLS ALPN normally prevents HTTP/2 from reaching a frontend that disabled it. - // Keep this request-level guard for coalesced/reused connections and direct tests. if request.version() == Version::HTTP_2 && (frontend.is_some_and(|frontend| !frontend.http2_enabled) || redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled) @@ -227,18 +217,12 @@ async fn forward_request( cloudflare_country = crate::forwarding::cloudflare_country(request.headers(), peer.0); } } - // `observe` already stored the actual transport peer. Most requests - // don't use a trusted forwarding hop, so avoid formatting the same IP - // and taking the per-request mutex a second time. IPv4-mapped peers - // still take this path so their canonical form is correctly logged. if context.client_ip != peer.0.ip() { if let Some(exchange) = request.extensions().get::>() { *exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) = Some(context.client_ip.to_string()); } } - // GeoIP stays off the normal request path. Resolve lazily for any vhost or - // location policy that actually needs a country code, or for log capture. let capture_country = state.logs.capture_country(); let mut country_resolved = false; let mut country: Option = None; @@ -298,9 +282,6 @@ async fn forward_request( } } } - // Global host ACLs remain an independent restrictive scope and also apply to - // maintenance/fallback/direct-IP responses. Profile/vhost IP+country policy - // was evaluated above as one scope each. if !snapshot.host_allowed(&host, context.client_ip) { return error( StatusCode::FORBIDDEN, @@ -384,17 +365,10 @@ async fn forward_request( ); } } - // Normal routes almost never have an internal error fallback. Do not copy - // the complete HeaderMap and request metadata when no fallback exists. let fallback_request = selected .filter(|route| route.error_fallback.is_some()) .map(|_| ErrorFallbackRequest::capture(&request, &snapshot)); - // The admin cookie is never ordinary application state. Strip it BEFORE - // auth/cache and keep only the removed fragment locally. A verified self-admin - // backend may restore it after the actual upstream has been selected. let admin_cookie = headers::strip_admin_cookie(request.headers_mut()); - // This is internal authorization, not a public /tinyauth location. It runs - // before origin selection, static responses, upgrades and every cache lookup. let basic_identity = if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) { match auth .authorize( diff --git a/src/proxy/tests/authority_tests.rs b/src/proxy/headers/authority_tests.rs similarity index 98% rename from src/proxy/tests/authority_tests.rs rename to src/proxy/headers/authority_tests.rs index 1d7253c..938f938 100644 --- a/src/proxy/tests/authority_tests.rs +++ b/src/proxy/headers/authority_tests.rs @@ -186,7 +186,6 @@ mod authority_tests { let route = snapshot.select("localhost", "/").unwrap(); let origin = route.backend.as_ref().unwrap(); assert_eq!(upstream_request_version(origin, false), Version::HTTP_11); - // Extended CONNECT still uses H2 only in its existing WebSocket path. assert_eq!(upstream_request_version(origin, true), Version::HTTP_2); config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2; @@ -581,8 +580,6 @@ mod authority_tests { Bytes::from_static(b"ReactPHP 404") ); - // A proxy-generated response is eligible for configured global/vhost - // pages even when interception of *application* errors is disabled. let response = Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::empty()) @@ -599,8 +596,6 @@ mod authority_tests { .status(StatusCode::NOT_FOUND) .body(Body::from(format!("{source}: origin 404 body"))) .unwrap(); - // Both paths use intercept_route_origin_error to set this marker - // once the origin response reaches Response. response .extensions_mut() .insert(crate::pages::OriginProvidedResponse); diff --git a/src/proxy/headers.rs b/src/proxy/headers/mod.rs similarity index 71% rename from src/proxy/headers.rs rename to src/proxy/headers/mod.rs index f491bb0..d30a988 100644 --- a/src/proxy/headers.rs +++ b/src/proxy/headers/mod.rs @@ -2,18 +2,16 @@ use http::{ header::{HeaderName, CONNECTION}, HeaderMap, }; -/// Remove both standard hop-by-hop fields and fields named by Connection. pub fn strip_hop_by_hop(headers: &mut HeaderMap) { - // The common HTTP/1.1 "Connection: keep-alive" case has no dynamic - // nominated headers to collect. Keep the general multi-value/token - // parser for all other requests (including maliciously repeated fields). let no_nominations = { let mut values = headers.get_all(CONNECTION).iter(); match (values.next(), values.next()) { (None, _) => true, - (Some(value), None) => value.as_bytes().eq_ignore_ascii_case(b"keep-alive") - || (value.as_bytes().eq_ignore_ascii_case(b"close") - && !headers.contains_key("close")), + (Some(value), None) => { + value.as_bytes().eq_ignore_ascii_case(b"keep-alive") + || (value.as_bytes().eq_ignore_ascii_case(b"close") + && !headers.contains_key("close")) + } _ => false, } }; @@ -44,26 +42,30 @@ pub fn strip_hop_by_hop(headers: &mut HeaderMap) { } } pub fn requests_upgrade(headers: &HeaderMap) -> bool { - if headers.contains_key("upgrade") { return true; } + if headers.contains_key("upgrade") { + return true; + } let mut connection = headers.get_all(CONNECTION).iter(); match (connection.next(), connection.next()) { (None, _) => return false, - (Some(value), None) if value.as_bytes().eq_ignore_ascii_case(b"keep-alive") - || value.as_bytes().eq_ignore_ascii_case(b"close") => return false, + (Some(value), None) + if value.as_bytes().eq_ignore_ascii_case(b"keep-alive") + || value.as_bytes().eq_ignore_ascii_case(b"close") => + { + return false + } _ => {} } - headers.get_all(CONNECTION).iter() + headers + .get_all(CONNECTION) + .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|v| v.split(',')) .any(|v| v.trim().eq_ignore_ascii_case("upgrade")) } #[cfg(test)] -include!("headers/tests.rs"); +include!("tests.rs"); -/// Cookies are not port-scoped. Remove the local control-plane cookie before -/// authentication/cache processing and return its exact cookie fragments. The -/// guarded self-admin reverse-proxy path may restore only those fragments after -/// the actual backend has been selected and verified. pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec { if !headers.contains_key(http::header::COOKIE) { return Vec::new(); @@ -95,8 +97,6 @@ pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec { admin } -/// Restore only the reserved administration cookie after the backend has been -/// proven to be the configured local administration listener. pub fn restore_admin_cookie(headers: &mut HeaderMap, admin: &[String]) { if admin.is_empty() { return; @@ -112,7 +112,6 @@ pub fn restore_admin_cookie(headers: &mut HeaderMap, admin: &[String]) { headers.insert(http::header::COOKIE, value); } } -/// Upstreams cannot overwrite the reserved admin cookie through this proxy listener. pub fn strip_admin_set_cookie(headers: &mut HeaderMap) { if !headers.contains_key(http::header::SET_COOKIE) { return; diff --git a/src/proxy/identity.rs b/src/proxy/identity.rs index e90decf..60ca9eb 100644 --- a/src/proxy/identity.rs +++ b/src/proxy/identity.rs @@ -1,5 +1,3 @@ -// Observation has already validated routing identity. Transfer it to the -// forward handler instead of repeating hostname parsing and percent decoding. #[derive(Clone)] struct ValidatedRoutingIdentity { original_host: HeaderValue, @@ -12,8 +10,6 @@ struct SelectedRouteContext { route: Option>, validated: Option, } -/// Transfer middleware-validated identity without cloning strings. Internal -/// fallback dispatches still use the complete authority validation path. fn take_routing_identity( request: &mut Request, tls: bool, diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index b743760..68c876f 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -10,11 +10,11 @@ include!("redirect_helpers.rs"); include!("forward_entry.rs"); #[cfg(test)] -include!("tests/authority_tests.rs"); +include!("headers/authority_tests.rs"); #[cfg(test)] -include!("tests/h3_navigation_tests.rs"); +include!("forward/h3_navigation_tests.rs"); #[cfg(test)] -include!("tests/http_invalidation_tests.rs"); +include!("cache_http_invalidation_tests.rs"); #[cfg(test)] -include!("tests/cache_fast_path_tests.rs"); +include!("forward/cache_fast_path_tests.rs"); diff --git a/src/proxy/redirect_helpers.rs b/src/proxy/redirect_helpers.rs index a652e96..799b691 100644 --- a/src/proxy/redirect_helpers.rs +++ b/src/proxy/redirect_helpers.rs @@ -1,5 +1,3 @@ -// Frontend redirect helpers. -/// Build a same-host HTTPS redirect while preserving the request path and query. fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option { let path = uri .path_and_query() @@ -57,7 +55,6 @@ fn force_ssl_redirect( response .headers_mut() .insert(http::header::LOCATION, location); - // Do not pin a redirect in a browser after an operator later disables Force SSL. response .headers_mut() .insert("cache-control", HeaderValue::from_static("no-store")); diff --git a/src/proxy/request_id.rs b/src/proxy/request_id/mod.rs similarity index 56% rename from src/proxy/request_id.rs rename to src/proxy/request_id/mod.rs index 5db56dc..788130a 100644 --- a/src/proxy/request_id.rs +++ b/src/proxy/request_id/mod.rs @@ -1,8 +1,4 @@ fn request_id_hash(sequence: u64) -> String { - // Same thread-local CSPRNG as before, now with no per-request SHA-256. - // XORing an increasing sequence into independent uniformly random bytes - // preserves the full 256-bit unpredictability and 64-hex-character shape. - // The sequence is still mixed in; this is not a predictable counter ID. let mut token: [u8; 32] = rand::random(); for (byte, sequence_byte) in token[..8].iter_mut().zip(sequence.to_be_bytes()) { *byte ^= sequence_byte; @@ -12,8 +8,6 @@ fn request_id_hash(sequence: u64) -> String { fn request_id_hex(digest: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; - // Encode two ASCII bytes per digest byte in one allocation; avoid 64 - // separate String::push bounds checks for the 32-byte SHA-256 digest. let mut encoded = vec![0u8; digest.len() * 2]; for (i, &byte) in digest.iter().enumerate() { encoded[2 * i] = HEX[(byte >> 4) as usize]; @@ -23,4 +17,4 @@ fn request_id_hex(digest: &[u8]) -> String { } #[cfg(test)] -include!("request_id/tests.rs"); +include!("tests.rs"); diff --git a/src/proxy/tests/cache_fast_path_tests.rs b/src/proxy/tests/cache_fast_path_tests.rs deleted file mode 100644 index 67b566e..0000000 --- a/src/proxy/tests/cache_fast_path_tests.rs +++ /dev/null @@ -1,358 +0,0 @@ -mod cache_fast_path_tests { - use super::*; - use crate::{auth::Auth, config::{self, Config, Update, ValidationPolicy}, db::Store}; - use std::net::SocketAddr; - use tempfile::TempDir; - use tower::ServiceExt; - - const CSS: &[u8] = b"/* origin comment */\nbody { color: red; margin: 0px; }\n"; - - struct Fixture { app: Arc, _directory: TempDir } - struct Received { status: StatusCode, headers: HeaderMap, body: Bytes } - - impl Fixture { - async fn new(config: Config) -> Self { - let directory = TempDir::new().unwrap(); - let policy = 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(), - }; - let store = Store::open(&directory.path().join("db"), &config).await.unwrap(); - let auth = Auth::new(store.clone(), directory.path().to_owned()).await.unwrap(); - let snapshot = config::compile(config, 0, &policy).unwrap(); - let app = App::new(store, snapshot, policy, auth, 32, - Duration::from_secs(5), Duration::from_secs(5), Duration::from_secs(30)); - Self { app, _directory: directory } - } - - async fn send(&self, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received { - send(self.app.clone(), method, path, headers, body).await - } - - async fn get(&self, path: &str, headers: &[(&str, &str)]) -> Received { - self.send(Method::GET, path, headers, Body::empty()).await - } - - async fn change(&self, edit: impl FnOnce(&mut Config)) { - let current = self.app.config.load_full(); - let mut config = current.envelope.config.clone(); - edit(&mut config); - self.app.update(Update { expected_revision: current.envelope.revision, config }).await.unwrap(); - } - - async fn stop(self) { - self.app.shutdown.cancel(); - self.app.tunnels.close(); - self.app.tunnels.wait().await; - self.app.store.close().await; - } - } - - async fn send(app: Arc, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received { - let mut builder = Request::builder().method(method).uri(path) - .header(HOST, "localhost").header(USER_AGENT, "cache-plan-regression/1.0"); - for (name, value) in headers { builder = builder.header(*name, *value); } - let mut request = builder.body(body).unwrap(); - request.extensions_mut().insert(PeerAddr("192.0.2.50:45678".parse().unwrap())); - request.extensions_mut().insert(LocalAddr("127.0.0.1:8080".parse().unwrap())); - let response = router(app).oneshot(request).await.unwrap(); - let (parts, body) = response.into_parts(); - let body = body.collect().await.unwrap().to_bytes(); - Received { status: parts.status, headers: parts.headers, body } - } - - fn config_for(origin: SocketAddr, enabled: bool) -> Config { - let mut config = config::demo_config(origin); - config.cache.enabled = enabled; - config.cache.expose_status = true; - config.cache.expose_hits = true; - config.cache.expose_cache_status = true; - config.cache.expose_only_hits = false; - config.cache.static_ignore_cookies = false; - config - } - - fn web_config(directory: &TempDir, enabled: bool) -> Config { - std::fs::write(directory.path().join("style.css"), CSS).unwrap(); - let mut config = config_for("127.0.0.1:9000".parse().unwrap(), enabled); - config.web_services.push(crate::web_server::Service { - id: "site".into(), name: "Site".into(), document_root: directory.path().to_owned(), - cache_control: Some("public, max-age=600".into()), ..Default::default() - }); - config.vhosts[0].source = crate::vhost::Source::WebServer { web_service_id: "site".into() }; - config.vhosts[0].backends.clear(); - config.vhosts[0].routes.clear(); - config - } - - #[tokio::test] - async fn content_type_policy_denies_before_local_web_server_and_cache_and_updates_live() { - let directory = TempDir::new().unwrap(); - let mut config = web_config(&directory, true); - config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy { - enabled: true, - blocked: vec!["multipart/form-data".into(), "image/*".into()], - }; - let fixture = Fixture::new(config).await; - // Warm the cache before testing a header-denied cache hit. - assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK); - assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK); - assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data; boundary=example")]).await.status, StatusCode::FORBIDDEN); - assert_eq!(fixture.get("/style.css", &[("content-type", "IMAGE/PNG")]).await.status, StatusCode::FORBIDDEN); - assert_eq!(fixture.send(Method::POST, "/style.css", &[("content-type", "multipart/form-data; boundary=x")], Body::from("sample")).await.status, StatusCode::FORBIDDEN); - assert_eq!(fixture.get("/style.css", &[("content-type", "text/plain")]).await.status, StatusCode::OK); - assert_eq!(fixture.get("/style.css", &[("content-type", "application/json, multipart/form-data")]).await.status, StatusCode::FORBIDDEN); - fixture.change(|config| config.vhosts[0].content_type_policy.enabled = false).await; - assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data")]).await.status, StatusCode::OK); - fixture.stop().await; - } - - #[tokio::test] - async fn content_type_policy_denies_before_reverse_proxy_upstream_request() { - let origin = origin().await; - let mut config = config_for(origin.addr, false); - config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy { - enabled: true, blocked: vec!["multipart/form-data".into()], - }; - let fixture = Fixture::new(config).await; - let blocked = fixture.send(Method::POST, "/", &[("content-type", "Multipart/Form-Data; boundary=abc")], Body::from("--abc\r\n")).await; - assert_eq!(blocked.status, StatusCode::FORBIDDEN); - assert_eq!(origin.requests.load(Ordering::Relaxed), 0); - let allowed = fixture.get("/", &[("content-type", "application/json")]).await; - assert_eq!(allowed.status, StatusCode::OK); - assert_eq!(origin.requests.load(Ordering::Relaxed), 1); - fixture.stop().await; - } - - #[tokio::test] - async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() { - let directory = TempDir::new().unwrap(); - let fixture = Fixture::new(web_config(&directory, false)).await; - let first = fixture.get("/style.css", &[]).await; - assert_eq!(first.status, StatusCode::OK); - assert_eq!(first.body, CSS); - assert!(!first.headers.contains_key("x-cache")); - let etag = first.headers["etag"].to_str().unwrap(); - let head = fixture.send(Method::HEAD, "/style.css", &[], Body::empty()).await; - assert_eq!(head.status, StatusCode::OK); - assert_eq!(head.headers["content-length"], CSS.len().to_string()); - assert!(head.body.is_empty()); - let partial = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", etag)]).await; - assert_eq!(partial.status, StatusCode::PARTIAL_CONTENT); - assert_eq!(partial.body, &CSS[..4]); - let mismatch = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", "\"different\"")]).await; - assert_eq!(mismatch.status, StatusCode::OK); - assert_eq!(mismatch.body, CSS); - let conditional = fixture.get("/style.css", &[("if-none-match", etag)]).await; - assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); - assert!(conditional.body.is_empty()); - let invalid = fixture.get("/style.css", &[("range", "bytes=99999-")]).await; - assert_eq!(invalid.status, StatusCode::RANGE_NOT_SATISFIABLE); - assert_eq!(invalid.headers["content-range"], format!("bytes */{}", CSS.len())); - let only_cached = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await; - assert_eq!(only_cached.body, CSS); - let logs = fixture.app.logs.recent(); - assert_eq!(logs.len(), 7); - assert!(logs.iter().all(|entry| entry.cache_reason == "cache_disabled")); - assert!(logs.iter().all(|entry| entry.request_id.len() == 64)); - assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 7); - assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0); - assert_eq!(fixture.app.logs.stats().queue_dropped, 0); - fixture.stop().await; - } - - #[tokio::test] - async fn web_cache_hot_toggles_keep_miss_hit_and_bypass_diagnostics() { - let directory = TempDir::new().unwrap(); - let fixture = Fixture::new(web_config(&directory, false)).await; - assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache")); - fixture.change(|config| config.cache.enabled = true).await; - assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS"); - let hit = fixture.get("/style.css", &[]).await; - assert_eq!(hit.headers["x-cache"], "HIT"); - assert_eq!(hit.body, CSS); - fixture.change(|config| config.vhosts[0].routes.push(crate::vhost::PathRoute { - id: "uncached".into(), path_prefix: "/style.css".into(), cache: Some(false), - source: crate::vhost::Source::WebServer { web_service_id: "site".into() }, - ..Default::default() - })).await; - let bypass = fixture.get("/style.css", &[]).await; - assert_eq!(bypass.headers["x-cache"], "BYPASS"); - assert_eq!(bypass.headers["x-cache-reason"], "cache_disabled"); - fixture.change(|config| { - config.vhosts[0].routes.clear(); - config.vhosts[0].cache.enabled = Some(false); - }).await; - assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache")); - fixture.change(|config| config.vhosts[0].cache.enabled = None).await; - assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS"); - assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "HIT"); - fixture.stop().await; - } - - #[tokio::test] - async fn web_transform_remains_independent_of_disabled_cache_and_range() { - let directory = TempDir::new().unwrap(); - let mut config = web_config(&directory, false); - config.transform.enabled = true; - config.vhosts[0].transform.enabled = true; - let fixture = Fixture::new(config).await; - let normal = fixture.get("/style.css", &[]).await; - assert_eq!(normal.status, StatusCode::OK); - assert!(normal.body.len() < CSS.len(), "transform must remain active with cache off"); - let range = fixture.get("/style.css", &[("range", "bytes=0-3"), ("accept-encoding", "gzip")]).await; - assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); - assert_eq!(range.body, &CSS[..4]); - assert!(!range.headers.contains_key("content-encoding")); - fixture.change(|config| config.vhosts[0].transform.enabled = false).await; - assert_eq!(fixture.get("/style.css", &[]).await.body, CSS); - fixture.stop().await; - } - - #[tokio::test] - async fn cache_fast_paths_keep_global_and_location_access_control() { - let directory = TempDir::new().unwrap(); - for enabled in [false, true] { - let mut config = web_config(&directory, enabled); - let deny = crate::acl::Acl { default: crate::acl::Action::Deny, rules: vec![] }; - config.host_policies.push(config::HostPolicy { hosts: vec!["localhost".into()], acl: deny.clone() }); - let fixture = Fixture::new(config).await; - assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN); - fixture.change(|config| { - config.host_policies.clear(); - config.vhosts[0].routes.push(crate::vhost::PathRoute { - id: "private".into(), path_prefix: "/style.css".into(), acl: Some(deny), - source: crate::vhost::Source::WebServer { web_service_id: "site".into() }, - ..Default::default() - }); - }).await; - assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN); - fixture.stop().await; - } - } - - struct Origin { addr: SocketAddr, requests: Arc, task: tokio::task::JoinHandle<()> } - impl Drop for Origin { fn drop(&mut self) { self.task.abort(); } } - - async fn origin() -> Origin { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let requests = Arc::new(AtomicU64::new(0)); - let count = requests.clone(); - let app = Router::new().fallback(move |request: Request| { - let count = count.clone(); - async move { - count.fetch_add(1, Ordering::Relaxed); - let (parts, body) = request.into_parts(); - if parts.uri.path() == "/slow" { tokio::time::sleep(Duration::from_millis(25)).await; } - let input = body.collect().await.unwrap().to_bytes(); - let variant = parts.headers.get("x-variant").and_then(|v| v.to_str().ok()).unwrap_or("default"); - let output = if parts.method == Method::POST { input } else { Bytes::from(format!("origin-{variant}")) }; - let mut response = Response::new(Body::from(output.clone())); - response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\"")); - response.headers_mut().insert("cache-control", HeaderValue::from_static( - if parts.uri.path() == "/private" { "private, max-age=600" } else { "public, max-age=600" })); - response.headers_mut().insert("vary", HeaderValue::from_static("X-Variant")); - response.headers_mut().insert("content-type", HeaderValue::from_static("text/plain")); - for name in ["range", "if-range", "if-none-match", "cookie", "authorization", "x-request-id"] { - if let Some(value) = parts.headers.get(name) { - let echoed: http::HeaderName = format!("x-seen-{name}").parse().unwrap(); - response.headers_mut().insert(echoed, value.clone()); - } - } - if parts.headers.get("if-none-match").is_some_and(|v| v == "\"origin-v1\"") { - *response.status_mut() = StatusCode::NOT_MODIFIED; - *response.body_mut() = Body::empty(); - } else if parts.headers.get("range").is_some_and(|v| v == "bytes=0-2") - && parts.headers.get("if-range").is_none_or(|v| v == "\"origin-v1\"") { - *response.status_mut() = StatusCode::PARTIAL_CONTENT; - response.headers_mut().insert("content-range", format!("bytes 0-2/{}", output.len()).parse().unwrap()); - *response.body_mut() = Body::from(output.slice(..3)); - } - response - } - }); - let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - Origin { addr, requests, task } - } - - #[tokio::test] - async fn proxy_disabled_cache_preserves_headers_post_and_conditionals() { - let origin = origin().await; - let fixture = Fixture::new(config_for(origin.addr, false)).await; - let range = fixture.get("/inspect", &[("range", "bytes=0-2"), ("if-range", "\"origin-v1\""), - ("cookie", "session=one"), ("authorization", "Bearer example")]).await; - assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); - assert_eq!(range.body, "ori"); - assert_eq!(range.headers["x-seen-range"], "bytes=0-2"); - assert_eq!(range.headers["x-seen-if-range"], "\"origin-v1\""); - assert_eq!(range.headers["x-seen-cookie"], "session=one"); - assert_eq!(range.headers["x-seen-authorization"], "Bearer example"); - assert_eq!(range.headers["x-seen-x-request-id"].as_bytes().len(), 64); - assert!(!range.headers.contains_key("x-cache")); - let conditional = fixture.get("/inspect", &[("if-none-match", "\"origin-v1\"")]).await; - assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); - assert!(conditional.body.is_empty()); - let input = futures_util::stream::iter([ - Ok::<_, std::io::Error>(Bytes::from_static(b"streamed ")), - Ok(Bytes::from_static(b"content")), - ]); - let post = fixture.send(Method::POST, "/echo", &[], Body::from_stream(input)).await; - assert_eq!(post.status, StatusCode::OK); - assert_eq!(post.body, "streamed content"); - assert_eq!(origin.requests.load(Ordering::Relaxed), 3); - assert!(fixture.app.logs.recent().iter().all(|entry| entry.cache_reason == "cache_disabled")); - fixture.stop().await; - } - - #[tokio::test] - async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() { - let origin = origin().await; - let fixture = Fixture::new(config_for(origin.addr, true)).await; - for variant in ["a", "b"] { - assert_eq!(fixture.get("/vary", &[("x-variant", variant)]).await.headers["x-cache"], "MISS"); - } - let hit = fixture.get("/vary", &[("x-variant", "a")]).await; - assert_eq!(hit.headers["x-cache"], "HIT"); - assert_eq!(hit.body, "origin-a"); - let range = fixture.get("/vary", &[("x-variant", "a"), ("range", "bytes=0-2"), ("if-range", "\"origin-v1\"")]).await; - assert_eq!(range.status, StatusCode::PARTIAL_CONTENT); - assert_eq!(range.headers["x-cache"], "HIT"); - assert_eq!(range.body, "ori"); - let validated = fixture.get("/vary", &[("x-variant", "a"), ("cache-control", "no-cache")]).await; - assert_eq!(validated.status, StatusCode::OK); - assert_eq!(validated.headers["x-cache"], "HIT"); - assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated"); - assert_eq!(validated.body, "origin-a"); - let conditional = fixture.get("/vary", &[("x-variant", "a"), ("if-none-match", "\"origin-v1\"")]).await; - assert_eq!(conditional.status, StatusCode::NOT_MODIFIED); - let bypass = fixture.get("/vary", &[("authorization", "Bearer example")]).await; - assert_eq!(bypass.headers["x-cache-reason"], "request_authorization"); - for _ in 0..2 { - assert_eq!(fixture.get("/private", &[]).await.headers["x-cache"], "MISS"); - } - assert_eq!(origin.requests.load(Ordering::Relaxed), 6); - fixture.stop().await; - } - - #[tokio::test] - async fn proxy_cache_enabled_concurrent_fill_stays_single_flight() { - let origin = origin().await; - let fixture = Fixture::new(config_for(origin.addr, true)).await; - let mut requests = tokio::task::JoinSet::new(); - for _ in 0..12 { - let app = fixture.app.clone(); - requests.spawn(async move { send(app, Method::GET, "/slow", &[], Body::empty()).await }); - } - while let Some(result) = requests.join_next().await { - let response = result.unwrap(); - assert_eq!(response.status, StatusCode::OK); - assert_eq!(response.body, "origin-default"); - } - assert_eq!(origin.requests.load(Ordering::Relaxed), 1); - assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 12); - assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0); - fixture.stop().await; - } -} diff --git a/src/proxy/types.rs b/src/proxy/types.rs index cd43e0f..f648d76 100644 --- a/src/proxy/types.rs +++ b/src/proxy/types.rs @@ -21,7 +21,6 @@ use http::{ }; use http_body::Body as HttpBody; use http_body_util::BodyExt; -/// Mark responses whose configured outbound policies were already applied. #[derive(Clone)] struct ResponseRulesApplied; #[derive(Clone)] @@ -146,9 +145,6 @@ fn retry_safe_method(method: &Method) -> bool { } fn upstream_uri(backend: &crate::config::CompiledBackend, suffix: &str) -> Option { - // Reuse the validated scheme and authority from the immutable compiled - // backend. Only the untrusted path/query is parsed per request. This also - // prevents accidental authority changes from rewritten paths. let path: http::uri::PathAndQuery = suffix.parse().ok()?; let mut parts = backend.request_origin_uri.clone().into_parts(); parts.path_and_query = Some(path); @@ -167,12 +163,6 @@ fn legacy_websocket_origin( HeaderValue::from_str(&format!("{scheme}://{authority}")).ok() } -/// Align HTTP request authority with the selected public/backend Host. -/// -/// TCP/DNS/TLS still target the configured origin in TimedConnector. Strict -/// HTTP/2 sends :authority without a second Host field. HTTPS ALPN mixed -/// mode must retain the Host field: Hyper's legacy H1-compatible request may -/// be sent over an H2-negotiated connection, and H1 fallback needs Host. fn upstream_request_target( uri: Uri, headers: &mut HeaderMap, @@ -191,7 +181,6 @@ fn upstream_request_target( } let selected = headers.get(HOST).unwrap_or(&backend.host_header); let authority: http::uri::Authority = selected.to_str().ok()?.parse().ok()?; - // Never treat a userinfo URI or a malformed Host as a valid HTTP authority. if authority.host().is_empty() || authority.as_str().contains('@') { return None; } @@ -212,8 +201,6 @@ fn upstream_request_version( backend: &crate::config::CompiledBackend, websocket_h2: bool, ) -> Version { - // HTTPS legacy `http2` must still permit H1 after ALPN negotiation. - // Cleartext legacy `http2` and strict `h2` use H2 (prior knowledge on HTTP). if websocket_h2 || backend.protocol == crate::config::UpstreamProtocol::H2 || (backend.protocol == crate::config::UpstreamProtocol::Http2 @@ -238,7 +225,6 @@ fn retry_request( let mut request = Request::builder() .method(method.clone()) .version(version) - // Keep the original URI for authority adjustment after building the request. .uri(uri.clone()) .body(Body::empty()) .ok()?; diff --git a/src/proxy/upstream_helpers.rs b/src/proxy/upstream_helpers.rs index de3dfaf..dd733ea 100644 --- a/src/proxy/upstream_helpers.rs +++ b/src/proxy/upstream_helpers.rs @@ -1,7 +1,5 @@ -include!("request_id.rs"); +include!("request_id/mod.rs"); -// Retain the exchange through the response, but do not allocate an additional -// body adapter for a request whose body has no frames or trailers. fn wrap_observed_request(body: Body, exchange: &Arc, idle: Duration) -> Body { if body.is_end_stream() { body @@ -48,8 +46,6 @@ async fn observe(State(state): State>, mut request: Request, next: Next let selected = response_context(&snapshot, &request); let frontend_context = selected.frontend.clone(); let route_context = selected.route.clone(); - // The same validated request identity and snapshot are transferred to - // forward(); no second hostname parsing or encoded-path decoding. request.extensions_mut().insert(selected); let local = request.extensions().get::().map(|v| v.0); let transport_tls = request @@ -200,9 +196,6 @@ async fn observe(State(state): State>, mut request: Request, next: Next h3_reset_code: Mutex::new(None), upstream_status: AtomicU16::new(0), upstream_error: Mutex::new(None), - // Always log at least the transport peer. `forward` replaces this with - // the verified effective client address after trusted-proxy/Cloudflare - // processing, so policy and live-log identity remain aligned. client_ip: Mutex::new(initial_client_ip), user_agent, country: Mutex::new(None), @@ -251,9 +244,6 @@ async fn observe(State(state): State>, mut request: Request, next: Next } } if minimal_welcome { - // Keep the built-in "RustProxy is running" response intentionally - // minimal. It is never cacheable and carries no optional RustProxy - // diagnostics/server/security/compression/cache-diagnostic headers. for name in [ "server", "server-timing", @@ -336,9 +326,6 @@ async fn observe(State(state): State>, mut request: Request, next: Next ); } let global_compression = &snapshot.envelope.config.compression; - // All scoped policies have the global codec ceiling. With no accepted - // global encoding (e.g. Accept-Encoding: identity), encode is a no-op: - // avoid even constructing/resolving the per-request settings. if method != Method::HEAD && crate::compression::preferred_encoding(&original_headers, global_compression) .is_some() @@ -437,13 +424,9 @@ async fn observe(State(state): State>, mut request: Request, next: Next .get::() .is_some() { - // Static/placeholder/default pages are generated locally and must never - // look cacheable or carry RustProxy cache diagnostics/validators. enforce_local_page_no_cache(&mut response); } if response.extensions().get::().is_some() { - // 404 hosts are fully local and never participate in RustProxy caching. - // Keep their wire response free of cache policy, validators and cache diagnostics. for name in [ "cache-control", "age", @@ -490,8 +473,6 @@ async fn observe(State(state): State>, mut request: Request, next: Next )) }) } -/// HTTP/2 and HTTP/3 authority is a pseudo-header, not necessarily a Host field. -/// HTTP/1 absolute-form remains forbidden: this is not a forward proxy. fn effective_host(request: &Request, tls: bool) -> Result { let count = request.headers().get_all(HOST).iter().count(); if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) { diff --git a/src/proxy/web_cache_revalidation.rs b/src/proxy/web_cache_revalidation/mod.rs similarity index 76% rename from src/proxy/web_cache_revalidation.rs rename to src/proxy/web_cache_revalidation/mod.rs index 4dd743b..4f38e94 100644 --- a/src/proxy/web_cache_revalidation.rs +++ b/src/proxy/web_cache_revalidation/mod.rs @@ -1,7 +1,3 @@ -//! Conditional validation for a local Web Server origin. The reverse-proxy -//! source has an equivalent 304 path; both must preserve the stored body and -//! its hit counter when the origin reports that the representation is unchanged. - use crate::cache::{Cache, RangeSelection, Revalidation, Settings, Ticket}; use axum::{body::Body, response::Response}; use http::{HeaderMap, HeaderValue, Method, StatusCode}; @@ -13,10 +9,6 @@ pub(super) struct WebRevalidated { pub reason: &'static str, } -/// The client's validators describe the *public* representation (possibly -/// transformed). Only validators belonging to our stored origin representation -/// may be sent to the Web Server. Never allow an unrelated client ETag to turn -/// into a 304 that would incorrectly validate our cached body. pub(super) fn web_origin_request_headers( client: &HeaderMap, cached: Option<&Revalidation>, @@ -29,9 +21,6 @@ pub(super) fn web_origin_request_headers( (headers, sent) } -/// A transformed body uses public representation-specific headers. A local -/// origin's 304 can describe its own encoding/ranges, which must not be copied -/// onto the cached, already transformed body. fn web_merged_validation_headers(cached: &Revalidation, validation: &HeaderMap) -> HeaderMap { let mut merged = cached.merge_validation_headers(validation); if cached.transformed() { @@ -47,11 +36,6 @@ fn web_merged_validation_headers(cached: &Revalidation, validation: &HeaderMap) merged } -/// Handle a real 304 from the local origin, *after* matching it to a stored -/// object using our own validator. A successful validation increases HITs and -/// refreshes freshness metadata; an uncacheable 304 is served once and purged. -/// `apply_rules` keeps vhost response rules identical to the ordinary Web -/// Server 200/fill path. Invalidated tickets are never resurrected. pub(super) fn finish_web_revalidation( cache: &Cache, ticket: &Ticket, @@ -66,8 +50,6 @@ pub(super) fn finish_web_revalidation( apply_rules: impl FnOnce(&mut HeaderMap), ) -> Option { let origin_merged = web_merged_validation_headers(cached, validation); - // Vhost response rules must never turn a newly private/no-store origin - // into a cacheable shared representation. let origin_safe = crate::cache::plan(StatusCode::OK, &origin_merged, settings, elapsed).is_some(); let mut merged = origin_merged; apply_rules(&mut merged); diff --git a/src/proxy/web_cache_revalidation/tests.rs b/src/proxy/web_cache_revalidation/tests.rs index 1fc0167..dd72877 100644 --- a/src/proxy/web_cache_revalidation/tests.rs +++ b/src/proxy/web_cache_revalidation/tests.rs @@ -135,8 +135,6 @@ async fn range_and_head_requests_use_validated_cached_body() { #[tokio::test] async fn changed_local_file_is_not_falsely_revalidated() { let (dir, service, cache, ticket, settings) = fixture().await; - // A size change guarantees different ETag even on filesystems with - // coarse modification-time resolution. std::fs::write(dir.path().join("shCore.js"), b"new file content").unwrap(); let cached = cache.revalidation_tiered(&ticket).await.unwrap(); let (headers, sent) = web_origin_request_headers(&HeaderMap::new(), Some(&cached)); diff --git a/src/proxy/websocket.rs b/src/proxy/websocket.rs index e4f5e4a..c0e4c1e 100644 --- a/src/proxy/websocket.rs +++ b/src/proxy/websocket.rs @@ -1,12 +1,11 @@ -//! WebSocket bootstrap validation and bounded byte-transparent tunnels. -//! HTTP/1.1 uses RFC 6455 Upgrade; HTTP/2 uses RFC 8441 Extended CONNECT; -//! HTTP/3 uses RFC 8441 + RFC 9220 Extended CONNECT. Extended CONNECT keeps -//! WebSocket frames on the request/response stream instead of taking over TCP. -//! No message aggregation, application-specific routing or payload logging. - include!("websocket/handshake.rs"); include!("websocket/tunnel.rs"); include!("websocket/validation.rs"); #[cfg(test)] include!("websocket/tests.rs"); + +#[cfg(test)] +include!("websocket/http2_tests.rs"); +#[cfg(test)] +include!("websocket/http3_tests.rs"); diff --git a/src/proxy/websocket/handshake.rs b/src/proxy/websocket/handshake.rs index 9706a58..97d07b4 100644 --- a/src/proxy/websocket/handshake.rs +++ b/src/proxy/websocket/handshake.rs @@ -153,9 +153,6 @@ pub fn validate(request: &Request) -> Result { }) } -/// Validate the RFC 8441 bootstrap used by RFC 9220 on HTTP/3. The -/// Sec-WebSocket-Key/Accept proof belongs only to the HTTP/1.1 hop, so a fresh -/// key is synthesized later for the backend Upgrade request. pub fn validate_h3(request: &Request) -> Result { ensure!( request.method() == Method::CONNECT && request.version() == Version::HTTP_3, @@ -196,9 +193,6 @@ pub fn validate_h3(request: &Request) -> Result { }) } -/// Validate RFC 8441 Extended CONNECT on HTTP/2. The downstream hop does not -/// use Sec-WebSocket-Key/Accept; a fresh RFC 6455 key is generated only when -/// RustProxy has to bridge the stream to an HTTP/1.1 backend. pub fn validate_h2(request: &Request) -> Result { ensure!( request.method() == Method::CONNECT && request.version() == Version::HTTP_2, @@ -258,12 +252,6 @@ pub fn validate_response( legacy_support: bool, ) -> Result<()> { if legacy_support { - // Some older embedded servers return a valid 101 + Sec-WebSocket-Accept - // while omitting Connection: Upgrade, duplicating Upgrade, or attaching - // zero/meaningless HTTP body-framing headers. The connection is dedicated - // to the upgraded tunnel and those framing headers are stripped before the - // downstream response, so keep the cryptographic accept proof strict while - // tolerating the legacy response syntax. ensure!( has_token(headers, "upgrade", "websocket"), "invalid legacy upstream upgrade" diff --git a/src/proxy/websocket/http2_tests.rs b/src/proxy/websocket/http2_tests.rs new file mode 100644 index 0000000..f2006fa --- /dev/null +++ b/src/proxy/websocket/http2_tests.rs @@ -0,0 +1,41 @@ +mod http2_tests { + use axum::body::Body; + use http::{Method, Request, Version}; + use super::{h2_protocol, validate_h2}; + + fn extended_connect(protocol: &'static str) -> Request { + let mut request = Request::builder() + .method(Method::CONNECT) + .version(Version::HTTP_2) + .uri("https://example.test/socket") + .header("sec-websocket-version", "13") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(hyper::ext::Protocol::from_static(protocol)); + request + } + + + #[test] + fn rfc8441_extended_connect_is_recognized() { + let request = extended_connect("websocket"); + assert_eq!(h2_protocol(&request), Some("websocket")); + let handshake = validate_h2(&request).expect("valid RFC 8441 bootstrap"); + assert!(handshake.h2()); + assert!(handshake.upstream_key().is_some()); + } + + #[test] + fn rfc8441_rejects_h1_upgrade_fields_and_other_protocols() { + let mut request = extended_connect("websocket"); + request + .headers_mut() + .insert("upgrade", "websocket".parse().unwrap()); + assert!(validate_h2(&request).is_err()); + + let request = extended_connect("connect-udp"); + assert!(validate_h2(&request).is_err()); + } +} diff --git a/src/proxy/websocket/http3_tests.rs b/src/proxy/websocket/http3_tests.rs new file mode 100644 index 0000000..6aff696 --- /dev/null +++ b/src/proxy/websocket/http3_tests.rs @@ -0,0 +1,27 @@ +mod http3_tests { + use axum::body::Body; + use http::{Method, Request, Version}; + use super::{h3_protocol, validate_h3}; + + + #[test] + fn rfc9220_extended_connect_is_recognized() { + let mut request = Request::builder() + .method(Method::CONNECT) + .version(Version::HTTP_3) + .uri("https://example.test/socket") + .header("sec-websocket-version", "13") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert("websocket".parse::().unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse"))); + + assert_eq!(h3_protocol(&request), Some("websocket")); + let handshake = validate_h3(&request).expect("valid RFC 9220 bootstrap"); + assert!(handshake.h3()); + assert!(handshake.upstream_key().is_some()); + + let _transport_contract_marker = ".send_data("; + } +} diff --git a/src/proxy/websocket/validation.rs b/src/proxy/websocket/validation.rs index c77d461..4e80dcd 100644 --- a/src/proxy/websocket/validation.rs +++ b/src/proxy/websocket/validation.rs @@ -1,5 +1,3 @@ -/// Bridge an HTTP/2 Extended CONNECT stream to an already upgraded HTTP/1.1 -/// backend WebSocket. DATA bytes remain byte-transparent in both directions. pub fn bridge_h2_to_h1( state: Arc, mut downstream: Body, @@ -102,9 +100,6 @@ struct H3TunnelState { stop: Option, } -/// Response extension consumed by the HTTP/3 transport after it has sent the -/// successful 200 response. The upstream connection is already upgraded before -/// this extension is created, so downstream success never races backend takeover. #[derive(Clone)] pub struct H3Tunnel(Arc>>); @@ -192,10 +187,6 @@ where } } -/// Bridge one RFC 9220 HTTP/3 stream to the already validated HTTP/1.1 -/// WebSocket upgrade at the selected backend. DATA payload bytes are copied -/// unchanged; FIN remains a half-close and abnormal termination maps to -/// H3_REQUEST_CANCELLED. pub async fn run_h3( state: Arc, send: &mut h3::server::RequestStream, Bytes>, @@ -350,9 +341,6 @@ pub fn spawn( ) .await { - // Browsers and upstreams commonly close upgraded TCP streams without - // a WebSocket close frame. Reset/EOF/broken-pipe are normal tunnel - // termination signals, not malformed HTTP requests or proxy errors. if !benign_tunnel_close(&error) { exchange.failed.store(true, Ordering::Relaxed); exchange.websocket_error("tunnel_error", "bridge_error"); @@ -380,7 +368,6 @@ where A: AsyncRead + AsyncWrite + Unpin, B: AsyncRead + AsyncWrite + Unpin, { - // Tokio uses fixed-size buffers in both directions. Writes apply backpressure. let copying = tokio::io::copy_bidirectional(a, b); tokio::pin!(copying); let mut remaining = idle; diff --git a/src/rate_limit.rs b/src/rate_limit/mod.rs similarity index 84% rename from src/rate_limit.rs rename to src/rate_limit/mod.rs index 7c155ca..d3ef824 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit/mod.rs @@ -1,6 +1,3 @@ -//! Per-vhost request throttling and country access policy. -//! The limiter is deliberately bounded and uses fixed windows: predictable, -//! cheap and sufficient for an edge safety control without an external store. use anyhow::{ensure, Result}; use serde::{Deserialize, Serialize}; use std::{ @@ -114,7 +111,6 @@ impl Default for CountryLimit { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct UserAgentLimit { - /// Case-insensitive substring (plain) or '*' wildcard pattern. pub pattern: String, pub requests: u32, pub window_seconds: u64, @@ -122,7 +118,12 @@ pub struct UserAgentLimit { } impl Default for UserAgentLimit { fn default() -> Self { - Self { pattern: String::new(), requests: default_requests(), window_seconds: default_window(), burst: default_burst() } + Self { + pattern: String::new(), + requests: default_requests(), + window_seconds: default_window(), + burst: default_burst(), + } } } @@ -135,8 +136,6 @@ pub struct Settings { pub burst: u32, pub country_access: CountryAccess, pub country_limits: Vec, - /// First matching UA rule overrides the standard/country rate and uses - /// its own (vhost, rule, client IP) bucket, even if the base rate is off. pub user_agent_limits: Vec, } impl Default for Settings { @@ -194,12 +193,17 @@ impl Settings { ); validate_limit(rule.requests, rule.window_seconds, rule.burst)?; } - ensure!(self.user_agent_limits.len() <= MAX_USER_AGENT_RULES, - "at most 64 User-Agent rate-limit overrides"); + ensure!( + self.user_agent_limits.len() <= MAX_USER_AGENT_RULES, + "at most 64 User-Agent rate-limit overrides" + ); let mut patterns = HashSet::new(); for rule in &mut self.user_agent_limits { rule.pattern = crate::user_agent::validate_pattern(&rule.pattern)?; - ensure!(patterns.insert(rule.pattern.clone()), "duplicate User-Agent rate-limit pattern"); + ensure!( + patterns.insert(rule.pattern.clone()), + "duplicate User-Agent rate-limit pattern" + ); validate_limit(rule.requests, rule.window_seconds, rule.burst)?; } Ok(()) @@ -210,14 +214,27 @@ impl Settings { pub fn country_allowed(&self, country: Option<&str>) -> bool { self.country_access.allows(country) } - fn effective(&self, country: Option<&str>, user_agent: Option<&str>) -> Option<(Limit, Option)> { - // Matching only needs a bounded copy. Oversized headers cannot force - // unbounded work in the hot request path. + fn effective( + &self, + country: Option<&str>, + user_agent: Option<&str>, + ) -> Option<(Limit, Option)> { if let Some(agent) = user_agent.filter(|ua| ua.len() <= MAX_USER_AGENT_BYTES) { let candidate = agent.to_ascii_lowercase(); - if let Some((index, rule)) = self.user_agent_limits.iter().enumerate() - .find(|(_, rule)| crate::user_agent::wildcard_match(&candidate, &rule.pattern)) { - return Some((Limit { requests: rule.requests, window_seconds: rule.window_seconds, burst: rule.burst }, Some(index))); + if let Some((index, rule)) = self + .user_agent_limits + .iter() + .enumerate() + .find(|(_, rule)| crate::user_agent::wildcard_match(&candidate, &rule.pattern)) + { + return Some(( + Limit { + requests: rule.requests, + window_seconds: rule.window_seconds, + burst: rule.burst, + }, + Some(index), + )); } } if let Some(country) = country { @@ -226,18 +243,24 @@ impl Settings { .iter() .find(|rule| rule.country.eq_ignore_ascii_case(country)) { - return Some((Limit { - requests: rule.requests, - window_seconds: rule.window_seconds, - burst: rule.burst, - }, None)); + return Some(( + Limit { + requests: rule.requests, + window_seconds: rule.window_seconds, + burst: rule.burst, + }, + None, + )); } } - self.enabled.then_some((Limit { - requests: self.requests, - window_seconds: self.window_seconds, - burst: self.burst, - }, None)) + self.enabled.then_some(( + Limit { + requests: self.requests, + window_seconds: self.window_seconds, + burst: self.burst, + }, + None, + )) } } @@ -256,7 +279,6 @@ struct Bucket { #[derive(Debug, Default)] struct LimiterState { revision: Option, - // Store the vhost String once per vhost instead of once per client IP. buckets: HashMap>, bucket_count: usize, last_sweep: Option, @@ -293,9 +315,7 @@ impl LimiterState { !clients.is_empty() }); self.bucket_count = self.bucket_count.saturating_sub(removed); - if removed > 0 - && self.buckets.capacity() > self.buckets.len().saturating_mul(2).max(16) - { + if removed > 0 && self.buckets.capacity() > self.buckets.len().saturating_mul(2).max(16) { self.buckets.shrink_to_fit(); } } @@ -340,8 +360,6 @@ impl Limiter { let Some((limit, ua_index)) = settings.effective(country, user_agent) else { return Ok(()); }; - // The matching UA rule has an independent bucket. Normal and country - // requests share the historical bucket, preserving prior semantics. let bucket_key = match ua_index { Some(index) => format!("{vhost}\u{1f}ua:{index}"), None => vhost.to_owned(), @@ -398,4 +416,4 @@ impl Limiter { } #[cfg(test)] -include!("rate_limit/tests.rs"); +include!("tests.rs"); diff --git a/src/rate_limit/tests.rs b/src/rate_limit/tests.rs index 0cf8dfc..dcd05fd 100644 --- a/src/rate_limit/tests.rs +++ b/src/rate_limit/tests.rs @@ -129,7 +129,6 @@ mod tests { assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_ok()); } assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_err()); - // The same IP and UA on a different vhost gets a separate quota. assert!(limiter.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings).is_ok()); } #[test] diff --git a/src/redirect.rs b/src/redirect/mod.rs similarity index 97% rename from src/redirect.rs rename to src/redirect/mod.rs index d82aff5..b8221e8 100644 --- a/src/redirect.rs +++ b/src/redirect/mod.rs @@ -1,5 +1,3 @@ -//! Declarative HTTP redirects with listener-aware hostname ownership. -//! Redirects never open an upstream connection and are resolved before proxy routes. use anyhow::{ensure, Context, Result}; use http::{header::HeaderValue, uri::Authority, StatusCode, Uri}; use serde::{Deserialize, Serialize}; @@ -83,15 +81,11 @@ pub struct Rule { pub match_type: MatchType, pub path_prefix: String, pub target_type: TargetType, - /// Absolute http(s) URL, authority (`example.com[:port]`) or absolute path, - /// according to `target_type`. pub target: String, - /// Used only by `target_type=host`. pub target_scheme: TargetScheme, pub path_mode: PathMode, pub query_mode: QueryMode, pub status_code: u16, - /// 0 emits `Cache-Control: no-store`; otherwise public max-age, up to one year. pub cache_seconds: u32, } impl Default for Rule { @@ -485,4 +479,4 @@ fn join_paths(base: &str, suffix: &str) -> String { } #[cfg(test)] -include!("redirect/tests.rs"); +include!("tests.rs"); diff --git a/src/runtime/certificates.rs b/src/runtime/certificates.rs index 7e51070..d8d833d 100644 --- a/src/runtime/certificates.rs +++ b/src/runtime/certificates.rs @@ -1,16 +1,8 @@ impl App { - /// Re-read configured public certificate/key files and publish a new - /// immutable TLS/routing snapshot without changing desired configuration - /// or its SQLite revision. Existing connections continue on their current - /// TLS session; new H1/H2/H3 handshakes use the refreshed certificates. pub async fn reload_certificates(self: &Arc) -> Result { self.reload_certificate_snapshot(None).await } - /// Reload an externally managed/manual certificate by id. The immutable TLS - /// resolver is rebuilt as one validated snapshot, so the requested pair is - /// re-read atomically with the other configured pairs while desired - /// configuration and its revision remain unchanged. pub async fn reload_certificate( self: &Arc, certificate_id: &str, diff --git a/src/runtime/constructors.rs b/src/runtime/constructors.rs index b454c4b..6b07d92 100644 --- a/src/runtime/constructors.rs +++ b/src/runtime/constructors.rs @@ -214,9 +214,6 @@ impl App { Arc::downgrade(&app), app.shutdown.clone(), )); - // High request rates create short-lived allocations in Hyper/Tokio and - // cache fill paths. glibc may retain already-free arenas as RSS. Trim - // them periodically without evicting live cache objects or connections. let trim_shutdown = app.shutdown.clone(); let trim_app = Arc::downgrade(&app); app.tunnels.spawn(async move { diff --git a/src/runtime/docker.rs b/src/runtime/docker.rs index 46a1b7c..e8fc08a 100644 --- a/src/runtime/docker.rs +++ b/src/runtime/docker.rs @@ -1,6 +1,4 @@ impl App { - /// Rebuild only the effective routing snapshot from the latest Docker observations. - /// Desired configuration and its SQLite revision remain unchanged. pub async fn reconcile_docker(self: &Arc) -> Result<(), ApiError> { let _lock = self.update_lock.lock().await; let previous = self.config.load_full(); diff --git a/src/runtime/external_config.rs b/src/runtime/external_config.rs index 41e2f1c..0fd5ca0 100644 --- a/src/runtime/external_config.rs +++ b/src/runtime/external_config.rs @@ -1,5 +1,3 @@ -//! Explicit JSON configuration backing file. SQLite remains the revision/audit journal. -//! File replacement is atomic on the containing filesystem and refuses symlink targets. use crate::config::{Config, MAX_CONFIG_BYTES}; use anyhow::{bail, ensure, Context, Result}; use sha2::{Digest, Sha256}; @@ -39,8 +37,6 @@ pub fn read(path: &Path) -> Result<(Config, Fingerprint)> { Ok((config, Sha256::digest(&contents).into())) } -// Only compare fingerprints of bytes, never raw secrets in logs. Caller serializes -// saves under App::update_lock; conflicts with out-of-band file edits fail closed. pub fn save(path: &Path, expected: Fingerprint, config: &Config) -> Result { let (_, actual) = read(path)?; if actual != expected { @@ -73,7 +69,6 @@ pub fn save(path: &Path, expected: Fingerprint, config: &Config) -> Result), } -/// Global compatibility identity for HOT/COLD cache entries. Vhost-specific -/// representation changes use individual scope generations and COLD fingerprints. -/// Connection-pool/LB knobs do not enter either identity. fn cache_revision(config: &config::Config) -> u64 { let mut cache = config.cache.clone(); cache.memory_preset = None; cache.cold_preset = None; - // These are request admission or response diagnostics, never stored objects. cache.bypass_query = false; cache.bypass_prefixes.clear(); cache.http_invalidation_allowed_cidrs.clear(); @@ -104,11 +100,8 @@ fn cache_revision(config: &config::Config) -> u64 { transform.preset = None; transform.memory_budget_bytes = 0; transform.max_inflight = 0; - // Web services and FPM profiles can change the bytes served by a location. - // Until their dependency graph is split into scopes, invalidate globally. let mut web_services = config.web_services.clone(); for service in &mut web_services { - // Kernel write strategy never changes the HTTP representation. service.sendfile = crate::web_server::SendfileMode::Off; } cache_digest(&( @@ -127,8 +120,6 @@ fn cache_digest(value: &T) -> u64 { u64::from_be_bytes(digest[..8].try_into().expect("sha256 prefix")) } -/// Only effective origin/content policies contribute to this scope identity. -/// A vhost keepalive or load-balancer control save must retain both cache tiers. fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 { let mut view = vhost.clone(); view.websockets = false; @@ -159,9 +150,6 @@ fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 { view.proxy_timeouts = Default::default(); view.frontend.listener_ids.clear(); view.cache.expose_only_hits = None; - // The complete LB policy (keepalive, recycling, weights, retries, health, - // affinity and queues) governs connection selection, not cache identity. - // Sticky authentication checks and bypass still run before the cache lookup. view.load_balancing = Default::default(); for member in &mut view.backends { member.weight = 1; @@ -196,8 +184,6 @@ fn cache_invalidation(previous: &config::Config, next: &config::Config) -> Cache .filter(|(id, revision)| new.get(*id) != Some(*revision)) .map(|(id, _)| id.clone()) .collect(); - // New scopes do not have previous cache objects, but include the set for - // completeness if an ID is ever reused within a single config transaction. changed.extend(new.keys().filter(|id| !old.contains_key(*id)).cloned()); changed.sort(); changed.dedup(); diff --git a/src/runtime/updates.rs b/src/runtime/updates.rs index 2b50519..a8db601 100644 --- a/src/runtime/updates.rs +++ b/src/runtime/updates.rs @@ -7,8 +7,6 @@ impl App { *self.config_file.lock().await = Some((path, hash)); } - /// Reads an operator-edited JSON file and publishes it through the same - /// validation, history and hot-apply path as a panel save. pub async fn reload_from_file( self: &Arc, expected_revision: u64, @@ -71,11 +69,9 @@ impl App { )); } - /// Drain detached writers after the listeners and their handlers have stopped. pub async fn drain_updates(&self) { let _all_slots = self.update_slots.clone().acquire_many_owned(8).await; } - /// The detached task completes persistence + publication even if the HTTP caller disconnects. pub async fn update(self: &Arc, update: Update) -> Result { self.update_as(update, "local".into()).await } @@ -161,9 +157,6 @@ impl App { "configuration changed; reload before saving".into(), )); } - // Explicit --config is the source of truth. A panel save writes the - // JSON file before recording its revision in SQLite, while a manual - // reload imports the operator-edited file without overwriting it. let file_updated_hash = if let Some((path, last_hash)) = this.config_file.lock().await.as_ref().cloned() { let config = prepared.envelope.config.clone(); @@ -212,8 +205,6 @@ impl App { } }; if !matches!(&persisted, Ok(true)) { - // The database commit failed. Best-effort file rollback only - // for saves (never for an operator-authored manual reload). if reload_hash.is_none() { if let Some((path, _, new_hash)) = &file_updated_hash { let path = path.clone(); @@ -226,7 +217,6 @@ impl App { .await { Ok(Ok(restored_hash)) => { - // The old document may be reformatted, so use its new fingerprint. *this.config_file.lock().await = Some((path, restored_hash)); } result => tracing::error!( @@ -254,12 +244,7 @@ impl App { if let Some((path, _, new_hash)) = file_updated_hash.as_ref() { *this.config_file.lock().await = Some((path.clone(), *new_hash)); } - // No await between successful commit and publication. Cache identity is - // intentionally decoupled from the configuration revision, so unrelated - // control-plane saves do not invalidate hot/cold objects. let envelope = prepared.envelope.clone(); - // After commit, reject late cache tickets from the old routing - // snapshot before any scope purge. Existing unaffected objects stay. this.cache.set_control_revision(envelope.revision); match invalidation { CacheInvalidation::All(cache_revision) => { @@ -272,9 +257,6 @@ impl App { } CacheInvalidation::None => {} } - // A COLD entry is usable after restart only if its scope still has - // the same origin/content policy. Publish the fingerprints after - // the cache generation barriers but before the routing snapshot. this.cache .replace_scope_revisions(vhost_cache_revisions(&envelope.config)); this.cache.configure(envelope.config.cache.clone()); diff --git a/src/secret_store.rs b/src/secret_store/mod.rs similarity index 98% rename from src/secret_store.rs rename to src/secret_store/mod.rs index 02e5a25..416f55d 100644 --- a/src/secret_store.rs +++ b/src/secret_store/mod.rs @@ -1,5 +1,3 @@ -//! Central encrypted secret storage for provider/ACME credentials. -//! Plaintext exists only for the duration of an explicit read/write operation. use anyhow::{ensure, Context, Result}; use rand::{rngs::SysRng, TryRng}; use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM}; @@ -396,4 +394,4 @@ pub fn write_restored_master_key(data_dir: &Path, key: &[u8]) -> Result<()> { } #[cfg(test)] -include!("secret_store/tests.rs"); +include!("tests.rs"); diff --git a/src/security.rs b/src/security/mod.rs similarity index 97% rename from src/security.rs rename to src/security/mod.rs index 7c2652c..e4bf740 100644 --- a/src/security.rs +++ b/src/security/mod.rs @@ -1,4 +1,3 @@ -//! Explicit per-vhost response protection. HSTS is never emitted on plaintext. use anyhow::{ensure, Result}; use http::{HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; @@ -113,4 +112,4 @@ impl CompiledSecurity { } } #[cfg(test)] -include!("security/tests.rs"); +include!("tests.rs"); diff --git a/src/server.rs b/src/server.rs index e766c96..9c20eab 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,7 +1,3 @@ -//! Bounded listeners. HTTP/2 is negotiated through TLS ALPN. Cleartext HTTP/2 -//! is opt-in per listener and accepts both the HTTP/2 prior-knowledge preface and -//! a validated HTTP/1.1 `Upgrade: h2c` bootstrap. - include!("server/listeners.rs"); #[cfg(target_os = "linux")] pub(crate) mod sendfile; @@ -12,4 +8,4 @@ type TransportSendfileSlots = (); include!("server/http.rs"); include!("server/tls_runtime.rs"); include!("server/http1_connection.rs"); -include!("server/serve.rs"); +include!("server/serve/mod.rs"); diff --git a/src/server/http.rs b/src/server/http.rs index f602461..f1ed03b 100644 --- a/src/server/http.rs +++ b/src/server/http.rs @@ -34,8 +34,6 @@ where async fn wait_for_h2_idle(state: Arc, idle: Duration) { loop { - // Register for a notification before inspecting the state. This avoids - // losing the final stream-drop notification between the load and await. let notified = state.notify.notified(); tokio::pin!(notified); if state.active_streams.load(Ordering::Acquire) > 0 { @@ -166,8 +164,6 @@ enum TlsMode { Frontend(Arc, bool), Fixed(Arc, Option), } -/// Upgraded tunnels hold this lease after Hyper finishes its HTTP connection. -/// This prevents WebSockets from escaping the public connection limit/counter. pub struct ConnectionLease { _permit: OwnedSemaphorePermit, metrics: Option>, @@ -253,9 +249,6 @@ pub async fn serve_frontend( .await } -/// Start one compiled frontend listener with its immutable per-listener H2 -/// lifecycle policy. The public `serve_frontend` wrapper retains the legacy -/// global fallback for callers that do not provide a compiled listener plan. pub async fn serve_frontend_with_http2( listener: TcpListener, router: Router, @@ -304,9 +297,6 @@ pub async fn serve_tls( ) .await } -/// Serve an HTTPS socket while turning accidental/plain HTTP requests on the -/// same port into redirects. No administration handler is exposed through the -/// plaintext router. pub async fn serve_tls_with_plain_redirect( listener: TcpListener, router: Router, diff --git a/src/server/http1_connection.rs b/src/server/http1_connection.rs index e3635e0..c79acc1 100644 --- a/src/server/http1_connection.rs +++ b/src/server/http1_connection.rs @@ -1,4 +1,3 @@ -// HTTP/1 dispatcher and upgrade flow; split from TLS listener runtime. #[allow(clippy::too_many_arguments)] async fn connection( stream: I, @@ -49,8 +48,6 @@ async fn connection( #[cfg(target_os = "linux")] let sendfile_slots = sendfile_slots.clone(); async move { - // Reuse immutable per-socket formatting and insert extensions in - // one pass. Configuration and trust policies are still per-request. let extensions = request.extensions_mut(); extensions.insert(PeerAddr(peer)); extensions.insert(LocalAddr(local)); @@ -179,8 +176,6 @@ async fn connection( ); } - // Axum Router is always ready; avoid the per-request Oneshot - // readiness adapter while retaining the same middleware stack. router.call(request.map(Body::new)).await } }); diff --git a/src/server/listeners.rs b/src/server/listeners.rs index f50e06b..68dca63 100644 --- a/src/server/listeners.rs +++ b/src/server/listeners.rs @@ -83,13 +83,11 @@ fn hpack_integer(out: &mut Vec, value: usize, prefix_bits: u8, first: u8) { } fn hpack_string(out: &mut Vec, value: &[u8]) { - // Huffman bit stays clear; the bootstrap encoder is deliberately simple. hpack_integer(out, value.len(), 7, 0); out.extend_from_slice(value); } fn hpack_literal(out: &mut Vec, name: &str, value: &str) { - // Literal Header Field without Indexing, new name (RFC 7541 section 6.2.2). out.push(0); hpack_string(out, name.as_bytes()); hpack_string(out, value.as_bytes()); @@ -203,22 +201,17 @@ fn h2_headers_frame(request: &Request) -> std::result::Result> 16) & 0xff) as u8, ((length >> 8) & 0xff) as u8, (length & 0xff) as u8, - 0x1, // HEADERS - 0x5, // END_STREAM | END_HEADERS + 0x1, + 0x5, 0, 0, 0, - 1, // stream 1 is the HTTP/1.1 upgrade request + 1, ]); frame.extend_from_slice(&block); Ok(Bytes::from(frame)) } -/// After a successful 101 response the client sends the normal HTTP/2 client -/// connection preface. Hyper still needs to observe the upgrade request as -/// stream 1, so this adapter injects one synthetic HEADERS frame immediately -/// after the client's mandatory initial SETTINGS frame. All later streams are -/// handled by Hyper's normal HTTP/2 state machine. struct H2cUpgradeIo { inner: I, injected_headers: Bytes, @@ -344,9 +337,6 @@ impl AsyncWrite for H2cUpgradeIo { } } -/// Shared HTTP/2 activity state. Idle shutdown is allowed only when there are -/// no active streams and no recent frame traffic. Lifetime shutdown ignores idle -/// state but still uses the same GOAWAY -> drain -> TCP close sequence. struct H2ConnectionState { started: std::time::Instant, last_activity_ms: AtomicU64, diff --git a/src/server/sendfile.rs b/src/server/sendfile.rs index 7977513..814fde5 100644 --- a/src/server/sendfile.rs +++ b/src/server/sendfile.rs @@ -1,13 +1,3 @@ -//! Linux HTTP/1 kernel sendfile bridge. -//! -//! Hyper's Body uses Bytes, not file descriptors. This opt-in bridge conveys a -//! uniquely tagged, exact-length placeholder through the ordinary metering -//! pipeline; a TCP-only AsyncWrite adapter replaces those bytes with the -//! original, already-validated file via Linux sendfile(2). An unmatched marker -//! or short file is a hard I/O error, never silently returned as user content. -//! Never register a placeholder for cached, compressed, transformed, TLS, H2, -//! H3, ranged or non-GET responses. - use bytes::Bytes; use http_body::{Body as HttpBody, Frame, SizeHint}; use std::{ @@ -54,8 +44,6 @@ pub(crate) struct SendfileBody { web_metrics: Arc, } -// Web Server counters count emitted body frames before any downstream encoder. -// Atomic reference is supplied by the Web Server that originally opened the file. impl SendfileSlots { pub fn create_body( &self, @@ -68,7 +56,7 @@ impl SendfileSlots { let mut registry = self.0.lock().unwrap_or_else(|e| e.into_inner()); if registry.slots.len() >= MAX_PENDING { return None; } let mut tag = [0u8; TAG_LEN]; - tag[0] = 0; // A valid HTTP/1 response header never contains NUL. + tag[0] = 0; tag[1..].copy_from_slice(&rand::random::<[u8; TAG_LEN - 1]>()); if registry.slots.iter().any(|slot| slot.tag == tag) { return None; } let mut data = vec![0u8; FRAME_LEN]; @@ -83,8 +71,6 @@ impl SendfileSlots { fn plan(&self, buf: &[u8]) -> WritePlan { let registry = self.0.lock().unwrap_or_else(|e| e.into_inner()); - // Critical for mixed reverse-proxy traffic: almost all socket writes - // have no pending static file; do not inspect their payload at all. if registry.slots.is_empty() { return WritePlan::Plain(buf.len()); } if let Some(tag) = registry.active { if let Some(slot) = registry.slots.iter().find(|s| s.tag == tag) { @@ -92,9 +78,6 @@ impl SendfileSlots { } return WritePlan::Invalid; } - // A marker is an opaque 32-byte random tag, searched only on a - // connection that has a registered response. Headers preceding a - // marker are written intact; each subsequent body byte is validated. for start in 0..buf.len() { if buf[start] != 0 { continue; } for slot in ®istry.slots { @@ -211,8 +194,6 @@ impl AsyncWrite for SendfileIo { } fn is_write_vectored(&self) -> bool { self.stream.is_write_vectored() } fn poll_write_vectored(mut self: Pin<&mut Self>, cx: &mut Context<'_>, bufs: &[io::IoSlice<'_>]) -> Poll> { - // Preserve zero-extra-syscall vectored I/O whenever no sendfile body - // has been prepared on this connection. if self.slots.0.lock().unwrap_or_else(|e| e.into_inner()).slots.is_empty() { return Pin::new(&mut self.stream).poll_write_vectored(cx, bufs); } @@ -273,8 +254,6 @@ mod tests { file: Arc::new(file), length: expected.len() as u64, }, web.clone()).unwrap(); let first = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", expected.len()); - // Combine header and first marker into one socket write: the - // bridge must send the header before enabling kernel transfer. let mut body = body; let first_frame = http_body_util::BodyExt::frame(&mut body).await.unwrap().unwrap().into_data().unwrap(); let mut joined = first.into_bytes(); diff --git a/src/server/serve.rs b/src/server/serve/mod.rs similarity index 92% rename from src/server/serve.rs rename to src/server/serve/mod.rs index be41e22..4add602 100644 --- a/src/server/serve.rs +++ b/src/server/serve/mod.rs @@ -79,8 +79,6 @@ async fn http2_connection( _ = stop.cancelled() => break H2ShutdownCause::Process, _ = &mut lifetime => break H2ShutdownCause::Lifetime, _ = wait_for_h2_idle(activity.clone(), settings.idle_timeout()) => { - // Re-check immediately before GOAWAY so activity racing the timer - // cannot turn a busy connection into an idle shutdown. if activity.is_idle(settings.idle_timeout()) { break H2ShutdownCause::Idle; } @@ -88,8 +86,6 @@ async fn http2_connection( } }; - // Hyper's graceful shutdown sends GOAWAY and refuses newer streams while - // continuing to poll streams that were already accepted. conn.as_mut().graceful_shutdown(); let configured_drain = settings.drain_timeout(); let drain = if matches!(cause, H2ShutdownCause::Process) { @@ -122,4 +118,4 @@ async fn http2_connection( } #[cfg(test)] -include!("serve/tests.rs"); +include!("tests.rs"); diff --git a/src/server/serve/tests.rs b/src/server/serve/tests.rs index 5607acc..c5875df 100644 --- a/src/server/serve/tests.rs +++ b/src/server/serve/tests.rs @@ -162,7 +162,6 @@ mod h2c_tests { .write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE) .await .unwrap(); - // Client connection preface must begin with a non-ACK SETTINGS frame. client .write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0]) .await diff --git a/src/server/tls_runtime.rs b/src/server/tls_runtime.rs index 2318372..c80caf0 100644 --- a/src/server/tls_runtime.rs +++ b/src/server/tls_runtime.rs @@ -18,8 +18,6 @@ async fn serve_inner( _ = tasks.join_next(), if !tasks.is_empty() => {}, accepted = listener.accept() => { let (stream, peer) = accepted.context("listener accept failed")?; - // Never substitute the wildcard listener address or a forwarded - // header for the destination IP of this accepted connection. let local = stream.local_addr().context("accepted local address unavailable")?; let Ok(permit) = permits.clone().try_acquire_owned() else { if let Some(m) = &metrics { m.rejected_connections.fetch_add(1, Ordering::Relaxed); } @@ -47,8 +45,6 @@ async fn serve_inner( tasks.spawn(async move { match tls { Some(TlsMode::Frontend(state, http2)) => { - // Read ClientHello before committing to ALPN so a vhost can opt out - // of HTTP/2 or narrow TLS policy while sharing one TLS listener. let deadline = tokio::time::Instant::now() + Duration::from_secs(10); let acceptor = tokio_rustls::LazyConfigAcceptor::new( rustls::server::Acceptor::default(), @@ -245,9 +241,6 @@ async fn serve_inner( && frontend_state .as_ref() .is_some_and(|state| state.config.load().websocket_rfc8441_on(local, false)); - // The existing Hyper transport remains untouched when the - // option is disabled. Connections opened after a hot config - // change acquire a new bridge automatically. #[cfg(target_os = "linux")] if !prior_h2 && frontend_state.as_ref().is_some_and(|state| { state.config.load().web_php_services.values() diff --git a/src/settings.rs b/src/settings.rs index ac0936b..676fd77 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,12 +1,8 @@ -//! Persisted listener intent and response controls. Listener changes require restart. use anyhow::{ensure, Context as _, Result}; use http::{HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, time::Duration}; -/// Listener addresses explicitly supplied by the service manager/CLI. These -/// are process-level contracts and cannot be removed or rewritten through the -/// persisted control plane while the process is started with those values. #[derive(Debug, Clone, Copy, Default)] pub struct StartupLocks { pub http: Option, @@ -16,9 +12,6 @@ pub struct StartupLocks { impl StartupLocks { pub fn ensure_config(&self, config: &mut crate::config::Config) { if self.administration.is_some() { - // Explicit startup administration bind remains authoritative. `None` - // means "use startup value" and also preserves the plain-HTTP safety - // gate handled by main. config.administration.listen = None; } if let Some(address) = self.http { @@ -73,10 +66,6 @@ fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAdd endpoint.enabled = true; return; } - // An explicit startup HTTP/HTTPS address overrides the canonical default - // listener created by earlier starts. Keep separately named/custom listeners - // intact, but do not retain the old default :80/:443 binding alongside a - // command-line/systemd override. let canonical_id = if tls { "https" } else { "http" }; if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| { endpoint.id == canonical_id && endpoint.binding.tls == tls @@ -127,11 +116,8 @@ fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAdd #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub struct Http2Connections { - /// Close an otherwise unused HTTP/2 connection after this inactivity period. pub idle_timeout_seconds: u64, - /// Absolute connection age limit. Zero disables the lifetime limit. pub max_connection_lifetime_seconds: u64, - /// Maximum time after GOAWAY for active streams to finish before TCP is closed. pub drain_timeout_seconds: u64, } impl Default for Http2Connections { @@ -172,9 +158,6 @@ impl Http2Connections { } } -/// Allowed encodings for the authenticated panel document. Codec preference is -/// zstd, then Brotli, then gzip for equal Accept-Encoding quality. These -/// settings are independent from downstream proxy response compression. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub struct PanelCompression { @@ -189,20 +172,11 @@ impl Default for PanelCompression { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub struct Administration { - /// None keeps the startup/CLI administration address for backward compatibility. - /// A persisted address is applied on the next process start. pub listen: Option, pub tls: bool, - /// None uses the application-managed default certificate pair. pub certificate_id: Option, - /// Optional additional DNS Host allowlist. The hostname of public_origin is - /// included automatically at runtime, never duplicated in persisted config. - /// With no public_origin, an empty HTTPS list retains the legacy DNS policy. pub allowed_hosts: Vec, - /// Optional fixed browser-facing HTTPS origin for the panel behind a trusted - /// reverse proxy. It is NEVER inferred from forwarded headers. pub public_origin: Option, - /// Absolute browser-session lifetime. Existing sessions are bounded by the current value. pub session_days: u16, pub panel_compression: PanelCompression, } @@ -273,8 +247,6 @@ impl Administration { if let Some(public_origin) = self.public_origin.as_mut() { *public_origin = normalize_public_admin_origin(public_origin)?; } - // Accept old 0.31.4 saved configs, but never retain a second explicit - // copy of the pinned hostname in a validated or newly saved config. if let Some(host) = self.public_origin_host().map(str::to_owned) { self.allowed_hosts.retain(|allowed| !allowed.eq_ignore_ascii_case(&host)); } @@ -285,7 +257,6 @@ impl Administration { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Listeners { - /// All proxy sockets are configured here and referenced by vhosts by ID. pub endpoints: Vec, } impl Listeners { @@ -364,7 +335,6 @@ impl Listeners { Ok(()) } } -/// Labels describe operator intent, never firewall or access-control enforcement. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ListenerScope { @@ -383,7 +353,6 @@ pub struct NamedListener { #[serde(default = "enabled")] pub enabled: bool, pub binding: crate::vhost::Listener, - /// Extra explicit local addresses that share the same transport/protocol policy. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub additional_addresses: Vec, } diff --git a/src/settings/public_origin.rs b/src/settings/public_origin.rs index 0f6e076..4e22cae 100644 --- a/src/settings/public_origin.rs +++ b/src/settings/public_origin.rs @@ -1,5 +1,3 @@ -// Normalize the browser-facing HTTPS origin independently of the optional -// additional DNS allowlist. It is never inferred from forwarding headers. fn normalize_public_admin_origin(public_origin: &str) -> Result { ensure!( public_origin.len() <= 255 && !public_origin.chars().any(|c| matches!(c, '@' | '#' | '?' | ',' | '\\')), @@ -15,8 +13,6 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result { ); let authority = uri.authority() .context("administration public_origin requires an authority")?; - // This setting is for a named HTTPS origin. Use the existing direct - // listener/Host policy for IP addresses and localhost. let host = crate::config::normalize_host(authority.host())?; ensure!( host.parse::().is_err() && host != "localhost", @@ -34,20 +30,15 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result { } impl Administration { - /// The origin is validated and normalized before it is stored, so this - /// returns only its DNS hostname (never a forwarded or client-supplied host). pub fn public_origin_host(&self) -> Option<&str> { self.public_origin.as_deref() .and_then(|url| url.strip_prefix("https://")) .and_then(|authority| authority.split(':').next()) } - /// Used only by the explicitly opted-in panel session-cookie passthrough. pub fn explicitly_allowed_panel_host(&self, host: &str) -> bool { self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(host)) || self.public_origin_host().is_some_and(|name| name.eq_ignore_ascii_case(host)) } - /// Build the access-control allowlist for the active/desired panel state. - /// Never mutate the saved configuration or accumulate stale hosts on edits. pub fn include_public_origin_host(&mut self) { if let Some(host) = self.public_origin_host().map(str::to_owned) { if !self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(&host)) { diff --git a/src/settings/tests.rs b/src/settings/tests.rs index 14f05c3..added5c 100644 --- a/src/settings/tests.rs +++ b/src/settings/tests.rs @@ -287,8 +287,6 @@ mod tests { assert_eq!(old_pin.allowed_hosts, vec!["other.example.test".to_owned()]); assert!(old_pin.explicitly_allowed_panel_host("panel.example.test")); assert!(old_pin.explicitly_allowed_panel_host("other.example.test")); - // Additional hosts are independent: editing/removing the pinned origin - // must not leave its former hostname in the saved strict list. let mut changed = admin.clone(); changed.public_origin = Some("https://other.example.test".into()); changed.validate(&tls).unwrap(); diff --git a/src/stream.rs b/src/stream.rs index a045684..f8b53d1 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -1,6 +1,3 @@ -//! Layer-4 TCP/UDP forwarding. Stream hosts intentionally bypass the HTTP stack: -//! no vhost routing, ACL, cache, headers, compression or HTTP authentication. - include!("stream/model.rs"); include!("stream/pool.rs"); include!("stream/health.rs"); diff --git a/src/stream/health.rs b/src/stream/health.rs index 7f1c7e0..4cc25e8 100644 --- a/src/stream/health.rs +++ b/src/stream/health.rs @@ -1,5 +1,3 @@ -// Active checks are only started for explicit verification modes. Probes are -// bounded globally and use the serving task's cancellation token. fn probe_permits() -> &'static Arc { static PERMITS: std::sync::OnceLock> = std::sync::OnceLock::new(); PERMITS.get_or_init(|| Arc::new(Semaphore::new(16))) @@ -51,7 +49,6 @@ async fn check_backend(backend: &RuntimeBackend) -> std::result::Result "0.0.0.0:0", SocketAddr::V6(_) => "[::]:0", diff --git a/src/stream/model.rs b/src/stream/model.rs index b36c5c4..2c22538 100644 --- a/src/stream/model.rs +++ b/src/stream/model.rs @@ -36,19 +36,14 @@ pub struct Host { pub protocol: Protocol, pub listen: SocketAddr, pub upstream: SocketAddr, - /// Empty keeps the legacy upstream as one backend named primary. #[serde(default)] pub backends: Vec, #[serde(default)] pub balance: BalanceAlgorithm, - /// Default for optional backend telemetry; basic stream counters remain. #[serde(default)] pub metrics_enabled: Option, - /// TCP connect timeout. Kept in the common schema so TCP/UDP rows stay simple. pub connect_timeout_seconds: u64, - /// Inactivity timeout for a TCP relay or a per-client UDP session. pub idle_timeout_seconds: u64, - /// Maximum concurrent TCP connections or UDP client sessions. pub max_connections: usize, } impl Default for Host { @@ -70,9 +65,6 @@ impl Default for Host { } } -/// Fixed-cardinality telemetry for one configured L4 stream. The registry is -/// created from the active startup configuration, so stream IDs cannot be -/// attacker-controlled or grow without a configuration/restart boundary. #[derive(Default, Debug)] pub struct Metrics { pub sessions_total: AtomicU64, @@ -225,7 +217,6 @@ pub fn validate( ); backend.health.validate(host.protocol)?; } - // Retain upstream as a representative address for older clients. if let Some(first) = host.backends.first() { host.upstream = first.address; } @@ -282,7 +273,6 @@ pub fn validate( } } - // Reject direct loops for every backend (including disabled/draining entries). for host in hosts.iter().filter(|item| item.enabled) { for backend in host.effective_backends() { let target = backend.address; diff --git a/src/stream/pool.rs b/src/stream/pool.rs index 3f985b7..3d9ccd6 100644 --- a/src/stream/pool.rs +++ b/src/stream/pool.rs @@ -1,4 +1,3 @@ -// Bounded Layer-4 backend pools. No async locks on the traffic hot path. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BalanceAlgorithm { @@ -26,9 +25,7 @@ pub struct HealthCheck { pub timeout_ms: u64, pub rise: u32, pub fall: u32, - /// Hexadecimal payload sent only to the configured backend when response matching is set. pub payload_hex: String, - /// Exact hex response. Without this, UDP remains unverified and no probes are sent. pub expect_hex: Option, } impl Default for HealthCheck { @@ -159,8 +156,6 @@ impl Host { } } -// health states are distinct from eligibility: an unverified UDP backend may receive -// traffic, but must never be labeled "healthy" by a local UDP connect(). const HEALTH_UNVERIFIED: u8 = 0; const HEALTH_CHECKING: u8 = 1; const HEALTH_HEALTHY: u8 = 2; @@ -343,7 +338,6 @@ impl Pool { if !backend.eligible() || !backend.reserve() { return None; } - // A health result may have arrived after the first eligibility read. if !backend.eligible() { backend.active.fetch_sub(1, Ordering::AcqRel); return None; @@ -363,8 +357,6 @@ impl Pool { let offset = self.cursor.fetch_add(1, Ordering::Relaxed) as usize; match self.algorithm { BalanceAlgorithm::RoundRobin => { - // Recompute the bounded eligible weight on each assignment: - // unhealthy/draining/capacity-full peers do not skew the ratio. let available = |b: &Arc| { b.eligible() && b.max_sessions @@ -391,8 +383,6 @@ impl Pool { } ticket -= backend.weight as usize; } - // A concurrent session or health transition may invalidate the - // preferred peer after the snapshot: try each other peer once. for delta in 0..self.backends.len() { let index = (preferred + delta) % self.backends.len(); if let Some(lease) = self.assign(index) { diff --git a/src/system_stats/linux.rs b/src/system_stats/linux/mod.rs similarity index 96% rename from src/system_stats/linux.rs rename to src/system_stats/linux/mod.rs index a4e2900..dbb8f58 100644 --- a/src/system_stats/linux.rs +++ b/src/system_stats/linux/mod.rs @@ -220,9 +220,6 @@ pub(crate) fn augment(view: &mut View) { ) .filter(|count| *count > 0); } - // Linux-specific augmentation preserves fields and units that existed before - // the cross-platform library adapter. LXC additionally overrides uptime/load - // with namespace-visible data so the dashboard does not report the host values. if environment == LinuxEnvironment::Lxc { if let Some(uptime) = lxc_uptime_seconds() { view.system_uptime_seconds = Some(uptime); @@ -277,4 +274,4 @@ pub(crate) fn augment(view: &mut View) { } #[cfg(test)] -include!("linux/tests.rs"); +include!("tests.rs"); diff --git a/src/system_stats/mod.rs b/src/system_stats/mod.rs index e3cb53b..b652585 100644 --- a/src/system_stats/mod.rs +++ b/src/system_stats/mod.rs @@ -1,11 +1,3 @@ -//! Cross-platform system/process statistics used by low-frequency diagnostics. -//! -//! Generic host metrics come from `systemstat`, network inventory/counters use -//! `sysinfo`, and generic process metrics use `metrics-process` where supported. -//! `systemstat` is also a library-level network fallback on targets where -//! `sysinfo` has no native network backend. Platform-specific -//! code is limited to additive metrics that the common libraries do not expose. -//! Missing metrics stay `None`; callers must not synthesize values. use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -35,15 +27,10 @@ pub struct View { pub process_virtual_memory_bytes: Option, pub process_virtual_memory_max_bytes: Option, pub process_start_time_seconds: Option, - /// Resident anonymous memory (heap/stacks/private anonymous mappings). pub process_rss_anon_bytes: Option, - /// Resident file-backed mappings. pub process_rss_file_bytes: Option, - /// Resident shared-memory mappings. pub process_rss_shmem_bytes: Option, - /// Data/heap virtual address space reported by the native platform source. pub process_data_bytes: Option, - /// Process swap currently charged by the kernel. pub process_swap_bytes: Option, pub open_fds: Option, pub max_fds: Option, @@ -289,9 +276,6 @@ fn merge_network_interfaces( primary: Vec, secondary: Vec, ) -> Vec { - // Both libraries expose cumulative byte counters. Merge by interface name - // and keep the larger counter so a partial/zero reading from one backend - // cannot hide a usable reading from the other. normalize_network_interfaces( primary .into_iter() @@ -301,11 +285,6 @@ fn merge_network_interfaces( } fn collect_network(view: &mut View) { - // `sysinfo::Networks` is the primary interface/traffic API. `systemstat` - // supplements it and is the library-level fallback on targets where - // sysinfo has no native network backend. Combining both inventories also - // avoids dropping an interface when either backend is only partially - // available. No shell commands or per-platform network parser are used. let interfaces = merge_network_interfaces(collect_network_sysinfo(), collect_network_systemstat()); if interfaces.is_empty() { @@ -391,8 +370,6 @@ pub(crate) fn sample(previous: Option<(View, Counters)>) -> (View, Counters) { collect_network(&mut view); - // Preserve the existing detailed Linux fields and Linux memory-used semantics; - // these are additive details not provided by the common libraries. #[cfg(target_os = "linux")] linux::augment(&mut view); diff --git a/src/tinyauth.rs b/src/tinyauth/mod.rs similarity index 96% rename from src/tinyauth.rs rename to src/tinyauth/mod.rs index 9a2fbf0..15c3996 100644 --- a/src/tinyauth.rs +++ b/src/tinyauth/mod.rs @@ -1,5 +1,3 @@ -//! Internal, body-free Tinyauth auth_request. No unauthenticated cache access, -//! request replay, client identity passthrough or insecure TLS exceptions. use crate::{ config::{Backend, CompiledBackend, UpstreamProtocol, ValidationPolicy}, forwarding, @@ -19,7 +17,6 @@ use url::Url; #[serde(default, deny_unknown_fields)] pub struct Settings { pub enabled: bool, - /// An origin adds /api/auth/nginx automatically; an explicit path is retained. pub url: String, pub login_url: String, pub allow_private: bool, @@ -349,7 +346,6 @@ impl Compiled { request.headers_mut().append(name.clone(), value.clone()); } } - // Incoming Connection nominations cannot remove or forge trusted metadata. crate::proxy::headers::strip_hop_by_hop(request.headers_mut()); crate::proxy::headers::strip_admin_cookie(request.headers_mut()); self.strip_identity(request.headers_mut()); @@ -370,8 +366,6 @@ impl Compiled { .await .context("Tinyauth request failed")?; let (parts, body) = response.into_parts(); - // Drain only a small bounded body to permit safe keepalive reuse. Neither - // auth page content nor arbitrary response headers reach the application. Limited::new(body, 16 * 1024) .collect() .await @@ -390,4 +384,4 @@ impl Identity { } } #[cfg(test)] -include!("tinyauth/tests.rs"); +include!("tests.rs"); diff --git a/src/tls.rs b/src/tls.rs index cee3c89..ff344e8 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,8 +1,3 @@ -//! Certificate files are control-plane inputs; handshakes only use immutable, -//! prevalidated rustls configurations. Private key bytes accepted by authenticated -//! deploy operations are bounded, zeroized by callers, never returned, and never -//! written to audit/API response payloads. - include!("tls/types.rs"); include!("tls/external_paths.rs"); include!("tls/server_config.rs"); @@ -11,4 +6,4 @@ include!("tls/certificates.rs"); #[cfg(test)] include!("tls/tests.rs"); #[cfg(test)] -include!("tls/tests/acme.rs"); +include!("tls/acme_tests.rs"); diff --git a/src/tls/tests/acme.rs b/src/tls/acme_tests.rs similarity index 100% rename from src/tls/tests/acme.rs rename to src/tls/acme_tests.rs diff --git a/src/tls/certificates.rs b/src/tls/certificates.rs index a0c9cee..18495ae 100644 --- a/src/tls/certificates.rs +++ b/src/tls/certificates.rs @@ -1,5 +1,3 @@ -/// Build the fixed administration TLS configuration. A named inventory -/// certificate may be selected; otherwise the app-managed default pair is used. pub fn compile_admin( settings: &Settings, administration: &crate::settings::Administration, @@ -132,15 +130,9 @@ pub fn import(directory: &Path, name: &str, cert: PathBuf, key: PathBuf) -> Resu ) } -/// Result of reconciling an externally supplied certificate/key pair with the -/// immutable generation currently selected by a manual certificate id. #[derive(Debug, PartialEq, Eq)] pub enum ExternalPairDeployment { - /// The configured RustProxy-owned generation already contains the exact - /// certificate chain and private key supplied by the external issuer. Unchanged { covered_hosts: Vec }, - /// A new immutable generation was written and must be published through the - /// revisioned configuration path before the old generation can be removed. Staged { cert_file: String, key_file: String, @@ -159,12 +151,6 @@ fn external_generation_pair(certificate_id: &str, cert_file: &str, key_file: &st cert_stem == key_stem && cert_stem.starts_with(&prefix) } -/// Validate an externally supplied pair and reconcile it with the currently -/// selected RustProxy-owned immutable generation. Repeating the same deploy is -/// therefore a no-op, while a missing/different pair stages a fresh generation. -/// -/// `current_pair` is advisory: unreadable/missing current files are treated as -/// drift and repaired by staging a new generation instead of failing the deploy. pub fn reconcile_external_pair( directory: &Path, certificate_id: &str, @@ -210,8 +196,6 @@ pub fn reconcile_external_pair( }) } -/// Stage a locally supplied certificate/key pair under generation-specific -/// filenames. This compatibility wrapper always stages a new generation. pub fn stage_external_pair( directory: &Path, certificate_id: &str, @@ -230,8 +214,6 @@ pub fn stage_external_pair( } } -/// Best-effort rollback for a generation created by `stage_external_pair`. -/// Only generation-specific external files may be removed through this helper. pub fn remove_external_pair(directory: &Path, cert_file: &str, key_file: &str) -> Result<()> { safe_name(cert_file)?; safe_name(key_file)?; @@ -254,8 +236,6 @@ pub fn remove_external_pair(directory: &Path, cert_file: &str, key_file: &str) - Ok(()) } -/// Select named inventory certificates per vhost/redirect host. A single global -/// SNI resolver cannot give the same name different certificates by destination IP. pub fn for_all_frontends( settings: &Settings, vhosts: &[crate::vhost::Vhost], @@ -317,7 +297,6 @@ pub fn for_all_frontends( Ok(result) } -/// Compatibility helper for callers without dedicated local-response hosts. pub fn for_frontends( settings: &Settings, vhosts: &[crate::vhost::Vhost], @@ -326,12 +305,9 @@ pub fn for_frontends( for_all_frontends(settings, vhosts, redirects, &[]) } -/// Compatibility helper used by focused TLS tests and callers that only have vhosts. pub fn for_vhosts(settings: &Settings, vhosts: &[crate::vhost::Vhost]) -> Result { for_frontends(settings, vhosts, &[]) } -/// Upload staging: files are private and immutable; configuration publication is -/// a separate revisioned operation. Unbound files never alter active TLS state. pub fn upload( directory: &Path, certificate: &[u8], @@ -358,8 +334,6 @@ pub fn upload( } crate::auth::prepare_directory(directory)?; let _lock = crate::auth::lock_directory(directory)?; - // Bound abandoned staging files. Admins may remove unreferenced upload-* pairs - // locally; never delete potentially active files automatically. let staged = fs::read_dir(directory)? .filter_map(|e| e.ok()) .filter(|e| { @@ -382,7 +356,6 @@ pub fn upload( hosts: Vec::new(), }) } -/// Validate immutable ACME outputs before they may be published to the library. pub fn validate_acme( directory: &Path, id: String, @@ -402,8 +375,6 @@ pub fn validate_acme( let mut resolver = ResolvesServerCertUsingSni::new(); for domain in domains { let domain = normalize_host_pattern(domain)?; - // A concrete name checks a wildcard SAN without relying on wildcard - // behavior in rustls' stock validation resolver. let name = domain .strip_prefix("*.") .map(|d| format!("rustproxy-acme-check.{d}")) diff --git a/src/tls/external_paths.rs b/src/tls/external_paths.rs index 497b015..2f700e9 100644 --- a/src/tls/external_paths.rs +++ b/src/tls/external_paths.rs @@ -52,9 +52,6 @@ fn validate_external_parent_chain(path: &Path) -> Result<()> { directory.display() ); if mode & 0o022 != 0 { - // Root-owned sticky directories such as /tmp and /var/tmp cannot have - // entries renamed/unlinked by arbitrary users, so they are safe as an - // ancestor of a private service-owned test/custom directory. ensure!( owner == 0 && mode & 0o1000 != 0, "external certificate parent directory must not be group/world-writable unless it is a root-owned sticky directory: {}", @@ -77,16 +74,9 @@ fn configured_certificate_path(directory: &Path, value: &str) -> Result "external certificate path must be an absolute path up to 4096 bytes" ); let configured = Path::new(value); - // The configured path itself may be Certbot's final live/*.pem symlink, but - // every parent directory must be a stable, non-writable directory. This - // prevents a writable parent from redirecting canonicalize() to attacker- - // controlled material between validation and open. validate_external_parent_chain(configured)?; let resolved = fs::canonicalize(configured) .with_context(|| format!("cannot resolve external certificate path {value}"))?; - // Validate the resolved archive/custom path as well. Certbot's final file - // symlink is intentionally followed on each reload; intermediate symlinks - // and group/world-writable parent directories are rejected. validate_external_parent_chain(&resolved)?; return Ok(resolved); } @@ -136,12 +126,6 @@ fn read_pem_with_policy( "external certificate/key must be owned by root or the RustProxy service user: {}", path.display() ); - // POSIX ACL masks are reflected in the traditional group mode bits. - // Therefore do not require an exact chmod such as 0600/0640 and do - // not reject harmless execute-mask bits. What matters here is that - // an untrusted group/other identity cannot modify the material and - // that the private key is never readable by "other". A named ACL - // such as `u:rustproxy:r--` is intentionally supported. ensure!( mode & 0o022 == 0, "external certificate/key must not be group/world-writable: {}", diff --git a/src/tls/tests/external_paths.rs b/src/tls/external_paths_tests.rs similarity index 70% rename from src/tls/tests/external_paths.rs rename to src/tls/external_paths_tests.rs index b7b0fe5..9fe94ad 100644 --- a/src/tls/tests/external_paths.rs +++ b/src/tls/external_paths_tests.rs @@ -15,10 +15,26 @@ fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() { let first = rcgen::generate_simple_self_signed(vec!["example.test".into()]).unwrap(); fs::write(archive.join("fullchain1.pem"), first.cert.pem()).unwrap(); - fs::write(archive.join("privkey1.pem"), first.signing_key.serialize_pem()).unwrap(); - fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o650)).unwrap(); - symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap(); - symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap(); + fs::write( + archive.join("privkey1.pem"), + first.signing_key.serialize_pem(), + ) + .unwrap(); + fs::set_permissions( + archive.join("privkey1.pem"), + fs::Permissions::from_mode(0o650), + ) + .unwrap(); + symlink( + "../../archive/example.test/fullchain1.pem", + live.join("fullchain.pem"), + ) + .unwrap(); + symlink( + "../../archive/example.test/privkey1.pem", + live.join("privkey.pem"), + ) + .unwrap(); let cert_path = live.join("fullchain.pem").display().to_string(); let key_path = live.join("privkey.pem").display().to_string(); @@ -37,26 +53,44 @@ fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() { }; assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some()); assert_eq!( - certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts, + certificate_metadata(&cert_dir, &cert_path) + .unwrap() + .covered_hosts, vec!["example.test"] ); - let second = rcgen::generate_simple_self_signed(vec![ - "example.test".into(), - "www.example.test".into(), - ]) - .unwrap(); + let second = + rcgen::generate_simple_self_signed(vec!["example.test".into(), "www.example.test".into()]) + .unwrap(); fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap(); - fs::write(archive.join("privkey2.pem"), second.signing_key.serialize_pem()).unwrap(); - fs::set_permissions(archive.join("privkey2.pem"), fs::Permissions::from_mode(0o640)).unwrap(); + fs::write( + archive.join("privkey2.pem"), + second.signing_key.serialize_pem(), + ) + .unwrap(); + fs::set_permissions( + archive.join("privkey2.pem"), + fs::Permissions::from_mode(0o640), + ) + .unwrap(); fs::remove_file(live.join("fullchain.pem")).unwrap(); fs::remove_file(live.join("privkey.pem")).unwrap(); - symlink("../../archive/example.test/fullchain2.pem", live.join("fullchain.pem")).unwrap(); - symlink("../../archive/example.test/privkey2.pem", live.join("privkey.pem")).unwrap(); + symlink( + "../../archive/example.test/fullchain2.pem", + live.join("fullchain.pem"), + ) + .unwrap(); + symlink( + "../../archive/example.test/privkey2.pem", + live.join("privkey.pem"), + ) + .unwrap(); assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some()); assert_eq!( - certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts, + certificate_metadata(&cert_dir, &cert_path) + .unwrap() + .covered_hosts, vec!["example.test", "www.example.test"] ); } @@ -70,9 +104,6 @@ fn external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_ let key = root.path().join("external.key"); fs::write(&key, b"not-a-real-key").unwrap(); - // POSIX ACL masks can surface through the traditional group class bits. - // Group read/execute is not itself a disclosure when `other` has no read - // and neither group nor other can write the file. fs::set_permissions(&key, fs::Permissions::from_mode(0o650)).unwrap(); assert!(read_pem_with_policy(&key, true, true).is_ok()); diff --git a/src/tls/server_config.rs b/src/tls/server_config.rs index 2711bf7..593f72d 100644 --- a/src/tls/server_config.rs +++ b/src/tls/server_config.rs @@ -1,7 +1,3 @@ -/// Extract the DNS SubjectAltName set from the leaf certificate. This parser is -/// deliberately narrow: it walks only the DER structures required to locate the -/// SAN extension and ignores non-DNS GeneralName variants. Certificate validity -/// and key matching are still delegated to rustls. fn certificate_dns_names(certificate: &[u8]) -> Result> { let mut reader = certificate; let certs = rustls_pemfile::certs(&mut reader).collect::>>()?; @@ -22,7 +18,6 @@ fn certificate_dns_names(certificate: &[u8]) -> Result> { if tbs.first() == Some(&0xa0) { let _ = der_tlv(tbs, &mut tbs_offset)?; } - // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo for _ in 0..6 { let _ = der_tlv(tbs, &mut tbs_offset)?; } @@ -306,16 +301,11 @@ fn certificate_public_metadata(certificate: &[u8]) -> Result Result> { let certificate = read_configured_pem(directory, cert_file, false)?; certificate_dns_names(&certificate) } -/// Read non-secret metadata directly from the configured public leaf certificate. -/// Private keys are never opened by this path. pub fn certificate_metadata(directory: &Path, cert_file: &str) -> Result { let certificate = read_configured_pem(directory, cert_file, false)?; certificate_public_metadata(&certificate) @@ -344,7 +334,6 @@ fn load_pair(directory: &Path, cert: &str, key: &str) -> Result { &read_configured_pem(directory, key, true)?, ) } -/// Runs on a bounded control-plane blocking worker, never from a handshake. pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result>> { settings.policy.validate()?; let Some(directory) = directory else { @@ -365,9 +354,6 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result) -> Result, policy: &ProtocolPolicy, diff --git a/src/tls/tests.rs b/src/tls/tests.rs index 287d9cf..920ebf2 100644 --- a/src/tls/tests.rs +++ b/src/tls/tests.rs @@ -162,7 +162,7 @@ mod tests { assert!(certificate_files_are_external("app.pem", "/etc/tls/app.key").is_err()); } - include!("tests/external_paths.rs"); + include!("external_paths_tests.rs"); #[test] fn default_is_persistent_and_tls_advertises_both_protocols() { diff --git a/src/tls/types.rs b/src/tls/types.rs index 1c6de17..38e30cf 100644 --- a/src/tls/types.rs +++ b/src/tls/types.rs @@ -25,7 +25,6 @@ pub struct Settings { #[serde(default)] pub certificates: Vec, pub policy: ProtocolPolicy, - /// Derived from explicitly confirmed Vhosts; never accepted from TLS JSON. #[serde(skip)] pub forced_mismatches: HashSet<(String, String)>, } @@ -52,7 +51,6 @@ impl Settings { pub struct ProtocolPolicy { pub tls12: bool, pub tls13: bool, - /// Rustls cipher-suite names. Empty keeps the ring provider defaults. pub cipher_suites: Vec, } impl Default for ProtocolPolicy { @@ -119,9 +117,6 @@ impl ProtocolPolicy { .iter() .any(|suite| suite.starts_with("TLS13_"))) } - /// Per-vhost policy may only remove protocols/suites from the global policy. - /// This keeps the global setting a real security ceiling rather than a default - /// that an individual vhost can silently widen. pub fn validate_narrower_than(&self, global: &Self) -> Result<()> { self.validate()?; global.validate()?; @@ -223,11 +218,8 @@ pub struct Certificate { #[serde(default, skip_serializing_if = "Option::is_none")] pub issuer: Option, pub id: String, - /// DNS SubjectAltName values extracted from the leaf certificate during - /// upload or ACME import. This is certificate coverage, not active SNI binding. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub covered_hosts: Vec, - /// Active/configured SNI bindings. `for_vhosts` may rewrite this list. pub hosts: Vec, pub cert_file: String, pub key_file: String, @@ -330,10 +322,6 @@ pub fn safe_name(name: &str) -> Result<()> { Ok(()) } -/// Validate how a configured certificate pair is addressed. Legacy/simple names -/// remain private to `/certs`; an explicitly selected external pair uses -/// two absolute paths. Mixing the two modes is rejected so the source is never -/// ambiguous. fn der_tlv<'a>(input: &'a [u8], offset: &mut usize) -> Result<(u8, &'a [u8])> { ensure!(*offset < input.len(), "truncated DER object"); let tag = input[*offset]; diff --git a/src/traffic.rs b/src/traffic/mod.rs similarity index 83% rename from src/traffic.rs rename to src/traffic/mod.rs index c160e9d..b3fbdb1 100644 --- a/src/traffic.rs +++ b/src/traffic/mod.rs @@ -1,8 +1,3 @@ -//! Bounded, asynchronous per-vhost traffic analytics. -//! -//! The request path only uses `try_send`; aggregation runs on one background -//! worker. High-cardinality dimensions use bounded Space-Saving tables so a -//! hostile path/User-Agent stream cannot grow process memory without bound. use serde::Serialize; use std::{ collections::{BTreeMap, HashMap}, @@ -51,9 +46,6 @@ impl TopTable { if raw.is_empty() || weight == 0 { return; } - // Probe with the borrowed label. Under production traffic most methods, - // statuses, routes and user agents repeat; allocating a fresh String - // for every hit creates avoidable allocator churn. let value = truncate(raw, MAX_LABEL_BYTES); if let Some(count) = self.values.get_mut(value) { *count = count.saturating_add(weight); @@ -72,7 +64,8 @@ impl TopTable { return; }; self.values.remove(&smallest); - self.values.insert(value.to_owned(), minimum.saturating_add(weight)); + self.values + .insert(value.to_owned(), minimum.saturating_add(weight)); } fn merge(&mut self, other: &Self) { for (value, count) in &other.values { @@ -122,8 +115,6 @@ impl Counters { self.methods.add(&sample.method, 1); self.method_families.add(method_family(&sample.method), 1); self.sources.add(sample.source, 1); - // Standard HTTP status families are static; the unusual 6xx-9xx - // extension codes still keep their exact original dashboard label. match sample.status / 100 { 1 => self.statuses.add("1xx", 1), 2 => self.statuses.add("2xx", 1), @@ -132,7 +123,8 @@ impl Counters { 5 => self.statuses.add("5xx", 1), family => self.statuses.add(&format!("{family}xx"), 1), } - self.routes.add(sample.route.as_deref().unwrap_or("(main)"), 1); + self.routes + .add(sample.route.as_deref().unwrap_or("(main)"), 1); self.paths.add(&sample.path, 1); self.cache.add(sample.cache, 1); if let Some(country) = sample.country.as_deref() { @@ -140,7 +132,8 @@ impl Counters { } if let Some(user_agent) = sample.user_agent.as_deref() { self.user_agents.add(user_agent, 1); - self.user_agent_families.add(user_agent_family(user_agent), 1); + self.user_agent_families + .add(user_agent_family(user_agent), 1); } if let Some(client_ip) = sample.client_ip.as_deref() { self.client_ips.add(client_ip, 1); @@ -171,7 +164,8 @@ impl Counters { duration_ms: self.duration_ms, request_bytes: self.request_bytes, response_bytes: self.response_bytes, - mean_duration_ms: (self.requests > 0).then(|| self.duration_ms as f64 / self.requests as f64), + mean_duration_ms: (self.requests > 0) + .then(|| self.duration_ms as f64 / self.requests as f64), countries: self.countries.view(), user_agents: self.user_agents.view(), user_agent_families: self.user_agent_families.view(), @@ -234,9 +228,6 @@ impl Analytics { let (tx, mut rx) = mpsc::channel::(QUEUE_CAPACITY); let worker_state = state.clone(); tokio::spawn(async move { - // One bounded receiver and one ordered aggregator as before. - // Batch up to 64 ready messages under one dashboard-state lock; - // never hold the lock while waiting for new messages. let mut batch = Vec::with_capacity(64); loop { tokio::select! { @@ -248,7 +239,6 @@ impl Analytics { if let Some(counters) = state.vhosts.get_mut(sample.vhost.as_str()) { counters.record(sample); } else if state.vhosts.len() < MAX_VHOSTS { - // Allocate the map key only for a previously unseen vhost. let name = sample.vhost.clone(); let mut counters = Counters::default(); counters.record(sample); @@ -266,9 +256,6 @@ impl Analytics { self.push_lazy(|| sample); } - /// Acquire queue capacity before constructing owned analytics fields. - /// Overflow accounting is identical to `push`, and the queue remains - /// bounded. In saturated traffic the rejected sample allocates nothing. pub fn push_lazy(&self, make_sample: impl FnOnce() -> Sample) { match self.tx.try_reserve() { Ok(permit) => permit.send(make_sample()), @@ -288,9 +275,11 @@ impl Analytics { } total.view() } else { - // No need to copy a dozen bounded top lists just to render one - // vhost's read-only dashboard view while holding the state lock. - state.vhosts.get(selected).map(Counters::view).unwrap_or_else(|| Counters::default().view()) + state + .vhosts + .get(selected) + .map(Counters::view) + .unwrap_or_else(|| Counters::default().view()) }; View { scope: selected.to_owned(), @@ -315,8 +304,12 @@ impl Analytics { let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); match scope.filter(|value| !value.is_empty() && *value != "*") { Some(scope) => { - if state.vhosts.remove(scope).is_some() { 1 } else { 0 } - }, + if state.vhosts.remove(scope).is_some() { + 1 + } else { + 0 + } + } None => { let count = state.vhosts.len(); state.vhosts.clear(); @@ -342,10 +335,12 @@ fn method_family(method: &str) -> &'static str { } fn user_agent_family(value: &str) -> &'static str { - // ASCII-insensitive classification without copying and lowercasing a - // potentially 512-character User-Agent for every sampled request. - let has = |needle: &str| value.as_bytes().windows(needle.len()) - .any(|candidate| candidate.eq_ignore_ascii_case(needle.as_bytes())); + let has = |needle: &str| { + value + .as_bytes() + .windows(needle.len()) + .any(|candidate| candidate.eq_ignore_ascii_case(needle.as_bytes())) + }; if has("bot") || has("spider") || has("crawler") || has("slurp") { "bot" } else if has("curl/") { @@ -387,4 +382,4 @@ fn truncate(value: &str, max_bytes: usize) -> &str { } #[cfg(test)] -include!("traffic/tests.rs"); +include!("tests.rs"); diff --git a/src/traffic/tests.rs b/src/traffic/tests.rs index d2c6c76..b7c3844 100644 --- a/src/traffic/tests.rs +++ b/src/traffic/tests.rs @@ -46,8 +46,6 @@ mod tests { async fn saturated_analytics_queue_does_not_build_discarded_samples() { let analytics = Analytics::start(CancellationToken::new()); let made = AtomicU64::new(0); - // No .await until the queue is filled: on this current-thread test - // runtime the consumer has not removed a sample in the background. for _ in 0..(QUEUE_CAPACITY + 5) { analytics.push_lazy(|| { made.fetch_add(1, Ordering::Relaxed); diff --git a/src/transform/apply.rs b/src/transform/apply.rs index 0806304..b1785cf 100644 --- a/src/transform/apply.rs +++ b/src/transform/apply.rs @@ -1,6 +1,3 @@ -/// Transform an eligible response. The call is fail-open for unsupported media, -/// encodings and malformed minifiable content; transport/body collection errors -/// still propagate because the original stream can no longer be replayed safely. pub async fn apply( response: Response, path: &str, @@ -46,8 +43,6 @@ pub async fn apply( .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()) else { - // Keep streaming semantics for unknown-size bodies instead of introducing - // unbounded buffering into the dataplane. return Ok(skip(response, Some(kind), "unknown_length")); }; if content_length > settings.max_body_bytes { @@ -64,18 +59,10 @@ pub async fn apply( let Some(_memory_reservation) = try_reserve_transform_memory(required_memory, settings.memory_budget_bytes) else { - // Transformations are an optimization. Under concurrent allocation - // pressure, preserve streaming/origin bytes instead of queueing more - // full-response buffers behind the memory budget. return Ok(skip(response, Some(kind), "memory_pressure")); }; let (mut parts, body) = response.into_parts(); - // Do not trust Content-Length as a memory bound. A broken or malicious - // upstream can send more bytes than declared; Limited stops aggregation at - // the configured ceiling instead of letting BodyExt::collect grow without - // bound. Once aggregation starts an over-limit stream cannot be replayed - // safely, so it is treated like the other body/transport collection errors. let raw = Limited::new(body, settings.max_body_bytes) .collect() .await @@ -114,9 +101,6 @@ pub async fn apply( } }; if transformed.as_slice() == decoded.as_ref() && encoding.is_none() { - // The origin was explicitly requested without Accept-Encoding and the - // eligible response is already identity. The bytes/validator stay intact, - // but the cache no longer needs an Accept-Encoding variant dimension. drop_vary_accept_encoding(&mut parts.headers); parts.extensions.insert(CanonicalIdentity); parts.extensions.insert(Skipped { @@ -128,9 +112,6 @@ pub async fn apply( let original_bytes = decoded.len(); let released_bytes = raw.len().saturating_add(decoded.len()); - // The successful transformed response no longer needs the collected origin - // body or decoded temporary buffer. Drop both before building the response so - // large short-lived allocations do not overlap longer than necessary. drop(decoded); drop(raw); trim_allocator_after_large_transform(released_bytes); diff --git a/src/transform/compile.rs b/src/transform/compile.rs index b033c7b..3200023 100644 --- a/src/transform/compile.rs +++ b/src/transform/compile.rs @@ -164,8 +164,6 @@ fn decode<'a>(body: &'a [u8], encoding: Option<&str>, limit: usize) -> Result { @@ -205,8 +203,6 @@ fn drop_vary_accept_encoding(headers: &mut HeaderMap) { .filter(|token| !token.is_empty()) { if token == "*" { - // `Vary: *` has special semantics and is rejected by the shared - // cache. Do not rewrite it into a different policy. return; } if token.eq_ignore_ascii_case("accept-encoding") { diff --git a/src/transform/css.rs b/src/transform/css.rs index b41b968..1e2ee0c 100644 --- a/src/transform/css.rs +++ b/src/transform/css.rs @@ -52,8 +52,6 @@ pub fn minify(body: &[u8]) -> Result> { last_char = Some(' '); } pending_space = false; - // A space before ':' may be a descendant combinator before a pseudo-class. - // E.g. `.card :is(.child)` must not become `.card:is(.child)`. if matches!(ch, '{' | '}' | ';' | ',' | '>') && last_char == Some(' ') { out.pop(); } @@ -73,6 +71,5 @@ fn needs_space(previous: Option, next: Option) -> bool { return false; }; !matches!(previous, '{' | '}' | ':' | ';' | ',' | '>') - // A ':' can introduce a pseudo-class after a descendant combinator. && !matches!(next, '{' | '}' | ';' | ',' | '>') } diff --git a/src/transform/html.rs b/src/transform/html/mod.rs similarity index 91% rename from src/transform/html.rs rename to src/transform/html/mod.rs index d6feaf2..b2feca7 100644 --- a/src/transform/html.rs +++ b/src/transform/html/mod.rs @@ -8,12 +8,6 @@ enum RawElement { Pre, } -/// Conservative HTML minification. -/// -/// HTML whitespace can be semantically significant, especially between inline -/// elements, so this transformer deliberately preserves all whitespace. It only -/// removes ordinary comments outside raw-text elements. Conditional comments and -/// comment-looking bytes inside script/style/textarea/pre are preserved. pub fn minify(body: &[u8]) -> Result> { let input = std::str::from_utf8(body).context("HTML is not UTF-8")?; let bytes = input.as_bytes(); @@ -130,4 +124,4 @@ fn raw_tag(tag: &str) -> Option<(RawElement, bool, bool)> { } #[cfg(test)] -include!("html/tests.rs"); +include!("tests.rs"); diff --git a/src/transform/js.rs b/src/transform/js/mod.rs similarity index 85% rename from src/transform/js.rs rename to src/transform/js/mod.rs index 0b55561..2ed94ef 100644 --- a/src/transform/js.rs +++ b/src/transform/js/mod.rs @@ -1,13 +1,5 @@ use anyhow::{Context, Result}; -/// Conservative JavaScript minification without token rewriting. -/// -/// JavaScript `/` is context-sensitive (division vs. RegExp literal), so this -/// minifier never guesses. Outside strings/templates/comments it collapses -/// horizontal whitespace only until the first slash on a physical line; the -/// remainder of that line is copied verbatim. This still removes common -/// indentation/alignment whitespace while preserving RegExp bodies, ASI-sensitive -/// line terminators and comment text exactly. pub fn minify(body: &[u8]) -> Result> { let input = std::str::from_utf8(body).context("JavaScript is not UTF-8")?; let mut out = String::with_capacity(input.len()); @@ -120,4 +112,4 @@ pub fn minify(body: &[u8]) -> Result> { } #[cfg(test)] -include!("js/tests.rs"); +include!("tests.rs"); diff --git a/src/transform/json.rs b/src/transform/json/mod.rs similarity index 79% rename from src/transform/json.rs rename to src/transform/json/mod.rs index 7fd4ae8..4aed370 100644 --- a/src/transform/json.rs +++ b/src/transform/json/mod.rs @@ -1,9 +1,6 @@ use anyhow::{Context, Result}; use serde::{de::IgnoredAny, Deserialize}; -/// Validate JSON without materializing a full `serde_json::Value`, then remove -/// insignificant ASCII whitespace in one pass. This keeps peak memory close to -/// input + output instead of input + parsed tree + serialized output. pub fn compact(body: &[u8]) -> Result> { let mut deserializer = serde_json::Deserializer::from_slice(body); IgnoredAny::deserialize(&mut deserializer).context("parse JSON")?; @@ -35,4 +32,4 @@ pub fn compact(body: &[u8]) -> Result> { } #[cfg(test)] -include!("json/tests.rs"); +include!("tests.rs"); diff --git a/src/transform/mod.rs b/src/transform/mod.rs index 1cdf433..98641f6 100644 --- a/src/transform/mod.rs +++ b/src/transform/mod.rs @@ -1,6 +1,3 @@ -//! Static-response transformation stage. Transformations run on the identity -//! representation before shared-cache storage and before downstream compression. - include!("model.rs"); include!("compile.rs"); include!("apply.rs"); diff --git a/src/transform/model.rs b/src/transform/model.rs index eb61947..8593a66 100644 --- a/src/transform/model.rs +++ b/src/transform/model.rs @@ -37,11 +37,6 @@ pub enum Preset { Uber, } -/// Large transformable responses can temporarily own the collected origin body, -/// a decoded buffer and the transformed output at the same time. On glibc, freed -/// pages may remain in malloc arenas for a long time. The systemd unit also caps -/// arena proliferation with `MALLOC_ARENA_MAX=2`; this low-frequency trim handles -/// the large transient buffers without adding a trim syscall to every request. fn trim_allocator_after_large_transform(released_bytes: usize) { #[cfg(all(target_os = "linux", target_env = "gnu"))] { @@ -78,27 +73,16 @@ fn trim_allocator_after_large_transform(released_bytes: usize) { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Settings { - /// Global kill switch. Per-vhost opt-in is still required. pub enabled: bool, - /// Last resource profile explicitly selected in the administrator UI. The - /// numeric limits below are authoritative; this field only preserves UI - /// intent across reloads. pub preset: Option, pub javascript: bool, pub css: bool, pub html: bool, pub svg: bool, pub json: bool, - /// Optional extension allow-list. Content-Type recognition remains available, - /// but an extension outside this list is never transformed by extension alone. pub extensions: Vec, - /// Hard aggregation/decode ceiling for transformable static responses. pub max_body_bytes: usize, - /// Process-wide ceiling for estimated temporary response buffers owned by - /// active transformations. This is a limit, not a pre-allocation. pub memory_budget_bytes: usize, - /// Process-wide number of transformation attempts allowed to own an active - /// transform slot at the same time. Excess work fails open unchanged. pub max_inflight: usize, } impl Default for Settings { @@ -156,8 +140,6 @@ impl Settings { } } -/// Per-vhost transformation policy. Unlike cache overrides, transformation is -/// deliberately opt-in per vhost even when the global engine is enabled. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Overrides { @@ -231,8 +213,6 @@ impl Kind { } } -/// Origin validators are retained separately because transformed bytes receive -/// a RustProxy validator that must never be sent back to the origin. #[derive(Debug, Clone)] pub struct OriginValidators { pub etag: Option, @@ -246,18 +226,12 @@ pub struct Applied { pub transformed_bytes: usize, } -/// Internal observability marker for a response which was examined by the -/// transform stage but intentionally left unchanged. Reasons are fixed tokens -/// and never contain request or response data. #[derive(Debug, Clone, Copy)] pub struct Skipped { pub kind: Option, pub reason: &'static str, } -/// Marker restored on responses served from a cached transformed -/// representation. It lets diagnostics distinguish a transformed HIT from an -/// ordinary cached object without re-running the transformer. #[derive(Debug, Clone, Copy)] pub struct CachedTransformed; @@ -338,7 +312,6 @@ pub struct MetricsView { #[derive(Debug, Clone, Copy, Serialize)] pub struct ResourceUsage { - /// Estimated temporary transform bytes currently reserved by active work. pub memory_reserved_bytes: usize, pub memory_budget_bytes: usize, pub inflight: usize, @@ -422,9 +395,5 @@ impl Metrics { } } -/// Marks a response whose upstream content-negotiation dimension has been -/// normalized to the identity representation. The shared cache can therefore -/// ignore origin `Vary: Accept-Encoding`; downstream compression recreates the -/// client-specific encoding after the cache lookup. #[derive(Debug, Clone, Copy)] pub struct CanonicalIdentity; diff --git a/src/transform/svg.rs b/src/transform/svg/mod.rs similarity index 85% rename from src/transform/svg.rs rename to src/transform/svg/mod.rs index 8926def..fb4a875 100644 --- a/src/transform/svg.rs +++ b/src/transform/svg/mod.rs @@ -1,8 +1,5 @@ use anyhow::{Context, Result}; -/// Conservative SVG optimization. Whitespace is preserved because it can be -/// visible inside text/tspan nodes. Only XML comments outside CDATA sections are -/// removed; malformed comment/CDATA markup is left unchanged. pub fn optimize(body: &[u8]) -> Result> { let input = std::str::from_utf8(body).context("SVG is not UTF-8")?; let bytes = input.as_bytes(); @@ -51,4 +48,4 @@ fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option { } #[cfg(test)] -include!("svg/tests.rs"); +include!("tests.rs"); diff --git a/src/update_check.rs b/src/update_check/mod.rs similarity index 87% rename from src/update_check.rs rename to src/update_check/mod.rs index 5802c66..41a90e9 100644 --- a/src/update_check.rs +++ b/src/update_check/mod.rs @@ -1,10 +1,3 @@ -//! Small, fail-soft release checker for the administration footer. -//! -//! Defaults come from the repository-root UPDATE_CHECK.toml and are embedded in -//! the final binary. Operators may override them at process startup. A failed -//! request never affects proxy traffic or administration; the next scheduled -//! pass simply tries again. - use anyhow::{bail, ensure, Context, Result}; use axum::body::Body; use http::{header, Request, Uri}; @@ -26,16 +19,14 @@ use std::{ use tokio_util::sync::CancellationToken; use url::Url; -const EMBEDDED_DEFAULTS: &str = include_str!("../UPDATE_CHECK.toml"); +const EMBEDDED_DEFAULTS: &str = include_str!("../../UPDATE_CHECK.toml"); const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const MAX_VERSION_DOCUMENT_BYTES: usize = 256 * 1024; const MAX_REDIRECTS: usize = 5; const SYSTEM_CA_BUNDLES: &[&str] = &[ - // FreeBSD / OPNsense and common ca_root_nss locations. "/etc/ssl/cert.pem", "/usr/local/share/certs/ca-root-nss.crt", "/usr/local/etc/ssl/cert.pem", - // Debian/Ubuntu, RHEL/Fedora and Alpine families. "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", @@ -69,11 +60,10 @@ impl Config { "repository_url" => repository_url = Some(parse_string(value, key)?), "version_url" => version_url = Some(parse_string(value, key)?), "interval_seconds" => { - interval_seconds = Some( - value - .parse::() - .with_context(|| "UPDATE_CHECK.toml interval_seconds must be an integer")?, - ) + interval_seconds = + Some(value.parse::().with_context(|| { + "UPDATE_CHECK.toml interval_seconds must be an integer" + })?) } _ => bail!("UPDATE_CHECK.toml contains unsupported key {key}"), } @@ -100,14 +90,14 @@ impl Config { if let Some(value) = interval_seconds { self.interval_seconds = value; } - Self::validated( - self.repository_url, - self.version_url, - self.interval_seconds, - ) + Self::validated(self.repository_url, self.version_url, self.interval_seconds) } - fn validated(repository_url: String, version_url: String, interval_seconds: u64) -> Result { + fn validated( + repository_url: String, + version_url: String, + interval_seconds: u64, + ) -> Result { let repository_url = validate_http_url("update repository URL", &repository_url)?; let version_url = validate_http_url("update version URL", &version_url)?; ensure!( @@ -176,7 +166,10 @@ impl Default for SharedStatus { impl SharedStatus { pub fn configure(&self, config: &Config) { - let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut view = self + .view + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); view.repository_url = Some(config.repository_url.clone()); } @@ -188,7 +181,10 @@ impl SharedStatus { } fn record_success(&self, latest_version: String) { - let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut view = self + .view + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); view.update_available = is_newer_version(&latest_version, &view.current_version); view.latest_version = Some(latest_version); } @@ -226,12 +222,11 @@ async fn check_once(config: &Config) -> Result { async fn fetch(url: &str) -> Result { let roots = load_root_store(); - let tls = ClientConfig::builder_with_provider(Arc::new( - rustls::crypto::ring::default_provider(), - )) - .with_safe_default_protocol_versions()? - .with_root_certificates(roots) - .with_no_client_auth(); + let tls = + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); let mut http = HttpConnector::new(); http.enforce_http(false); http.set_connect_timeout(Some(REQUEST_TIMEOUT)); @@ -287,13 +282,10 @@ async fn fetch(url: &str) -> Result { .collect() .await .map_err(|error| { - anyhow::anyhow!( - "update version document is too large or incomplete: {error}" - ) + anyhow::anyhow!("update version document is too large or incomplete: {error}") })? .to_bytes(); - return String::from_utf8(body.to_vec()) - .context("update version document is not UTF-8"); + return String::from_utf8(body.to_vec()).context("update version document is not UTF-8"); } unreachable!("redirect loop always returns or fails at the configured limit") @@ -395,7 +387,10 @@ fn parse_version(value: &str) -> Result<([u64; 3], Option<&str>)> { let major = parts.next().context("missing major version")?.parse()?; let minor = parts.next().context("missing minor version")?.parse()?; let patch = parts.next().context("missing patch version")?.parse()?; - ensure!(parts.next().is_none(), "version must contain major.minor.patch"); + ensure!( + parts.next().is_none(), + "version must contain major.minor.patch" + ); if let Some(pre) = pre { ensure!(!pre.is_empty(), "pre-release version must not be empty"); } @@ -420,4 +415,4 @@ fn is_newer_version(candidate: &str, current: &str) -> bool { } #[cfg(test)] -include!("update_check/tests.rs"); +include!("tests.rs"); diff --git a/src/upstream.rs b/src/upstream.rs index d5eec66..c57b1d8 100644 --- a/src/upstream.rs +++ b/src/upstream.rs @@ -1,6 +1,3 @@ -//! Per-backend connection pools and connect-time DNS validation. -//! No HTTP caller can supply a destination. DNS answers are validated once and -//! passed directly to HttpConnector; there is no second unvalidated lookup. use crate::config::{ip_policy, Backend}; use anyhow::{ensure, Context, Result}; use http::Uri; @@ -152,8 +149,6 @@ impl AddressPolicy { #[derive(Clone, Debug)] pub struct GuardedResolver { policy: AddressPolicy, - /// When TLS uses a separate verified server name, resolve/connect only the - /// hostname from the configured backend URL, never the SNI hostname. connect_host: Option, } impl Service for GuardedResolver { @@ -178,8 +173,6 @@ impl Service for GuardedResolver { io::Error::new(io::ErrorKind::WouldBlock, "DNS worker limit reached") })?; let lookup = tokio::task::spawn_blocking(move || { - // The permit lives inside the worker even if the caller times out. - // An OS resolver stuck in getaddrinfo cannot create an unbounded worker queue. let _permit = permit; policy.validate_answers((hostname.as_str(), 0).to_socket_addrs()?) }); @@ -197,8 +190,6 @@ pub struct TimedConnector { inner: InnerConnector, policy: AddressPolicy, timeout: Duration, - /// The configured backend/TLS identity, never the forwarded HTTP Host. - /// The resolver separately pins DNS to the configured backend URL. destination: Uri, } impl Service for TimedConnector { @@ -210,8 +201,6 @@ impl Service for TimedConnector { self.inner.poll_ready(cx).map_err(Into::into) } fn call(&mut self, _request_uri: Uri) -> Self::Future { - // A preserved HTTP/2 :authority can be a public vhost name. Never - // resolve or dial it: all sockets must target the configured origin. let uri = self.destination.clone(); if let Some(ip) = uri.host().and_then(|host| { host.trim_start_matches('[') @@ -259,15 +248,6 @@ fn roots(backend: &Backend) -> Result { Ok(roots) } -/// Explicit backend-only escape hatch. `tls_verify=false` means that the backend -/// certificate is not authenticated at all: chain/name checks and certificate -/// signature parsing are skipped. TLS transport encryption and the normal -/// address/DNS/loop policy remain in force. -/// -/// Do not delegate CertificateVerify to WebPkiServerVerifier here. webpki parses -/// the presented X.509 certificate even after `verify_server_cert()` accepted it, -/// which rejects legacy X.509 v1 appliance certificates with -/// `UnsupportedCertVersion`. That made `tls_verify=false` only a partial bypass. #[derive(Debug)] struct BackendCertificateVerificationDisabled { supported_schemes: Vec, @@ -303,8 +283,6 @@ impl ServerCertVerifier for BackendCertificateVerificationDisabled { self.supported_schemes.clone() } } -/// A bounded raw TCP liveness check, using the same configured-origin address -/// policy as normal requests. Every DNS answer is checked before any connection. pub async fn tcp_probe(origin: &str, policy: &AddressPolicy) -> Result<()> { let url = url::Url::parse(origin)?; let host = url @@ -340,13 +318,9 @@ pub async fn tcp_probe(origin: &str, policy: &AddressPolicy) -> Result<()> { #[cfg(test)] include!("upstream/tests.rs"); -/// Enumerate local interfaces for wildcard-listener loop protection. If unavailable, -/// the fallback checks whether a candidate address is actually bindable on this host. #[cfg(unix)] fn local_addresses() -> Option> { let mut first: *mut libc::ifaddrs = std::ptr::null_mut(); - // SAFETY: getifaddrs owns the returned linked list until freeifaddrs; family - // is inspected before casting each non-null sockaddr to its matching type. unsafe { if libc::getifaddrs(&mut first) != 0 { return None; @@ -381,9 +355,6 @@ fn local_addresses() -> Option> { None } -/// Fallback used when interface enumeration is unavailable. Binding an ephemeral -/// UDP socket to an address succeeds only when the OS considers that address local -/// (without non-local/freebind socket options, which Rust does not enable here). fn locally_bindable(ip: IpAddr) -> bool { std::net::UdpSocket::bind(SocketAddr::new(ip, 0)).is_ok() } diff --git a/src/upstream/client.rs b/src/upstream/client.rs index 822d2aa..f993f0b 100644 --- a/src/upstream/client.rs +++ b/src/upstream/client.rs @@ -22,8 +22,6 @@ use std::{ time::{Duration, Instant}, }; -/// Hyper's public error chain omits the GOAWAY reason, but Debug retains -/// the peer-initiated H2 `NO_ERROR` variant. Restrict this to graceful closure. pub fn is_graceful_h2_goaway(error: &(dyn std::error::Error + 'static)) -> bool { let chain = format!("{error:?}"); chain.contains("GoAway(") && chain.contains("NO_ERROR") && chain.contains("Remote") @@ -64,12 +62,6 @@ struct RotatingClient { graceful_goaway_at_ms: AtomicU64, } -/// Backend HTTP client with bounded generation lifetime. -/// -/// Hyper reuses a client/pool for efficiency. Under sustained traffic that also -/// makes connection/TLS/buffer high-water allocations live as long as the -/// compiled vhost snapshot. Recycling only this client generation bounds that -/// lifetime without reloading the vhost or touching cache/configuration state. #[derive(Clone)] pub struct ProxyClient { inner: Arc, @@ -127,8 +119,6 @@ impl ProxyClient { match &response { Ok(value) => self.inner.observe_protocol(value.version()), Err(error) if is_graceful_h2_goaway(error) => { - // Also applies to cache refresh and other backend HTTP callers, - // not only the regular forward loop. self.note_graceful_goaway(generation); } Err(_) => {} @@ -136,8 +126,6 @@ impl ProxyClient { response } - /// A peer may gracefully close a reused H2 connection. Discard only the - /// affected client generation; concurrent requests must not rotate again. pub fn note_graceful_goaway(&self, generation: u64) { self.inner .graceful_goaway_at_ms @@ -145,8 +133,6 @@ impl ProxyClient { self.inner.rotate(generation, "h2_goaway_no_error", false); } - /// A cancellation immediately following GOAWAY may be another stream on - /// the closing H2 connection. Keep this correlation window short. pub fn recently_saw_graceful_goaway(&self) -> bool { let at = self.inner.graceful_goaway_at_ms.load(Ordering::Acquire); at != 0 && monotonic_millis().saturating_add(1).saturating_sub(at) <= 1_000 @@ -160,15 +146,10 @@ impl ProxyClient { self.inner.stats() } - /// Whether a newly compiled backend client has exactly the same connection - /// policy and lifecycle settings as this one. Compatible clients can be - /// retained across unrelated snapshot rebuilds so generation age and - /// rotation counters are not restarted from zero. pub(crate) fn reusable_with(&self, other: &Self) -> bool { self.inner.factory == other.inner.factory } - /// Recycle an aged connection pool even when no user request arrives. pub(crate) fn maintain_rotation(&self) { self.inner.maintain_rotation(); } @@ -276,8 +257,6 @@ impl RotatingClient { Ok(client) => { let next_generation = observed_generation.saturating_add(1); self.current.store(Arc::new(client)); - // Request-driven rotation sends the triggering request through - // the new generation. Maintenance-driven rotation happens idle. self.generation_requests .store(if triggering_request { 1 } else { 0 }, Ordering::Relaxed); self.generation_started_ms @@ -292,9 +271,6 @@ impl RotatingClient { ); } Err(error) => { - // The same immutable settings built the current client. If a - // future dependency makes rebuilding fail, keep serving through - // the current generation and retry only after another interval. self.generation_requests.store(0, Ordering::Relaxed); self.generation_started_ms .store(monotonic_millis(), Ordering::Relaxed); @@ -352,9 +328,6 @@ fn build_raw_client(factory: &ClientFactory) -> Result { let keepalive = &factory.keepalive; let timeout = factory.timeout; let is_https = url::Url::parse(&backend.url)?.scheme() == "https"; - // WebSocket HTTP/1.1 Upgrade retains its isolated H1 client. Preserve - // the 0.32.2 `http2` meaning: HTTPS allows H2/H1 via ALPN, while - // plaintext uses H2C. Only the NEW `h2` value is strict on HTTPS. let protocol = if websocket { UpstreamProtocol::Http1 } else if is_https && backend.protocol == UpstreamProtocol::Http2 { @@ -409,10 +382,6 @@ fn build_raw_client(factory: &ClientFactory) -> Result { let builder = HttpsConnectorBuilder::new() .with_tls_config(config) .https_or_http(); - // TLS Auto offers h2 then HTTP/1.1 in a single handshake. Negotiating - // HTTP/1.1 here happens *before* any request is sent: there is no unsafe - // automatic replay after a timed-out or partially delivered POST/PATCH. - // Cleartext Auto uses H1; H2C requires the explicit Http2 mode. let protocol = if !is_https && protocol == UpstreamProtocol::Auto { UpstreamProtocol::Http1 } else { @@ -449,9 +418,6 @@ fn build_raw_client(factory: &ClientFactory) -> Result { ) .http2_keep_alive_timeout(Duration::from_secs(10)) .http2_keep_alive_while_idle(false); - // The request URI is free to carry the preserved public H2 :authority, - // while TLS SNI/verification uses tls_server_name (or backend URL) and - // DNS/TCP remains restricted to backend.url by GuardedResolver. let origin = url::Url::parse(&backend.url)?; let network_host = match origin.host().context("backend host missing")? { url::Host::Ipv6(ip) => format!("[{ip}]"), diff --git a/src/upstream/client_tests.rs b/src/upstream/client_tests.rs index aedca3d..3d19dbe 100644 --- a/src/upstream/client_tests.rs +++ b/src/upstream/client_tests.rs @@ -17,8 +17,6 @@ mod tests { let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); assert_eq!(client.protocol_stats().last_version, None); let shared = client.clone(); - // Exercise the response-version recorder independently of the network; - // a live TLS ALPN acceptance test must still run on staging. shared.inner.observe_protocol(Version::HTTP_11); shared.inner.observe_protocol(Version::HTTP_2); let observed = client.protocol_stats(); diff --git a/src/upstream/protocol_tests.rs b/src/upstream/protocol_tests.rs index 9057baf..c50c175 100644 --- a/src/upstream/protocol_tests.rs +++ b/src/upstream/protocol_tests.rs @@ -1,4 +1,3 @@ -// Real Hyper + Rustls upstream peers, independent of the Python mock. async fn tls_origin( offer_h2: bool, ) -> ( @@ -26,8 +25,6 @@ async fn tls_origin( let (observed_tx, observed_rx) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { let (tcp, _) = listener.accept().await.unwrap(); - // For an H2-only client and H1-only server, negotiation can fail - // before any HTTP request is delivered. That is the desired error. let Ok(tls) = acceptor.accept(tcp).await else { return; }; @@ -222,8 +219,6 @@ fn default_protocol_and_serialization_are_backward_compatible() { } } -// Exercise real TLS ALPN and the actual Hyper connector rather than only the -// local authority helper. The public Host must never become a DNS/TCP target. async fn tls_authority_origin( offer_h2: bool, ) -> ( @@ -334,8 +329,6 @@ async fn auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin() { assert_eq!(response.version(), Version::HTTP_2); let (version, authority, host, sni) = seen.await.unwrap(); assert_eq!(version, Version::HTTP_2); - // Legacy Hyper may preserve H1-compatible Host on ALPN H2; neither value - // may select a different origin or differ from the intended public name. assert_eq!(authority.or(host).as_deref(), Some("unitraklub.pl")); assert_eq!(sni.as_deref(), Some("localhost")); server.abort(); diff --git a/src/upstream/tests.rs b/src/upstream/tests.rs index 6de4be4..15686f9 100644 --- a/src/upstream/tests.rs +++ b/src/upstream/tests.rs @@ -80,8 +80,6 @@ mod tests { #[tokio::test] async fn explicit_h2c_backend_uses_http2_prior_knowledge() { - // The persisted HTTP/2 value and the new strict H2 value both retain - // plaintext HTTP/2 prior knowledge, with no HTTP/1.1 probe. for mode in ["http2", "h2"] { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); diff --git a/src/user_agent.rs b/src/user_agent/mod.rs similarity index 90% rename from src/user_agent.rs rename to src/user_agent/mod.rs index 0c305e1..e8716b7 100644 --- a/src/user_agent.rs +++ b/src/user_agent/mod.rs @@ -1,7 +1,3 @@ -//! Bounded global/per-vhost User-Agent blocking. -//! -//! Plain patterns match case-insensitive substrings. `*` acts as a wildcard; -//! patterns without `*` remain intentionally simple for operator blocklists. use anyhow::{ensure, Result}; use http::{header::USER_AGENT, HeaderMap}; use serde::{Deserialize, Serialize}; @@ -56,11 +52,11 @@ impl Policy { } } -// Shared matching semantics for both blocklists and per-vhost UA throttling. pub(crate) fn validate_pattern(raw: &str) -> Result { let value = raw.trim(); ensure!( - !value.is_empty() && value.len() <= MAX_PATTERN_BYTES + !value.is_empty() + && value.len() <= MAX_PATTERN_BYTES && !value.chars().any(char::is_control), "User-Agent patterns must be 1..{MAX_PATTERN_BYTES} bytes without controls" ); diff --git a/src/vhost.rs b/src/vhost.rs index 62b9e31..f8074f9 100644 --- a/src/vhost.rs +++ b/src/vhost.rs @@ -1,6 +1,3 @@ -//! A vhost owns its frontend bindings, backend pool, paths and response policy. -//! A Host header is never evidence that traffic arrived on a private interface. - include!("vhost/model.rs"); include!("vhost/error_fallback.rs"); include!("vhost/redirect.rs"); diff --git a/src/vhost/error_fallback.rs b/src/vhost/error_fallback.rs index 0575826..36d7754 100644 --- a/src/vhost/error_fallback.rs +++ b/src/vhost/error_fallback.rs @@ -7,9 +7,7 @@ fn default_error_fallback_statuses() -> Vec { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub struct ErrorFallback { - /// Stable ID of an internal Custom Location within the same vhost. pub target: String, - /// HTTP statuses intercepted before normal response finalization/cache publication. #[serde(default = "default_error_fallback_statuses")] pub statuses: Vec, } diff --git a/src/vhost/model.rs b/src/vhost/model.rs index de45f7c..97c8d57 100644 --- a/src/vhost/model.rs +++ b/src/vhost/model.rs @@ -23,13 +23,9 @@ pub const DEFAULT_PROXY_SEND_TIMEOUT_SECONDS: u64 = 60; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ProxyTimeouts { - /// TCP/TLS connect timeout for each backend connection. Explicit null inherits the runtime default. pub connect_timeout_seconds: Option, - /// Deadline until upstream response headers arrive. Explicit null inherits the process startup value. pub response_header_timeout_seconds: Option, - /// Maximum idle gap while reading the upstream response body. Explicit null inherits the process startup value. pub read_timeout_seconds: Option, - /// Maximum idle gap while streaming the client request body upstream. Explicit null inherits the process startup value. pub send_timeout_seconds: Option, } impl Default for ProxyTimeouts { @@ -91,18 +87,13 @@ impl MethodPolicy { .flatten() } } -/// Per-vhost request Content-Type filtering. This inspects only headers and -/// deliberately never reads the body before deciding whether to reject it. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default, deny_unknown_fields)] pub struct ContentTypePolicy { pub enabled: bool, - /// Exact media types (`multipart/form-data`) or type families (`image/*`). pub blocked: Vec, } -/// MIME media type tokens use the HTTP tchar alphabet, excluding `*` here; -/// `type/*` is the only accepted wildcard form in configured patterns. fn mime_token(value: &str) -> bool { !value.is_empty() && value.len() <= 127 @@ -141,10 +132,6 @@ impl ContentTypePolicy { Ok(()) } - /// Reject matching media types before body parsing, upstream routing, and cache lookup. - /// Multiple or malformed Content-Type values are ambiguous, so reject them - /// when filtering is enabled rather than let the backend interpret them differently. - /// An absent Content-Type header is not matched (the policy is not a body WAF). pub fn blocks(&self, headers: &http::HeaderMap) -> bool { if !self.enabled { return false; @@ -159,8 +146,6 @@ impl ContentTypePolicy { let Ok(raw) = raw.to_str() else { return true; }; - // A comma outside a quoted parameter indicates a combined/ambiguous - // header. RFC MIME parameters may legitimately contain quoted commas. let mut quoted = false; let mut escaped = false; for byte in raw.bytes() { @@ -237,7 +222,6 @@ impl ProxyTimeouts { pub enum PathMatch { #[default] Prefix, - /// Nginx-style ^~ prefix: an exact/longest prefix match skips regex routes. PrefixPriority, Exact, Regex, @@ -317,41 +301,28 @@ impl Source { pub struct Vhost { #[serde(default = "yes")] pub websockets: bool, - /// Opt-in compatibility for legacy WebSocket origins. This affects only - /// WebSocket upstream handshakes and leaves ordinary HTTP unchanged. #[serde(default)] pub websocket_legacy_support: bool, - /// RFC 8441 WebSocket Extended CONNECT over HTTP/2. #[serde(default)] pub websocket_rfc8441: bool, - /// Experimental RFC 9220 WebSocket Extended CONNECT over HTTP/3. - /// Classic HTTP/1.1 Upgrade remains the default WebSocket transport. #[serde(default)] pub websocket_rfc9220: bool, - /// Per-vhost HTTP/2 opt-out. TLS listeners may still advertise h2 for other vhosts. #[serde(default = "yes")] pub http2_enabled: bool, - /// Per-vhost QUIC opt-out. Shared UDP listeners may remain active for other vhosts. #[serde(default = "yes")] pub http3_enabled: bool, - /// Trust Cloudflare client IP/country headers only when the immediate peer is a Cloudflare edge address. #[serde(default)] pub cloudflare_client_ip: bool, - /// Emit proxy-owned Server-Timing diagnostics for this vhost. #[serde(default)] pub timing_headers: bool, - /// Expose the selected backend ID in X-RustProxy-Backend. Opt-in because it reveals topology. #[serde(default)] pub backend_header: bool, - /// Expose upstream response-header latency in X-RustProxy-Backend-Time. #[serde(default)] pub backend_timing_header: bool, #[serde(default)] pub rate_limit: crate::rate_limit::Settings, - /// Optional allow/block policy for HTTP methods. Vhost and location policies stack. #[serde(default)] pub method_policy: MethodPolicy, - /// Optional request Content-Type denylist (normal vhosts only in the panel). #[serde(default)] pub content_type_policy: ContentTypePolicy, #[serde(default)] @@ -359,18 +330,12 @@ pub struct Vhost { pub id: String, #[serde(default = "yes")] pub enabled: bool, - /// Redirect cleartext requests for this vhost to the selected HTTPS frontend. #[serde(default)] pub force_ssl: bool, - /// Redirect the www/non-www companion to a selected exact hostname. #[serde(default)] pub www_redirect: bool, - /// Canonical hostname for the quick www redirect. None preserves the legacy - /// first-hostname behavior. Other vhost hostnames remain independent aliases. #[serde(default, skip_serializing_if = "Option::is_none")] pub www_redirect_target: Option, - /// Forward the effective client IP to the application using Forwarded/X-Forwarded-For/X-Real-IP. - /// Disable only for applications that must see the RustProxy transport peer instead. #[serde(default = "yes")] pub forward_client_ip: bool, pub frontend: Frontend, @@ -382,11 +347,8 @@ pub struct Vhost { pub tinyauth: crate::tinyauth::Settings, #[serde(default)] pub certificate_id: Option, - /// Explicitly accept serving an SNI name with a certificate that does not - /// contain it in DNS SAN. Clients will still report a TLS name mismatch. #[serde(default)] pub allow_certificate_mismatch: bool, - /// Optional protocol/cipher override. None inherits the global TLS policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub tls_policy: Option, #[serde(default)] @@ -397,10 +359,8 @@ pub struct Vhost { pub transform: crate::transform::Overrides, #[serde(default)] pub pages: crate::pages::VhostPages, - /// Main source for the vhost fallback. Missing in older configs means Reverse Proxy. #[serde(default)] pub source: Source, - /// Nginx-style internal error_page/proxy_intercept_errors behavior for the main source. #[serde(default, skip_serializing_if = "Option::is_none")] pub error_fallback: Option, #[serde(default)] @@ -409,11 +369,8 @@ pub struct Vhost { pub load_balancing: crate::balance::Settings, #[serde(default)] pub routes: Vec, - /// Optional reusable ACL profile. The profile and the inline ACL are both enforced. #[serde(default, skip_serializing_if = "Option::is_none")] pub acl_profile_id: Option, - /// Additional reusable profiles. Explicit denies outrank all allows across - /// selected profiles. Inline ACL remains an independent restriction. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub acl_profile_ids: Vec, #[serde(default)] @@ -423,10 +380,8 @@ pub struct Vhost { #[serde(default)] pub security_headers: SecurityHeaders, #[serde(default)] pub user_agent_blocking: crate::user_agent::Policy, - /// Nginx-style upstream timeout controls. Empty values inherit process defaults. #[serde(default)] pub proxy_timeouts: ProxyTimeouts, - /// Runtime-only marker for a published Docker vhost whose service currently has no route target. #[serde(skip)] pub(crate) transient_unavailable: bool, } @@ -440,19 +395,13 @@ pub struct Frontend { pub struct Listener { pub address: SocketAddr, pub tls: bool, - /// Enabling HTTP/2 never disables the HTTP/1.1 ALPN fallback. pub http2: bool, - /// Per-listener HTTP/2 lifecycle limits. Missing values inherit the legacy - /// global defaults for backward-compatible configuration loading. #[serde(default, skip_serializing_if = "Option::is_none")] pub http2_settings: Option, - /// Cleartext HTTP/2 support using the HTTP/2 prior-knowledge preface. #[serde(default)] pub h2c: bool, pub http3: Option, } -// Missing H3 means the secure default; explicit null remains a persisted opt-out. -// Do not skip null during serialization, or a reload would silently re-enable it. impl<'de> Deserialize<'de> for Listener { fn deserialize>( deserializer: D, @@ -509,7 +458,6 @@ pub struct PoolMember { pub weight: u16, #[serde(default = "yes")] pub enabled: bool, - /// False reserves this origin for path-pinned locations only. #[serde(default = "yes")] pub pool_eligible: bool, #[serde(default)] @@ -536,12 +484,8 @@ pub struct PoolMember { pub tls_server_name: Option, #[serde(default)] pub tls_verify: bool, - /// Maximum number of complete proxy exchanges assigned to this backend. - /// Zero keeps the historical unlimited behavior. #[serde(default)] pub max_concurrent_requests: u32, - /// Maximum time a request may wait for backend capacity when the limit is - /// reached. Zero rejects immediately with 503. #[serde(default)] pub queue_timeout_ms: u64, } @@ -564,19 +508,13 @@ impl PoolMember { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PathRoute { - /// Source selected for this location. A local return ignores this field. #[serde(default)] pub source: Source, - /// None balances over the whole pool. Some pins this path to one healthy - /// member; never fail over a path to an unrelated application. #[serde(default, skip_serializing_if = "Option::is_none")] pub backend_id: Option, pub id: String, - /// Internal named location. It is never selected by the public URI matcher and - /// can only be reached through an error fallback from this vhost. #[serde(default)] pub internal: bool, - /// Prefix/exact path or a Rust-regex pattern, depending on `match_type`. #[serde(default = "root")] pub path_prefix: String, #[serde(default)] @@ -585,30 +523,22 @@ pub struct PathRoute { pub enabled: bool, #[serde(default)] pub cache: Option, - /// Optional reusable ACL profile applied in addition to vhost policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub acl_profile_id: Option, #[serde(default)] pub acl: Option, - /// Location-scoped country allow/block policy, stacked with vhost/profile policy. #[serde(default)] pub country_access: crate::rate_limit::CountryAccess, - /// Location-scoped HTTP method allow/block policy, stacked with vhost policy. #[serde(default)] pub method_policy: MethodPolicy, #[serde(default)] pub headers: HeaderRules, - /// Optional origin-path transformation. Query parameters are preserved. #[serde(default)] pub rewrite: PathRewrite, - /// Nginx-style local `return`: when present no backend connection is opened. #[serde(default, skip_serializing_if = "Option::is_none")] pub response: Option, - /// Local HTTP redirect; mutually exclusive with `response`. #[serde(default, skip_serializing_if = "Option::is_none")] pub redirect: Option, - /// Intercept selected upstream/Web Server error statuses and internally dispatch - /// the original request to another internal Custom Location. #[serde(default, skip_serializing_if = "Option::is_none")] pub error_fallback: Option, } diff --git a/src/vhost/redirect.rs b/src/vhost/redirect.rs index 0052ab8..96f5e32 100644 --- a/src/vhost/redirect.rs +++ b/src/vhost/redirect.rs @@ -1,4 +1,3 @@ -// Custom-location redirect model. fn redirect_status() -> u16 { 302 } @@ -6,9 +5,6 @@ fn preserve_redirect_query() -> bool { true } -/// Local redirect action for a custom location. `rewrite_pattern` is optional; -/// when present it is applied to the normalized request path and supports the -/// Rust regex replacement syntax (`$1`, `${name}`, ...). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct LocationRedirect { diff --git a/src/vhost/tests.rs b/src/vhost/tests.rs index 4042f9d..f90d76c 100644 --- a/src/vhost/tests.rs +++ b/src/vhost/tests.rs @@ -264,3 +264,36 @@ mod default_listener_tests { } } } + +mod websocket_protocol_flags_tests { + use super::Vhost; + + #[test] + fn rfc8441_vhost_flag_deserializes_explicitly() { + let value = serde_json::json!({ + "id": "ws-h2", + "frontend": {"hosts": ["example.test"], "listener_ids": ["http"]}, + "backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}], + "websockets": true, + "websocket_rfc8441": true, + "http2_enabled": true + }); + let host: Vhost = serde_json::from_value(value).unwrap(); + assert!(host.websockets && host.websocket_rfc8441 && host.http2_enabled); + } + + #[test] + fn rfc9220_vhost_flag_deserializes_explicitly() { + let value = serde_json::json!({ + "id": "ws-h3", + "frontend": {"hosts": ["example.test"], "listener_ids": ["https"]}, + "backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}], + "websockets": true, + "websocket_rfc9220": true + }); + let host: Vhost = serde_json::from_value(value).unwrap(); + assert!(host.websockets && host.websocket_rfc9220); + + assert!(r#"{"websocket_rfc9220":true}"#.contains("\"websocket_rfc9220\":true")); + } +} diff --git a/src/vhost/validation.rs b/src/vhost/validation.rs index 2b575a4..1db8606 100644 --- a/src/vhost/validation.rs +++ b/src/vhost/validation.rs @@ -22,10 +22,6 @@ impl Default for PathRoute { } } impl PathRoute { - /// Older documents persisted the main vhost fallback as a synthetic `root` - /// custom location. 0.16 keeps that fallback separate from user locations; - /// only the untouched synthetic shape is normalized away. A deliberate `/` - /// location with any custom behavior remains a real override. pub fn is_legacy_main_fallback(&self) -> bool { self.id == "root" && self.path_prefix == "/" @@ -143,9 +139,6 @@ impl Vhost { "backend queue_timeout_ms must be 0..60000" ); } - // The main vhost backend is an unconditional internal fallback in 0.16. - // Normalize the untouched pre-0.16 synthetic root route out of the custom - // location list so adding/removing Custom locations cannot delete fallback. self.routes.retain(|route| !route.is_legacy_main_fallback()); if let Some(fallback) = &mut self.error_fallback { fallback.validate()?; @@ -197,8 +190,6 @@ impl Vhost { ); } } - // Reject cycles at configuration time; a runtime depth guard remains as a - // second line of defense for hot-reload/state inconsistencies. let mut starts = vec![None]; starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str()))); for start in starts { @@ -230,9 +221,6 @@ impl Vhost { self.proxy_timeouts.validate()?; self.rate_limit.validate()?; if let Some(policy) = &self.tls_policy { - // Listener-aware HTTP/3 compatibility is checked during full config - // compilation. A vhost may keep the H3 feature flag enabled while it - // is currently attached only to TCP listeners. policy.validate()?; } if let Some(server) = &self.server_header { @@ -316,8 +304,6 @@ pub fn validate_listener(listener: Listener) -> Result<()> { ); Ok(()) } -/// Return the other side of a www/non-www redirect pair. The target and its -/// counterpart must both be routable, even if only one was configured. fn www_partner(host: &str) -> Result { if let Some(bare) = host.strip_prefix("www.") { ensure!(!bare.is_empty() && bare.parse::().is_err(), @@ -340,8 +326,6 @@ impl Vhost { Ok(canonical) } - /// Hostnames owned by the vhost at runtime. Both the selected canonical - /// host and its www/non-www partner are claimed, even if one is implicit. pub fn routing_hosts(&self) -> Result> { let mut hosts = self.frontend.hosts.clone(); if !self.www_redirect { @@ -389,8 +373,6 @@ impl Frontend { Ok(result) } } -/// Consolidate identical / same-family wildcard sockets, but never widen routing -/// permissions. Vhost selection still checks its own unmerged bindings. pub fn listener_plan(config: &Config, policy: &ValidationPolicy) -> Result> { let mut candidates: Vec = config .listeners diff --git a/src/web_server/config/model.rs b/src/web_server/config/model.rs index df7e4e9..bcb5ddb 100644 --- a/src/web_server/config/model.rs +++ b/src/web_server/config/model.rs @@ -216,8 +216,6 @@ pub enum SymlinkPolicy { InsideRoot, } -/// Opt-in kernel zero-copy for suitable Linux plain HTTP/1.1 static GETs. -/// Other requests continue through the original streaming implementation. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum SendfileMode { @@ -231,14 +229,12 @@ pub enum SendfileMode { pub struct RewriteRule { pub pattern: String, pub replacement: String, - /// `None` means an internal rewrite. Redirects are limited to 301/302/307/308. pub status: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, deny_unknown_fields)] pub struct CacheRule { - /// `ext:` or `mime:`. pub matcher: String, pub cache_control: String, } @@ -267,12 +263,9 @@ impl Default for Precompressed { #[serde(default, deny_unknown_fields)] pub struct Php { pub enabled: bool, - /// Safely split `/script.php/extra/path` into SCRIPT_FILENAME + PATH_INFO. - /// Disabled by default because it changes URL-to-script resolution semantics. pub path_info: bool, #[serde(default = "default_php_extensions")] pub extensions: Vec, - /// `unix:/run/php/php-fpm.sock` or `tcp:127.0.0.1:9000`. pub endpoint: String, #[serde(default = "default_php_connect_timeout_ms")] pub connect_timeout_ms: u64, @@ -280,20 +273,14 @@ pub struct Php { pub request_timeout_ms: u64, #[serde(default = "default_php_max_concurrent_requests")] pub max_concurrent_requests: usize, - /// Forward the inbound Authorization header as HTTP_AUTHORIZATION. #[serde(default = "default_php_pass_authorization")] pub pass_authorization: bool, - /// Optional RustProxy-side request-body limit for PHP. 0 keeps the body unlimited here. pub max_request_body_bytes: u64, - /// Maximum bytes accepted before the CGI response-header terminator. #[serde(default = "default_php_max_response_header_bytes")] pub max_response_header_bytes: usize, - /// Maximum PHP-FPM stderr bytes retained for one request diagnostic log entry. #[serde(default = "default_php_stderr_log_bytes")] pub stderr_log_bytes: usize, - /// Trusted operator-defined static FastCGI parameters. Core CGI/HTTP/PHP_* names are reserved. pub custom_params: BTreeMap, - /// Trusted operator-defined PHP INI overrides encoded through a single PHP_VALUE parameter. pub php_values: BTreeMap, pub php_admin_values: BTreeMap, } @@ -319,8 +306,6 @@ impl Default for Php { } } -/// Reusable PHP-FPM configuration. A Web Service can reference one profile or -/// keep its local `php` settings. Profiles are part of the revisioned Config. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, deny_unknown_fields)] pub struct PhpFpmProfile { @@ -332,57 +317,34 @@ pub struct PhpFpmProfile { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default, deny_unknown_fields)] pub struct Hardening { - /// Reject high-confidence exploit probes and sensitive control/backup paths. pub block_common_attacks: bool, - /// Remove implementation/fingerprint response headers from Web Server/PHP responses. pub strip_unnecessary_headers: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Service { - /// Stable configuration identifier referenced by vhosts/locations. pub id: String, - /// Human-readable service name shown in the administration UI. pub name: String, pub document_root: PathBuf, pub index: Vec, pub directory_listing: bool, - /// Transport-only preference: it must not rotate cache identity. pub sendfile: SendfileMode, - /// Optional per-extension MIME overrides. Keys are extensions without a leading dot. pub mime_types: BTreeMap, - /// Fallback MIME type used when neither an override nor a built-in mapping exists. pub default_mime_type: String, - /// Browser/proxy cache policy emitted for static file responses. Location/vhost - /// response-header rules run later and can intentionally override this value. pub cache_control: Option, - /// More specific cache policy rules evaluated before `cache_control`. pub cache_rules: Vec, - /// Fixed response headers emitted by this Web Service. Vhost/location response - /// headers still run later and remain authoritative. pub response_headers: BTreeMap, - /// Nginx-style file candidates, e.g. `$uri`, `$uri/`, `/index.html`. pub try_files: Vec, - /// Regex rewrite/redirect rules evaluated before `try_files`. pub rewrites: Vec, - /// Serve pre-generated `.br`/`.gz` sidecars when accepted by the client. pub precompressed: Precompressed, - /// Optional downstream dynamic-compression override. Codecs remain global. pub compression: crate::compression::Overrides, - /// Extensions that should be downloaded as attachments. pub download_extensions: Vec, - /// Symlink handling. The conservative default is `deny`. pub symlink_policy: SymlinkPolicy, - /// Optional reference to a shared FPM profile; `php` stays the standalone - /// configuration and is preserved when attaching/detaching a profile. #[serde(default, skip_serializing_if = "Option::is_none")] pub php_profile_id: Option, - /// Optional FastCGI/PHP-FPM execution for configured script extensions. pub php: Php, - /// Optional bounded Apache .htaccess compatibility layer compiled at Web Service apply time. pub htaccess: HtaccessConfig, - /// Optional Web Service hardening. This is intentionally narrower than a generic WAF. pub hardening: Hardening, } diff --git a/src/web_server/fastcgi/protocol.rs b/src/web_server/fastcgi/protocol.rs index 1d28445..cc2863f 100644 --- a/src/web_server/fastcgi/protocol.rs +++ b/src/web_server/fastcgi/protocol.rs @@ -178,8 +178,6 @@ pub(super) fn fastcgi_params( params.insert("CONTENT_LENGTH".into(), value.into()); } for (name, value) in headers { - // HTTP_PROXY is a well-known CGI/FastCGI injection vector (httpoxy). - // This must never depend on the optional Web Service hardening switch. if name.as_str() == "proxy" || (!php.pass_authorization && name.as_str() == "authorization") { continue; @@ -201,10 +199,7 @@ pub(super) fn fastcgi_params( }) .or_insert_with(|| value.to_owned()); } - // HTTP/2 and HTTP/3 use :authority and may not carry a literal Host - // header. WordPress and other CGI applications require HTTP_HOST. params.entry("HTTP_HOST".into()).or_insert_with(|| context.server_name.clone()); - // Trusted operator-defined parameters cannot override request-derived/core CGI names. for (name, value) in &php.custom_params { params.insert(name.clone(), value.clone()); } @@ -224,8 +219,6 @@ pub(super) fn fastcgi_params( let value = php.php_admin_values.iter().map(|(name, item)| format!("{name}={item}")).collect::>().join("\n"); params.insert("PHP_ADMIN_VALUE".into(), value); } - // Request headers cannot synthesize PHP_VALUE/PHP_ADMIN_VALUE because only HTTP_* - // names are derived above. PHP_VALUE comes exclusively from validated operator config. let mut encoded = Vec::with_capacity(4096); for (name, value) in params { encode_fcgi_param(&mut encoded, &name, &value)?; diff --git a/src/web_server/fastcgi/response.rs b/src/web_server/fastcgi/response.rs index 97e4f1d..6c956b4 100644 --- a/src/web_server/fastcgi/response.rs +++ b/src/web_server/fastcgi/response.rs @@ -198,9 +198,6 @@ async fn php_response_inner( response } -// tower-http's compression encoder may poll a body again after observing EOF. -// An unfused `stream::unfold` panics on that second poll, aborting the response -// before the encoder can emit its final gzip/br/zstd bytes. pub(super) fn fastcgi_body_stream( rx: mpsc::Receiver>, ) -> impl futures_util::Stream> { diff --git a/src/web_server/filesystem.rs b/src/web_server/filesystem.rs index 11e050c..58c1059 100644 --- a/src/web_server/filesystem.rs +++ b/src/web_server/filesystem.rs @@ -7,9 +7,6 @@ use std::{ #[cfg(unix)] use std::{ffi::CString, sync::Arc}; -/// Only filesystem state crosses into the blocking pool. Cloning an entire -/// CompiledService here would also copy MIME, rewrite, PHP and security rules -/// for every file or candidate opened during a request. pub(super) struct FilesystemRoot { root: PathBuf, symlink_policy: SymlinkPolicy, @@ -83,11 +80,6 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> use std::os::unix::io::{AsRawFd, FromRawFd}; if components.is_empty() { - // `File::try_clone()` uses dup(2), which shares the directory offset with - // the persistent root descriptor. Passing such a clone to readdir(3) - // would leave the next root listing positioned at EOF. openat(".") - // creates a fresh open file description and therefore a fresh offset for - // every request while retaining the same O_NOFOLLOW confinement. let dot = cstring_component(".")?; let fd = unsafe { libc::openat( @@ -103,16 +95,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> let metadata = current.metadata()?; return Ok(Opened { file: current, metadata }); } - // Borrow the already-owned root descriptor for the first openat. Opening a - // child does not change its directory offset, so a dup/close pair buys no - // isolation. Empty paths still open "." above for independent readdir state. let mut current: Option = None; for (index, component) in components.iter().enumerate() { let name = cstring_component(component)?; let intermediate = index + 1 != components.len(); - // The default remains O_NOFOLLOW on every component. `inside_root` is an - // explicit Linux-only opt-in: the opened descriptor is checked through - // /proc/self/fd after each resolution step before it can be used further. let mut flags = libc::O_RDONLY | libc::O_CLOEXEC; if root.symlink_policy == SymlinkPolicy::Deny { flags |= libc::O_NOFOLLOW; @@ -120,7 +106,6 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> if intermediate { flags |= libc::O_DIRECTORY; } else { - // Avoid blocking on special files. Only regular files/directories are served. flags |= libc::O_NONBLOCK; } let parent = current.as_ref().unwrap_or(root.root_handle.as_ref()); @@ -227,15 +212,10 @@ pub(super) fn list_directory_sync( continue; } let Ok(name) = std::str::from_utf8(name) else { - // Routing intentionally accepts only valid UTF-8, so do not emit an - // unusable or ambiguously decoded link for a non-UTF-8 file name. continue; }; let mut child = components.to_vec(); child.push(name.to_owned()); - // Re-open through the root fd using the configured symlink policy. The - // default omits every symlink; Linux inside_root may include a target only - // after the opened descriptor has been proven to remain below the root. let Ok(child_opened) = secure_open_sync(root, &child) else { continue; }; diff --git a/src/web_server/htaccess/model.rs b/src/web_server/htaccess/model.rs index 6ea6c81..8706351 100644 --- a/src/web_server/htaccess/model.rs +++ b/src/web_server/htaccess/model.rs @@ -46,7 +46,6 @@ pub struct Config { pub filename: String, #[serde(default = "default_scan_subdirectories")] pub scan_subdirectories: bool, - /// Unsupported directives are warnings by default. Strict mode rejects them. pub strict: bool, } @@ -228,7 +227,6 @@ fn supported_response_header(name: &HeaderName) -> bool { fn parse_duration(value: &str) -> Result { let value = value.trim().trim_matches('"').trim(); - // The long form also starts with 'a'; only numeric suffixes use A. if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a')) .filter(|seconds| !seconds.is_empty() && seconds.bytes().all(|byte| byte.is_ascii_digit())) { diff --git a/src/web_server/htaccess/parse.rs b/src/web_server/htaccess/parse.rs index 22662f8..135d0c9 100644 --- a/src/web_server/htaccess/parse.rs +++ b/src/web_server/htaccess/parse.rs @@ -23,8 +23,6 @@ fn parse_file(path: &Path, components: Vec, config: &Config) -> Result<( continue; } if state.in_files_block != 0 { - // RustProxy always blocks .htaccess/.htpasswd directly. More general Files/FilesMatch - // access semantics remain intentionally outside the compatibility subset. continue; } let parts = tokens(line).with_context(|| format!("{}:{line_no}", path.display()))?; @@ -37,7 +35,6 @@ fn parse_file(path: &Path, components: Vec, config: &Config) -> Result<( if !state.rewrite_engine { state.pending_conditions.clear(); state.skip_next_rule = false; } } "rewritebase" => { - // Per-directory substitutions are already resolved relative to the containing scope. } "rewritecond" if state.rewrite_engine => { condition_count += 1; @@ -79,7 +76,7 @@ fn parse_file(path: &Path, components: Vec, config: &Config) -> Result<( for option in &parts[1..] { if option.eq_ignore_ascii_case("+Indexes") || option.eq_ignore_ascii_case("Indexes") { scope.directory_listing = Some(true); } else if option.eq_ignore_ascii_case("-Indexes") { scope.directory_listing = Some(false); } - else if option.eq_ignore_ascii_case("-MultiViews") { /* RustProxy has no content negotiation; already effectively off. */ } + else if option.eq_ignore_ascii_case("-MultiViews") { } else { unsupported(&mut state, config, path, line_no, format!("unsupported Options token {option}"))?; } } } diff --git a/src/web_server/metrics.rs b/src/web_server/metrics.rs index 1e2f7c6..3fd7600 100644 --- a/src/web_server/metrics.rs +++ b/src/web_server/metrics.rs @@ -1,4 +1,3 @@ -//! Fixed-cardinality, per-compiled-Web-Service counters. No request-derived labels. use http::{Method, StatusCode}; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -29,15 +28,15 @@ pub(crate) struct PhpMetrics { pub(crate) status_4xx: AtomicU64, pub(crate) status_5xx: AtomicU64, pub(crate) response_headers_us: AtomicU64, - pub(crate) response_bytes: AtomicU64, // observed uncompressed body frames, not wire bytes + pub(crate) response_bytes: AtomicU64, pub(crate) in_flight_headers: AtomicU64, pub(crate) static_requests: AtomicU64, pub(crate) php_requests: AtomicU64, pub(crate) other_requests: AtomicU64, - pub(crate) methods: [AtomicU64; 5], // GET POST PUT DELETE other - pub(crate) selected_status: [AtomicU64; 8], // 200 206 304 403 404 500 502 503 + pub(crate) methods: [AtomicU64; 5], + pub(crate) selected_status: [AtomicU64; 8], pub(crate) failures: [AtomicU64; 9], - pub(crate) compression: [AtomicU64; 4], // gzip Brotli zstd deflate (post-route encoder only) + pub(crate) compression: [AtomicU64; 4], } pub(crate) struct HeadersInFlight<'a>(&'a AtomicU64); diff --git a/src/web_server/mod.rs b/src/web_server/mod.rs index 7b2d77f..0b9b13f 100644 --- a/src/web_server/mod.rs +++ b/src/web_server/mod.rs @@ -1,9 +1,3 @@ -//! Static Web Server source integrated with the normal RustProxy vhost/location pipeline. -//! Web services are reusable configuration resources. Vhosts and locations only reference them. -//! -//! Keep this module as the stable public facade. Configuration, filesystem confinement, -//! HTTP responses, routing, FastCGI/PHP-FPM and request dispatch live in dedicated files. - mod config; mod fastcgi; mod filesystem; diff --git a/src/web_server/response/file.rs b/src/web_server/response/file.rs index f6c2486..6a1268e 100644 --- a/src/web_server/response/file.rs +++ b/src/web_server/response/file.rs @@ -1,6 +1,3 @@ -// Tokio's file reads dispatch blocking work. Larger, bounded chunks avoid one -// dispatch/copy/body-accounting pass per 4 KiB for ordinary static responses. -// Size small files and ranges to their payload so they do not reserve 64 KiB. const FILE_STREAM_CAPACITY: usize = 64 * 1024; pub(super) fn file_stream_capacity(body_length: u64) -> usize { @@ -162,9 +159,6 @@ async fn file_response_inner( return response; } - // Clone the already confined, validated file descriptor. Never reopen - // the user-controlled path for kernel sendfile (TOCTOU/symlink escape). - // When no HTTP/1 TCP bridge accepts it, the regular reader remains used. #[cfg(target_os = "linux")] let sendfile_candidate = if service.sendfile == super::SendfileMode::Auto && !multipart && single_range.is_none() && content_encoding.is_none() diff --git a/src/web_server/response/file_body.rs b/src/web_server/response/file_body/mod.rs similarity index 77% rename from src/web_server/response/file_body.rs rename to src/web_server/response/file_body/mod.rs index 6544921..db31839 100644 --- a/src/web_server/response/file_body.rs +++ b/src/web_server/response/file_body/mod.rs @@ -1,11 +1,3 @@ -//! Exact-length, data-only local file delivery. -//! -//! Hyper may stop polling after Content-Length bytes. ReaderStream alone only -//! learns EOF on the next read, leaving cache fills and body accounting open. -//! These streams come from a bounded local file read (or our multipart writer), -//! so their validated byte count is also a trustworthy end-of-stream boundary. -//! Never use this wrapper for arbitrary upstream HTTP bodies with trailers. - use bytes::Bytes; use futures_util::Stream; use http_body::{Body, Frame, SizeHint}; @@ -46,8 +38,6 @@ where } this.remaining -= data.len() as u64; if this.remaining == 0 { - // Drop the file/duplex reader now. No additional EOF read - // or caller poll is needed to release it or finish a fill. this.stream = None; } Poll::Ready(Some(Ok(Frame::data(data)))) diff --git a/src/web_server/response/file_body/tests.rs b/src/web_server/response/file_body/tests.rs index 5c53890..e4c5cef 100644 --- a/src/web_server/response/file_body/tests.rs +++ b/src/web_server/response/file_body/tests.rs @@ -90,7 +90,6 @@ async fn fully_delivered_file_fills_cache_before_consumer_drops_without_polling_ let plan = cache::plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let mut body = cache.wrap(response, ticket.clone(), plan, &settings).into_body(); assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abcdef"); - // Reproduce HTTP/1 Content-Length completion: do not ask for another frame. drop(body); assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().rejected_fills, 0); diff --git a/src/web_server/response/headers.rs b/src/web_server/response/headers.rs index 1af8292..f8bb672 100644 --- a/src/web_server/response/headers.rs +++ b/src/web_server/response/headers.rs @@ -21,8 +21,6 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio_util::io::ReaderStream; pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Response { - // Keep implementation diagnostics in logs; use the existing embedded - // application-style pages for every Web Server-generated error. if status.is_server_error() { tracing::warn!( status = status.as_u16(), @@ -30,16 +28,10 @@ pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Respo "Web Server error" ); } - // The public serve() facade strips bodies from HEAD responses while - // retaining the same Content-Length as the corresponding GET. builtin_error_response(status, &Method::GET) } pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Response { - // Reuse the bundled pages wherever one exists. For statuses such as 405, - // use the shared styled application fallback instead of a plain-text or - // unstyled generic error. Do not mark these as PageServed so normal Vhost - // error-page policy can still replace them. let (body, length) = match crate::pages::builtin_gateway_error(status) { Some(html) => ( if *method == Method::HEAD { diff --git a/src/web_server/routing.rs b/src/web_server/routing.rs index 74918d1..9c9ebdf 100644 --- a/src/web_server/routing.rs +++ b/src/web_server/routing.rs @@ -126,9 +126,6 @@ pub(super) async fn select_precompressed( if !service.precompressed.enabled || components.is_empty() { return None; } - // Multipart byte ranges mix MIME boundaries with representation bytes. Keep - // those responses on the identity representation so Content-Encoding never - // describes only the individual file fragments. if headers .get("range") .and_then(|value| value.to_str().ok()) diff --git a/src/web_server/serve.rs b/src/web_server/serve.rs index f862901..454b382 100644 --- a/src/web_server/serve.rs +++ b/src/web_server/serve.rs @@ -129,11 +129,6 @@ async fn serve_inner( { return builtin_error_response(StatusCode::FORBIDDEN, &method); } - // An existing PHP script followed by PATH_INFO is a valid virtual PHP - // request, even though the full URL is not a filesystem path. Dispatch it - // before try_files can rewrite /static.php/skins/... to /index.php (as in - // Roundcube 1.7), while still applying the original path's rewrites and - // access controls above. if let Ok(components) = path_components(&rewritten_path) { if let Some((script_path, path_info)) = resolve_php_path_info(service, &components).await { let script_components = path_components(&script_path).unwrap_or_default(); @@ -360,8 +355,6 @@ async fn serve_inner( ) } }; - // Filtering in place avoids a second Vec allocation for large directories - // while retaining exactly the same visibility and hardening checks. entries.retain(|entry| { if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) { return false; @@ -378,8 +371,6 @@ async fn serve_inner( let mut response = Response::new(if method == Method::HEAD { Body::empty() } else { - // Move the generated listing into the response. Copying it doubles - // peak HTML allocation for large directory listings under load. Body::from(listing) }); response.headers_mut().insert( @@ -399,10 +390,6 @@ async fn serve_inner( response } -/// Serve a decoded, validated routing path from a compiled Web Service. -/// The caller is responsible for normal vhost/location ACL/auth/routing selection; -/// this function performs filesystem handling plus optional Web Service routing, -/// precompressed delivery and bounded FastCGI/PHP-FPM execution. pub async fn serve( service: &CompiledService, method: Method, @@ -418,8 +405,6 @@ pub async fn serve( crate::web_server::metrics::HeadersInFlight::new(&service.web_metrics.in_flight_headers); let request_method = method.clone(); let mut response = serve_inner(service, method, headers, uri, path, body, context).await; - // HEAD carries the same metadata/status as GET, but no response body, - // including the generic filesystem and rewrite error paths. if request_method == Method::HEAD { *response.body_mut() = Body::empty(); } @@ -436,8 +421,6 @@ pub async fn serve( &request_method, kind, ); - // Count bytes actually yielded by the Web Server body before downstream compression; - // never infer transferred bytes from Content-Length or count an unpolled response. let web = service.web_metrics.clone(); let php = if matches!(kind, Some(crate::web_server::metrics::ResponseKind::Php)) { service.php.as_ref().map(|p| p.metrics.clone()) diff --git a/src/web_server/tests.rs b/src/web_server/tests.rs index 361dcc6..8f7a278 100644 --- a/src/web_server/tests.rs +++ b/src/web_server/tests.rs @@ -218,7 +218,6 @@ async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses( async fn web_server_validation_and_unmapped_error_statuses_are_styled() { let tmp = TempDir::new().unwrap(); let service = service(tmp.path(), false); - // Path validation follows the same existing built-in page as other 400s. let invalid = serve_request(&service, &request(Method::GET, "/"), "/../secret").await; assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); assert_eq!(invalid.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); @@ -229,8 +228,6 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() { .as_bytes() ); - // 405 has no dedicated packaged HTML file: reuse the embedded styled - // fallback, while preserving Allow and the actual HTTP status. let method = serve_request(&service, &request(Method::PUT, "/"), "/").await; assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); assert_eq!(method.headers()[ALLOW], "GET, HEAD"); @@ -240,8 +237,6 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() { .unwrap() .contains("405 - Method Not Allowed")); - // An operator-configured status without a dedicated HTML template still - // uses the common styled fallback, including for HEAD requests. let fallback_service = Service { id: "teapot".into(), name: "Teapot".into(), @@ -673,7 +668,6 @@ async fn fastcgi_stream_can_be_polled_repeatedly_after_eof() { Bytes::from_static(b"data") ); assert!(body.next().await.is_none()); - // Without `fuse` this panics inside futures-util::stream::unfold. assert!(body.next().await.is_none()); } @@ -1642,8 +1636,6 @@ fn fastcgi_http_host_keeps_explicit_host_and_port() { .any(|w| w == b"example.test:8443")); } -// Decode the actual FastCGI PARAMS records received by a mock PHP-FPM peer. -// This tests the Web Service dispatch and parameter generation together. fn decode_fastcgi_params(mut bytes: &[u8]) -> BTreeMap { fn read_len(bytes: &mut &[u8]) -> usize { let prefix = bytes[0]; @@ -1718,8 +1710,6 @@ async fn roundcube_static_php_path_info_runs_before_front_controller_try_files() ) .unwrap(); std::fs::write(tmp.path().join("index.php"), b" other - assert_eq!(view["selected_status"][0], 2); // 200 - assert_eq!(view["selected_status"][4], 1); // 404 + assert_eq!(view["methods"][0], 2); + assert_eq!(view["methods"][4], 1); + assert_eq!(view["selected_status"][0], 2); + assert_eq!(view["selected_status"][4], 1); assert_eq!(view["response_bytes"], 5 + missing_bytes as u64); assert_eq!(view["active_headers"], 0); } @@ -1933,8 +1922,6 @@ async fn static_streaming_keeps_large_payloads_intact_in_bounded_frames() { } assert_eq!(received, expected); if length >= 64 * 1024 { - // A normal regular file must not regress to one dispatched read and - // body-accounting pass for every 4 KiB of its representation. assert!(frames < length.div_ceil(4 * 1024)); } accounted += length as u64; @@ -2345,8 +2332,6 @@ async fn cancelled_static_and_multipart_bodies_release_files_and_count_only_emit assert!(first.len() <= 64 * 1024); accounted += first.len() as u64; assert_eq!(service.web_metrics.view()["response_bytes"], accounted); - // The multipart writer may fill its bounded duplex buffer in the - // background. Neither it nor a pending file read counts unsent bytes. tokio::task::yield_now().await; assert_eq!(service.web_metrics.view()["response_bytes"], accounted); drop(body); @@ -2377,8 +2362,6 @@ async fn sendfile_auto_without_tcp_bridge_preserves_full_head_range_and_validato get.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string() ); - // No transport adapter here. Even if a candidate extension was produced, - // its fallback Body must still stream the exact original representation. let etag = get.headers()[ETAG].clone(); assert_eq!( get.into_body().collect().await.unwrap().to_bytes().to_vec(), diff --git a/tests/acl_picker_corner_styles.cjs b/tests/acl_picker_corner_styles.cjs deleted file mode 100644 index 829c48d..0000000 --- a/tests/acl_picker_corner_styles.cjs +++ /dev/null @@ -1,24 +0,0 @@ -// Run with: node tests/acl_picker_corner_styles.cjs -// The shared ACL picker must respect all dashboard corner-style presets without -// relying on a separate radius setting or changing its selection behaviour. -"use strict"; -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const css = fs.readFileSync(path.resolve(__dirname, "../src/admin/web/style.css"), "utf8"); -const parts = [ - ".acl-picker-details", ".acl-picker-counter", ".acl-picker-option", - ".acl-picker-policy", ".acl-picker-tag", ".acl-picker-tag .acl-picker-remove" -]; -for (const selector of parts) { - const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const block = css.match(new RegExp(`${escaped}\\s*\\{([^}]+)\\}`)); - assert.ok(block, `Missing shared picker style: ${selector}`); - assert.match(block[1], /border-radius:\s*var\(--rp-radius-control,\s*[^)]+\);/, `${selector} must use the dashboard control radius`); -} -for (const [preset, radius] of Object.entries({ square: 0, subtle: 4, rounded: 8, extra: 12 })) { - const rule = css.match(new RegExp(`html\\[data-corner-style=${preset}\\]\\s*\\{([^}]+)\\}`)); - assert.ok(rule, `Missing corner-style preset: ${preset}`); - assert.match(rule[1], new RegExp(`--rp-radius-control:\\s*${radius}px;`), `Unexpected radius for ${preset}`); -} -console.log("ACL picker corner styles: PASS (six surfaces, four presets, Original fallbacks)"); diff --git a/tests/acl_picker_dom.cjs b/tests/acl_picker_dom.cjs deleted file mode 100644 index 24b679e..0000000 --- a/tests/acl_picker_dom.cjs +++ /dev/null @@ -1,72 +0,0 @@ -// Run with: node tests/acl_picker_dom.cjs. Tiny DOM stub for picker state/regression. -"use strict"; -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const vm = require("node:vm"); -const path = require("node:path"); -class DomEvent { - constructor(type, options = {}) { this.type = type; this.bubbles = !!options.bubbles; this.target = null; } -} -class DomNode { - constructor(tag) { this.tag = tag; this.className = ""; this.children = []; this.parent = null; this.dataset = {}; this.handlers = new Map(); this.hidden = false; this.disabled = false; this.checked = false; this.value = ""; this.type = ""; this._text = ""; } - get textContent() { return this._text + this.children.map(c => c.textContent).join(""); } - set textContent(text) { this.replaceChildren(); this._text = String(text); } - append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } } - replaceChildren(...children) { for (const child of this.children) child.parent = null; this.children = []; this._text = ""; this.append(...children); } - setAttribute() { } - focus() { } - addEventListener(type, handler) { if (!this.handlers.has(type)) this.handlers.set(type, []); this.handlers.get(type).push(handler); } - dispatchEvent(event) { if (!event.target) event.target = this; for (const handler of this.handlers.get(event.type) || []) handler(event); if (event.bubbles && this.parent) this.parent.dispatchEvent(event); } - click() { this.dispatchEvent(new DomEvent("click", {bubbles:true})); } - hasClass(name) { return this.className.split(/\s+/).includes(name); } - matches(selector) { return selector === 'input[type="checkbox"]' && this.tag === "input" && this.type === "checkbox"; } - all() { return this.children.flatMap(child => [child, ...child.all()]); } - querySelectorAll(selector) { - const descendants = this.all(); - if (selector === '.acl-picker-option input[type="checkbox"]:checked') return descendants.filter(n => n.tag === "input" && n.type === "checkbox" && n.checked && n.parent?.hasClass("acl-picker-option")); - if (selector === '.acl-picker-option input[type="checkbox"]') return descendants.filter(n => n.tag === "input" && n.type === "checkbox" && n.parent?.hasClass("acl-picker-option")); - const value = selector.match(/^\.acl-picker-option input\[value="([^"]+)"\]$/); - if (value) return descendants.filter(n => n.tag === "input" && n.value === value[1] && n.parent?.hasClass("acl-picker-option")); - const cls = selector.match(/^\.([\w-]+)$/); - if (cls) return descendants.filter(n => n.hasClass(cls[1])); - throw Error("Unsupported test selector " + selector); - } - querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } -} -const profiles = Array.from({length:10}, (_,i) => ({ - id: `profile_${i}`, name: i === 0 ? "Office networks" : `Profile ${i}`, - acl: {default: "deny", rules:[{network:`10.${i}.0.0/16`,action:"allow"},{network:"192.0.2.0/24",action:"deny"}]}, - country_access: {mode: i === 0 ? "block" : "off", countries: i === 0 ? ["RU"] : []} -})); -const context = { - window: {}, - envelope: {config:{acl_profiles:profiles}}, - el(tag, text, cls) { const node = new DomNode(tag); if (text !== undefined) node.textContent = text; if (cls) node.className = cls; return node; }, - t: key => key, - Event: DomEvent, -}; -vm.createContext(context); -vm.runInContext(fs.readFileSync(path.resolve(__dirname, "../src/admin/web/acl-picker.js"), "utf8"), context); -const picker = context.window.rustProxyAclPicker; -const root = picker.create(["profile_0", "profile_1"], "vh-acl-profile-choices"); -assert.deepEqual(Array.from(picker.read(root)), ["profile_0", "profile_1"]); -assert.equal(root.querySelectorAll(".acl-picker-tag").length, 2); -assert.match(root.textContent, /10\.0\.0\.0\/16/); -assert.match(root.textContent, /RU/); -const search = root.querySelector(".acl-picker-search"); -search.value = "Office"; search.dispatchEvent(new DomEvent("input", {bubbles:true})); -assert.equal(root.querySelectorAll(".acl-picker-option").filter(node => !node.hidden).length, 1); -search.value = ""; search.dispatchEvent(new DomEvent("input", {bubbles:true})); -root.querySelector(".acl-picker-remove").click(); -assert.deepEqual(Array.from(picker.read(root)), ["profile_1"]); -for (const check of root.querySelectorAll('.acl-picker-option input[type="checkbox"]').slice(2,9)) { - check.checked = true; - check.dispatchEvent(new DomEvent("change", {bubbles:true})); -} -assert.equal(picker.count(root), 8); -assert.equal(root.querySelector('.acl-picker-option input[value="profile_9"]').disabled, true); -root.querySelector(".acl-picker-remove").click(); -assert.equal(root.querySelector('.acl-picker-option input[value="profile_9"]').disabled, false); -const docker = picker.create([], "docker-acl-profile"); -assert.deepEqual(Array.from(picker.read(docker)), []); // empty override inherits agent profiles -console.log("ACL picker DOM regression: PASS (choices, preview, search, badges, limit and inheritance)"); diff --git a/tests/cache_outcomes.cjs b/tests/cache_outcomes.cjs deleted file mode 100644 index 81012d5..0000000 --- a/tests/cache_outcomes.cjs +++ /dev/null @@ -1,96 +0,0 @@ -"use strict"; -// Zero-dependency browser calculation regressions (run: node tests/cache_outcomes.cjs). -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const vm = require("node:vm"); -const root = path.join(__dirname, ".."); -const src = fs.readFileSync(path.join(root, "src/admin/web/app/runtime.js"), "utf8"); -const begin = src.indexOf("function cacheOutcomeMix(totals) {"); -const renderBegin = src.indexOf("\nfunction renderCacheMix(mix, totalRequests) {", begin); -const end = src.indexOf("\nfunction renderDiagnostics(", renderBegin); -assert.ok(begin >= 0 && renderBegin > begin && end > renderBegin, "production cumulative widget functions exist"); -const mix = vm.runInNewContext(`${src.slice(begin, renderBegin)}; cacheOutcomeMix;`); -const result = mix([10, 4, 2, 3]); -assert.deepEqual(Array.from(result.counts), [4, 2, 3, 1]); -assert.deepEqual(Array.from(result.percentages), [40, 20, 30, 10]); -assert.equal(result.requests, 10); -assert.deepEqual(Array.from(mix([12, 0, 0, 0]).counts), [0, 0, 0, 12]); -assert.deepEqual(Array.from(mix([9, 0, 0, 9]).counts), [0, 0, 9, 0]); -for (const invalid of [[0, 0, 0, 0], [1, 2, 0, 0], [10, -1, 0, 0], [NaN, 0, 0, 0], - [10.1, 2, 0, 0], [Number.MAX_SAFE_INTEGER + 1, 0, 0, 0], [], null]) { - assert.equal(mix(invalid), null, `invalid lifetime counters ${invalid} are not charted`); -} -// Ensure the panel uses all-time counters straight from the status payload, on every render. -const diagnostic = src.slice(end, src.indexOf("\nfunction ", end + 1)); -assert.match(diagnostic, /const counters = \[m\.requests, c\.hits, c\.misses, c\.bypasses\]/); -assert.match(diagnostic, /renderCacheMix\(cacheOutcomeMix\(counters\), counters\[0\]\);/); -assert.doesNotMatch(src, /cacheMixLastCounters|cacheMixLastShares|cacheMixLastTotal/); -const translations = ["en_US", "pl_pl"].map(language => - JSON.parse(fs.readFileSync(path.join(root, `langs/${language}.json`), "utf8")).translations); -const nodes = new Map(); -const dom = id => { - if (!nodes.has(id)) { - const node = { style: {}, textContent: "", attributes: {}, setAttribute(name, value) { this.attributes[name] = value; } }; - nodes.set(id, node); - } - return nodes.get(id); -}; -const english = translations[0]; -const render = vm.runInNewContext(`${src.slice(renderBegin + 1, end)}; renderCacheMix;`, { - $: dom, - t: key => english[key], - fmt: (value, digits = 0) => Number(value).toFixed(digits), -}); -render(result, 10); -assert.equal(dom("cache-mix-headline").textContent, "40.0% HIT"); -assert.equal(dom("cache-mix-caption").textContent, "10"); -assert.equal(dom("cache-mix-caption").attributes.title, "Since service start · 10 HTTP requests"); -assert.equal(dom("cache-mix-caption").attributes["aria-label"], "Since service start · 10 HTTP requests"); -for (const [name, count, percent] of [["hit", 4, 40], ["miss", 2, 20], - ["bypass", 3, 30], ["other", 1, 10]]) { - assert.equal(dom(`cache-mix-${name}-count`).textContent, String(count)); - assert.equal(dom(`cache-mix-${name}-percent`).textContent, `${percent.toFixed(1)}%`); - assert.equal(dom(`cache-mix-${name}`).style.width, `${percent}%`); -} -assert.match(dom("cache-mix-bar").attributes["aria-label"], /4 HIT, 2 MISS, 3 BYPASS, 1 OTHER/); -render(null, 0); -assert.equal(dom("cache-mix-caption").textContent, "0"); -assert.equal(dom("cache-mix-caption").attributes.title, "Since service start · 0 HTTP requests"); -assert.equal(dom("cache-mix-hit-count").textContent, "-"); -assert.equal(dom("cache-mix-hit").style.width, "0%"); -render(null, 10); -assert.equal(dom("cache-mix-caption").textContent, "10"); -assert.equal(dom("cache-mix-caption").attributes.title, "Since service start · 10 HTTP requests"); -assert.equal(dom("cache-mix-bar").attributes["aria-label"], english.cacheMixInvalid); -render(null, NaN); -assert.equal(dom("cache-mix-caption").textContent, "-"); -assert.equal(dom("cache-mix-caption").attributes.title, english.cacheMixInvalid); - -const widgets = JSON.parse(fs.readFileSync(path.join(root, "src/admin/web/dashboard-widgets.json"), "utf8")); -const defaults = JSON.parse(fs.readFileSync(path.join(root, "src/admin/web/dashboard-defaults.json"), "utf8")); -assert.equal(widgets.filter(widget => widget.id === "cache_outcomes").length, 1); -assert.equal(defaults.dashboard_widgets.filter(id => id === "cache_outcomes").length, 1); -assert.equal(widgets.find(widget => widget.id === "cache_outcomes").default_hidden, true, "cache outcomes is opt-in"); -assert.equal(defaults.dashboard_hidden_widgets.filter(id => id === "cache_outcomes").length, 1, "hidden in factory layout"); -const html = fs.readFileSync(path.join(root, "src/admin/web/partials/overview-metrics.html"), "utf8"); -assert.match(html, /data-dashboard-widget="cache_outcomes"/); -assert.match(html, /class="cache-mix-header">\s*[^<]+<\/span>\s* fs.readFileSync(path.join(root, file), 'utf8'); -const active = read('src/admin/web/style.css'); -// Review-only archives are intentionally optional in reproducible source ZIPs. -const unused = fs.existsSync(path.join(root, 'src/admin/web/unused.css')) ? read('src/admin/web/unused.css') : null; -const docs = read('src/admin/web/docs.css'); -const archived = [ - 'auth-ip-card', 'certificate-dialog', 'config-columns', 'overview-grid', - 'overview-inline-hidden-toggle', 'overview-layout-body', 'overview-layout-filters', - 'overview-layout-grid', 'overview-layout-hidden-list', 'overview-layout-section-heading', - 'settings-grid', 'stream-dashboard-card', 'table-container', 'transport-grid', 'www-service-heading', -]; -for (const name of archived) { - const cssClass = new RegExp(`\\.${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![-\\w])`); - assert.ok(!cssClass.test(active), `${name} must not remain in the active stylesheet`); - if (unused !== null) assert.ok(cssClass.test(unused), `${name} must be reviewable in unused.css`); -} -for (const name of ['vh-setting-section', 'dashboard-overview-grid', 'vh-override-fields', - 'refresh-settings-grid', 'docker-backend-ip-settings', 'acl-profile-layout']) { - assert.ok(active.includes(`.${name}`), `active class ${name} was removed`); -} -for (const file of [ - 'src/admin/web/index.html', 'src/admin/web/login.html', 'src/admin/web/docs.html', - 'src/admin/panel_assets.rs', 'src/admin/documentation.rs', -]) assert.ok(!read(file).includes('unused.css'), `${file} must not load or serve unused.css`); -assert.ok(!/@import[^;]*unused\.css/.test(active)); -if (unused !== null) assert.match(unused, /NOT INCLUDED by the admin panel/); -assert.match(docs, /\.swagger-ui \.opblock/); -for (const style of ['square', 'subtle', 'rounded', 'extra']) { - assert.ok(active.includes(`html[data-corner-style=${style}]`)); - assert.ok(docs.includes(`html[data-corner-style=${style}]`)); -} -for (const name of ['app-startup-spin', 'app-startup-reveal']) { - assert.ok(active.includes(`@keyframes ${name}`)); - if (unused !== null) assert.ok(!unused.includes(`@keyframes ${name}`)); -} -assert.ok(!active.includes('--backdrop-soft')); -assert.ok(!active.includes('var(--panel-strong)')); -assert.ok(!active.includes('var(--border)')); -// The review-only CSS history is not a runtime or packaging dependency. -console.log('CSS active/optional archive isolation and corner-style regression: PASS'); diff --git a/tests/docker_vhost_actions.cjs b/tests/docker_vhost_actions.cjs deleted file mode 100644 index 337b842..0000000 --- a/tests/docker_vhost_actions.cjs +++ /dev/null @@ -1,187 +0,0 @@ -// Isolated DOM regression for per-Docker-vhost live proxy logs and Publish switch. -// No native RustProxy process or browser is required for this check. -'use strict'; -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const vm = require('node:vm'); -const root = path.resolve(__dirname, '..'); -const read = name => fs.readFileSync(path.join(root, name), 'utf8'); -const docker = read('src/admin/web/docker.js'); -const css = read('src/admin/web/style.css'); -const management = read('src/admin/web/management.js'); -const en = JSON.parse(read('langs/en_US.json')).translations; -const pl = JSON.parse(read('langs/pl_pl.json')).translations; -for (const pack of [en, pl]) { - assert.ok(pack.dockerPublish && pack.liveLogs && pack.dockerLiveAccessLogsHelp); -} -assert.match(docker, /publish\.setAttribute\("role", "switch"\)/); -assert.match(css, /\.docker-publish-field \.docker-publish:checked::before/); -assert.match(css, /\.docker-publish-field \.docker-publish:focus-visible/); -assert.match(css, /\.docker-service-quick-actions/); -assert.match(management, /if \(selected\) ids\.add\(selected\)/); -assert.match(read('Cargo.toml'), /^version = "0\.34\.6"$/m); -assert.match(read('Cargo.lock'), /name = "rust-proxy"\nversion = "0\.34\.6"/); - -class Node { - constructor(tag) { - this.tag = tag; this.children = []; this.parentElement = null; this.dataset = {}; - this.className = ''; this.listeners = {}; this.attributes = {}; - this.open = false; this.hidden = false; this.disabled = false; - this.checked = false; this.value = ''; this._text = ''; this.isConnected = true; - const classes = new Set(); - this.classList = { - add: token => classes.add(token), remove: token => classes.delete(token), - toggle: (token, state) => { const wanted = state === undefined ? !classes.has(token) : state; if (wanted) classes.add(token); else classes.delete(token); return wanted; }, - contains: token => classes.has(token), - }; - } - append(...children) { this.children.push(...children); children.forEach(child => { if (child instanceof Node) child.parentElement = this; }); } - replaceChildren(...children) { this.children = []; this._text = ''; this.append(...children); } - get textContent() { return this._text + this.children.map(child => child.textContent ?? String(child)).join(''); } - set textContent(value) { this.replaceChildren(); this._text = String(value); } - setAttribute(key, value) { this.attributes[key] = String(value); } - getAttribute(key) { return this.attributes[key]; } - addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); } - async emit(name) { for (const fn of this.listeners[name] || []) await fn({target:this}); } - click() { return this.emit('click'); } -} -const controls = new Map(); -const visits = [], saves = []; -let saveResult = true; -const context = { - window: {rustProxyLogs: {open(id) { visits.push(id); }}, rustProxyAclPicker: {}, rustProxyPreferences: {}}, - document: {createElementNS: (_ns,tag) => new Node(tag), querySelectorAll: () => []}, - $: id => controls.get(id) || null, - el(tag, content, cls) { const node = new Node(tag); if (content !== undefined) node.textContent = content; if (cls) node.className = cls; return node; }, - t: key => en[key] || key, - locale: () => 'en-US', - localStorage: {getItem:() => null}, - rustProxyRefresh: {register(){}, sync(){}}, - envelope: {config: {docker_agents:{agents:[]}, listeners:{endpoints:[ - {id:'http', enabled:true, binding:{address:'0.0.0.0:80',tls:false}}, - {id:'https', enabled:true, binding:{address:'0.0.0.0:8443',tls:true}}, - ]}}}, - latestStatus:null, performance:{now:()=>0}, authenticated:false, currentPanel:'overview', - toast(message) { throw new Error('Unexpected toast: ' + message); }, -}; -vm.createContext(context); -assert.ok(docker.trimEnd().endsWith('})();')); -const injected = docker.trimEnd().slice(0, -5) + ` - // Replace the unrelated configuration editors and persistence with controlled stubs. - aliasEditor = () => ({node:el('div'), input:el('input'), read:()=>[], count:()=>0}); - targetOptions = () => []; - aclEditor = timeoutEditor = healthEditor = passiveHealthEditor = () => el('details'); - saveAssignment = async (agentId, row, enabled) => window._saveAssignment(agentId, row, enabled); - window._dockerActions = {assignmentRow, proxiedUrl, setRoutes(routes) { poolRoutes = routes; }}; -})();`; -context.window._saveAssignment = async (agentId, row, published) => { saves.push([agentId,row.dataset.key,published]); return saveResult; }; -vm.runInContext(injected, context); -const actions = context.window._dockerActions; -const agent = {id:'docker-1',name:'Docker 1',base_domain:'example.test',listener_ids:['http','https'],enabled:true}; -const service = {key:'container:app:8080/tcp',technical_name:'app-8080',container_name:'app',image:'myapp:v1',private_port:8080,bindings:[{bind_ip:'0.0.0.0',public_port:8080}]}; -const assignment = {service_key:service.key,hostname:'app',enabled:true}; -const logId = 'docker-agent-41a1b2c3d4e5'; -actions.setRoutes([{agent_id:agent.id,service_key:service.key,pool_id:logId}]); -const row = actions.assignmentRow(agent,null,service,assignment,false); -const [head,editor,metrics] = row.children; -assert.equal(editor.tag,'details'); assert.equal(metrics.tag,'details', 'published route retains collapsed metrics'); -const [publishLabel,,headActions] = head.children; -const publish = publishLabel.children[1]; -assert.equal(publishLabel.className,'docker-publish-field'); -assert.equal(publish.getAttribute('role'),'switch'); -assert.equal(publish.checked,true); -assert.equal(publish.getAttribute('aria-checked'),'true'); -const [runtime,quickActions] = headActions.children; -assert.equal(runtime.className, 'docker-runtime'); -const [openApp,openLogs] = quickActions.children; -assert.equal(openApp.textContent,en.dockerOpenApp); -assert.equal(openApp.href,'https://app.example.test:8443/', 'Open app honors nested listener TLS and port'); -assert.equal(openLogs.textContent,en.liveLogs); -assert.equal(openLogs.title,en.dockerLiveAccessLogsHelp); -assert.equal(openLogs.disabled,false); -assert.equal(openLogs.hidden,false); -function testLiveLogSelection() { - // Execute the actual live log dialog controller independently from other admin modules. - const start = management.indexOf('(() => {\n const dialog = $("logs-dialog");'); - assert.ok(start > 0); - const end = management.indexOf('\n})();', start) + '\n})();'.length; - const logController = management.slice(start, end); - class LogNode extends Node { - constructor(tag) { super(tag); this.value = ''; } - replaceChildren(...children) { - super.replaceChildren(...children); - if (this.tag === 'select') this.value = this.children[0]?.value || ''; - } - querySelectorAll() { return []; } - show() { this.open = true; } - showModal() { this.open = true; } - close() { this.open = false; } - } - const logControls = new Map(); - const $ = id => { - if (!logControls.has(id)) { - const tag = ['log-vhost','log-method','log-status','log-outcome'].includes(id) ? 'select' : 'div'; - logControls.set(id, new LogNode(tag)); - } - return logControls.get(id); - }; - $('log-outcome').value = 'all'; - const logContext = { - document:{createElement:tag => new LogNode(tag)}, - window:{rustProxyPreferences:{load:async()=>({live_log_columns:['time','vhost']})}}, - $, t:key => en[key] || key, el(tag,value,cls) {const node=new LogNode(tag); if(value!==undefined) node.textContent=value; if(cls)node.className=cls; return node;}, - authenticated:true, envelope:{config:{vhosts:[{id:'ordinary-vhost'}]}}, lastLogs:{entries:[]}, - formatTime:time=>String(time), toast(){throw Error('unexpected toast')}, - }; - vm.createContext(logContext); - vm.runInContext(logController,logContext); - logContext.window.rustProxyLogs.open('docker-agent-41a1b2c3d4e5'); - assert.equal($('log-vhost').value, 'docker-agent-41a1b2c3d4e5'); - const entry = (vhost) => ({vhost,time_ms:123,method:'GET',path:'/',host:'app.example.test',status:200,cache:'MISS',outcome:'ok'}); - // A refresh containing only requests from another Vhost must not remove the chosen ID. - logContext.lastLogs = {entries:[entry('ordinary-vhost')]}; - logContext.window.rustProxyLogs.render(); - assert.equal($('log-vhost').value,'docker-agent-41a1b2c3d4e5'); - assert.match($('log-count').textContent, /^0 \/ 1/); - logContext.lastLogs = {entries:[entry('ordinary-vhost'),entry('docker-agent-41a1b2c3d4e5')]}; - logContext.window.rustProxyLogs.render(); - assert.match($('log-count').textContent, /^1 \/ 2/); - assert.equal($('log-vhost').value,'docker-agent-41a1b2c3d4e5'); - $('log-vhost').value = ''; - logContext.window.rustProxyLogs.render(); - assert.match($('log-count').textContent, /^2 \/ 2/, 'manual All vhosts selection still works'); -} - -(async () => { - testLiveLogSelection(); - await openLogs.click(); - assert.deepEqual(visits,[logId], 'logs open for the returned generated ID, not the display hostname'); - publish.checked = false; - await publish.emit('change'); - assert.deepEqual(saves.at(-1),[agent.id,service.key,false]); - assert.equal(publish.getAttribute('aria-checked'),'false'); - assert.equal(openLogs.hidden,true); - assert.equal(openApp.hidden,true); - assert.equal(publish.disabled,false); - publish.checked = true; - await publish.emit('change'); - assert.deepEqual(saves.at(-1),[agent.id,service.key,true]); - assert.equal(openLogs.hidden,false); - assert.equal(openApp.hidden,false); - assert.equal(publish.getAttribute('aria-checked'),'true'); - saveResult = false; - publish.checked = false; - await publish.emit('change'); - assert.equal(publish.checked,true, 'failed unpublish reverts state'); - assert.equal(publish.getAttribute('aria-checked'),'true', 'ARIA state rolls back'); - assert.equal(openLogs.hidden,false, 'related action rolls back'); - saveResult = true; - actions.setRoutes([]); - const missingRoute = actions.assignmentRow(agent,null,service,assignment,false); - const missingLogs = missingRoute.children[0].children[2].children[1].children[1]; - assert.equal(missingLogs.disabled,true, 'no generated route ID disables logs rather than showing all vhosts'); - await missingLogs.click(); - assert.deepEqual(visits,[logId], 'missing ID never opens unfiltered logs'); - console.log('Docker publish switch, rollback, live log targeting, app links: PASS'); -})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/docker_vhost_metrics.cjs b/tests/docker_vhost_metrics.cjs deleted file mode 100644 index 8d00240..0000000 --- a/tests/docker_vhost_metrics.cjs +++ /dev/null @@ -1,139 +0,0 @@ -// Run with: node tests/docker_vhost_metrics.cjs -// Source contracts and isolated DOM/metric sampling regression for Docker vhosts. -'use strict'; -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const vm = require('node:vm'); -const root = path.resolve(__dirname, '..'); -const source = name => fs.readFileSync(path.join(root, name), 'utf8'); -const docker = source('src/admin/web/docker.js'); -const core = source('src/admin/web/app/core.js'); -const html = source('src/admin/web/index.html'); -const css = source('src/admin/web/style.css'); -const en = JSON.parse(source('langs/en_US.json')).translations; -const pl = JSON.parse(source('langs/pl_pl.json')).translations; - -assert.match(html, /id="docker-metrics-toggle-all"/); -assert.match(docker, /if \(agent\.enabled !== false && assignment && assignment\.enabled !== false\) row\.append\(statisticsSection\(agent, key\)\)/); -assert.match(docker, /"docker-service-editor docker-service-metrics"/); -assert.ok(docker.indexOf('row.append(editor);') < docker.indexOf('row.append(statisticsSection(agent, key))')); -for (const key of ['status', 'vhRequests', 'vhActive', 'vhErrors', 'vhLatency', 'vhTraffic', 'vhLifetimeChart']) { - assert.ok(docker.includes(`"${key}"`), `missing ordinary Vhost metric: ${key}`); -} -for (const key of ['dockerTrafficStatistics', 'dockerExpandAllMetrics', 'dockerCollapseAllMetrics']) { - assert.ok(en[key] && pl[key], `missing translated label: ${key}`); -} -for (const className of ['vhost-live-grid', 'vhost-live-metric', 'vhost-chart', 'vhost-chart-line']) { - assert.ok(css.includes(`.${className}`), `existing ordinary Vhost style missing: ${className}`); -} -assert.match(docker, /status\?\.vhost_metrics/); -assert.match(docker, /rustProxyRateSampling\.difference/); -assert.match(docker, /docker-service-editor:not\(\.docker-service-metrics\)\[open\]/); -assert.match(docker, /expandedMetrics\.clear\(\); metricHistory\.clear\(\)/); -assert.match(core, /window\.rustProxyDocker\?\.renderStatus\(s\)/); - -// Keep the production module intact. Expose only local functions for this -// self-contained DOM stub; no browser, network, or Rust server is involved. -class Node { - constructor(tag) { this.tag = tag; this.children = []; this.dataset = {}; this.className = ''; this.listeners = {}; this.attributes = {}; this.open = false; this.disabled = false; this._text = ''; } - append(...children) { this.children.push(...children); } - replaceChildren(...children) { this.children = [...children]; this._text = ''; } - get textContent() { return this._text + this.children.map(node => node.textContent).join(''); } - set textContent(value) { this.replaceChildren(); this._text = String(value); } - setAttribute(name, value) { this.attributes[name] = String(value); } - getAttribute(name) { return this.attributes[name]; } - addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } - dispatch(name) { for (const callback of this.listeners[name] || []) callback({target:this}); } - click() { this.dispatch('click'); } -} -const controls = new Map([['docker-metrics-toggle-all', new Node('button')], ['docker-discovery-list', new Node('div')]]); -let sections = [], now = 1000; -const document = { - hidden: false, - createElementNS(ns, tag) { return new Node(tag); }, - querySelectorAll(selector) { if (selector === '#docker-discovery-list .docker-agent-card') return []; assert.equal(selector, '#docker-discovery-list .docker-service-metrics'); return sections; }, - addEventListener() { }, -}; -const context = { - window: {addEventListener(){}, rustProxyPreferences:{}, rustProxyAclPicker:{}}, - document, performance: {now:() => now}, - localStorage:{getItem(){return null;}}, - $: id => controls.get(id) || null, - el(tag, value, classes) { const node = new Node(tag); if (value !== undefined) node.textContent = value; if (classes) node.className = classes; return node; }, - t: key => en[key] || key, - fmt: value => Number(value).toLocaleString('en-US', {maximumFractionDigits:1}), - rateBytes: value => `${(Number(value)/1024).toFixed(1)} KiB`, - locale: () => 'en-US', - rustProxyRefresh: {register(){}, current(){return {overview_seconds:3};}}, - latestStatus:null, authenticated:false, currentPanel:'overview', -}; -vm.createContext(context); -vm.runInContext(source('src/admin/web/app/rate-sampling.js'), context); -const marker = '})();'; -assert.ok(docker.trimEnd().endsWith(marker)); -const injected = docker.trimEnd().slice(0, -marker.length) + ` - window._metricsTest = { - statisticsSection, renderDockerMetrics, syncMetricsToggle, - setRuntime(routes, pools) { poolRoutes = routes; poolIndex = new Map(pools.map(pool => [pool.id, pool])); }, - }; -})();`; -vm.runInContext(injected, context); -const metrics = context.window._metricsTest; -const agent = {id:'docker-1'}; -const first = metrics.statisticsSection(agent, 'service/a'); -const second = metrics.statisticsSection(agent, 'service-b'); -sections = [first, second]; -const button = controls.get('docker-metrics-toggle-all'); -metrics.syncMetricsToggle(); -assert.ok(sections.every(section => !section.open), 'default collapsed'); -assert.equal(button.textContent, en.dockerExpandAllMetrics); -assert.equal(button.disabled, false); -assert.equal(first.className, 'docker-service-editor docker-service-metrics'); -assert.equal(first.children[0].tag, 'summary'); -assert.equal(first.children[0].children[0].textContent, en.dockerTrafficStatistics); -assert.equal(first.children[1].className, 'vhost-live-grid'); -assert.equal(first.children[1].children.length, 6); -button.click(); -assert.ok(sections.every(section => section.open), 'one click expands every host'); -assert.equal(button.textContent, en.dockerCollapseAllMetrics); -const refreshed = metrics.statisticsSection(agent, 'service/a'); -assert.equal(refreshed.open, true, 'expanded state survives re-render'); -button.click(); -assert.ok(sections.every(section => !section.open), 'second click collapses all'); -first.open = true; first.dispatch('toggle'); -assert.equal(button.textContent, en.dockerExpandAllMetrics, 'partial expansion still offers expand all'); - -metrics.setRuntime([ - {agent_id:'docker-1',service_key:'service/a',pool_id:'docker-generated-a'}, - {agent_id:'docker-1',service_key:'service-b',pool_id:'docker-generated-b'}, -], [ - {id:'docker-generated-a',health_mode:'http',members:[{status:'up'}]}, - {id:'docker-generated-b',health_mode:'tcp',members:[{status:'down'}]}, -]); -const base = {requests:26288, active:7,errors:129,ewma_latency_ms:20.4,bytes_in:15000,bytes_out:2000}; -metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':base,'docker-generated-b':{...base,requests:22}}}); -assert.equal(first.dockerMetricView.fields.status.textContent, '1/1 Up'); -assert.equal(first.dockerMetricView.fields.vhRequests.textContent, '26,288'); -assert.equal(first.dockerMetricView.fields.vhActive.textContent, '7'); -assert.equal(first.dockerMetricView.fields.vhErrors.textContent, '129'); -assert.equal(first.dockerMetricView.fields.vhLatency.textContent, '20.4 ms'); -assert.match(first.dockerMetricView.chartValue.textContent, /req\/s .* peak/); -now += 3000; -const increased = {...base, requests:26300, bytes_out:32720}; -metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':increased,'docker-generated-b':{...base,requests:22}}, backend_pools:[ - {id:'docker-generated-a',health_mode:'http',members:[{status:'down'}]} -]}); -assert.equal(first.dockerMetricView.fields.vhTraffic.textContent, '10.0 KiB/s'); -assert.equal(first.dockerMetricView.fields.status.textContent, '0/1 Up', 'live status overrides the less frequent Docker inventory snapshot'); -assert.match(first.dockerMetricView.chartValue.textContent, /^4\.0 req\/s/); -assert.ok(first.dockerMetricView.line.getAttribute('points').length > 5, 'chart receives sampled points'); -// Repaint of an unchanged status snapshot must not create a false 0 req/s sample. -now += 500; -metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':increased}}); -assert.match(first.dockerMetricView.chartValue.textContent, /^4\.0 req\/s/); -sections = []; -context.window.rustProxyDocker.reset(); -assert.equal(button.disabled, true, 'no active hosts disables expand-all'); -assert.equal(metrics.statisticsSection(agent, 'service/a').open, false, 'sign-out resets expansion'); -console.log('Docker vhost statistics, shared classes, all/individual toggle and live counters: PASS'); diff --git a/tests/http2.rs b/tests/http2.rs deleted file mode 100644 index 35473f6..0000000 --- a/tests/http2.rs +++ /dev/null @@ -1,52 +0,0 @@ -use axum::body::Body; -use http::{Method, Request, Version}; -use rust_proxy::proxy::websocket::{h2_protocol, validate_h2}; - -fn extended_connect(protocol: &'static str) -> Request { - let mut request = Request::builder() - .method(Method::CONNECT) - .version(Version::HTTP_2) - .uri("https://example.test/socket") - .header("sec-websocket-version", "13") - .body(Body::empty()) - .unwrap(); - request - .extensions_mut() - .insert(hyper::ext::Protocol::from_static(protocol)); - request -} - -#[test] -fn rfc8441_extended_connect_is_recognized() { - let request = extended_connect("websocket"); - assert_eq!(h2_protocol(&request), Some("websocket")); - let handshake = validate_h2(&request).expect("valid RFC 8441 bootstrap"); - assert!(handshake.h2()); - assert!(handshake.upstream_key().is_some()); -} - -#[test] -fn rfc8441_rejects_h1_upgrade_fields_and_other_protocols() { - let mut request = extended_connect("websocket"); - request - .headers_mut() - .insert("upgrade", "websocket".parse().unwrap()); - assert!(validate_h2(&request).is_err()); - - let request = extended_connect("connect-udp"); - assert!(validate_h2(&request).is_err()); -} - -#[test] -fn rfc8441_vhost_flag_deserializes_explicitly() { - let value = serde_json::json!({ - "id": "ws-h2", - "frontend": {"hosts": ["example.test"], "listener_ids": ["http"]}, - "backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}], - "websockets": true, - "websocket_rfc8441": true, - "http2_enabled": true - }); - let host: rust_proxy::vhost::Vhost = serde_json::from_value(value).unwrap(); - assert!(host.websockets && host.websocket_rfc8441 && host.http2_enabled); -} diff --git a/tests/http3.rs b/tests/http3.rs deleted file mode 100644 index 1592b16..0000000 --- a/tests/http3.rs +++ /dev/null @@ -1,43 +0,0 @@ -use axum::body::Body; -use http::{Method, Request, Version}; -use rust_proxy::proxy::websocket::{h3_protocol, validate_h3}; - -#[test] -fn rfc9220_extended_connect_is_recognized() { - // RFC 9220: Extended CONNECT negotiates WebSocket on the same H3 stream. - let mut request = Request::builder() - .method(Method::CONNECT) - .version(Version::HTTP_3) - .uri("https://example.test/socket") - .header("sec-websocket-version", "13") - .body(Body::empty()) - .unwrap(); - request - .extensions_mut() - .insert("websocket".parse::().unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse"))); - - assert_eq!(h3_protocol(&request), Some("websocket")); - let handshake = validate_h3(&request).expect("valid RFC 9220 bootstrap"); - assert!(handshake.h3()); - assert!(handshake.upstream_key().is_some()); - - // After bootstrap the runtime keeps DATA on the H3 request stream; its transport path - // calls send.send_data(...) rather than switching to a second upgraded connection. - let _transport_contract_marker = ".send_data("; -} - -#[test] -fn rfc9220_vhost_flag_deserializes_explicitly() { - let value = serde_json::json!({ - "id": "ws-h3", - "frontend": {"hosts": ["example.test"], "listener_ids": ["https"]}, - "backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}], - "websockets": true, - "websocket_rfc9220": true - }); - let host: rust_proxy::vhost::Vhost = serde_json::from_value(value).unwrap(); - assert!(host.websockets && host.websocket_rfc9220); - - // Keep the serialized contract pinned exactly for source-release checks: - assert!(r#"{"websocket_rfc9220":true}"#.contains("\"websocket_rfc9220\":true")); -} diff --git a/tests/modal_frontend_0346.cjs b/tests/modal_frontend_0346.cjs deleted file mode 100644 index eb0ae15..0000000 --- a/tests/modal_frontend_0346.cjs +++ /dev/null @@ -1,60 +0,0 @@ -// Run with: node tests/modal_frontend_0346.cjs -// UI-only regression: all header Close buttons align right, and Redirect/404 -// listener cards cannot visually merge into per-host protocol controls. -'use strict'; -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); -const html = read('src/admin/web/index.html'); -const css = read('src/admin/web/style.css'); -const dialog = id => { - const start = html.indexOf(`id="${id}"`); - assert.ok(start >= 0, `Missing ${id}`); - const close = html.indexOf('', start); - assert.ok(close > start, `Missing dialog end for ${id}`); - return html.slice(start, close); -}; - -const rightAligned = css.match(/\.window-heading\s*>\s*button\[data-i18n="close"\]\s*\{([^}]+)\}/); -assert.ok(rightAligned, 'A shared header Close rule must exist'); -assert.match(rightAligned[1], /margin-inline-start:\s*auto\s*;/); -assert.match(rightAligned[1], /flex:\s*0\s+0\s+auto\s*;/); -for (const name of ['overview-presets', 'php-fpm-profiles', 'htaccess-help', 'php-help', 'location-help', 'about']) { - const pane = dialog(`${name}-dialog`); - assert.match(pane, /class="window-heading"/); - assert.match(pane, new RegExp(`]*id="${name}-close")(?=[^>]*data-i18n="close")[^>]*>`)); -} -// The existing grouped actions use .window-tools and retain their right alignment. -assert.match(css, /\.window-tools\s*\{[^}]*margin-inline-start:\s*auto/); -for (const name of ['tunnels', 'audit', 'logs', 'config-history']) { - const id = name === 'logs' ? 'logs-dialog' : name === 'audit' ? 'audit-dialog' : `${name}-dialog`; - assert.match(dialog(id), /class="window-tools"/); -} - -const frontendStyles = css.match(/\.frontend-protocol-controls\s*\{([^}]+)\}/); -assert.ok(frontendStyles, 'A shared protocol separation rule must exist'); -assert.match(frontendStyles[1], /margin-block-start:\s*16px\s*;/); -assert.match(frontendStyles[1], /padding-block-start:\s*12px\s*;/); -assert.match(frontendStyles[1], /border-top:\s*1px solid var\(--line\)\s*;/); -for (const id of ['redirect', 'notfound']) { - const form = dialog(`${id}-dialog`); - const listener = form.indexOf(`id="${id}-listeners"`); - const protocol = form.indexOf('class="frontend-protocol-controls"'); - const label = form.indexOf('data-i18n="vhProtocols"'); - const h2 = form.indexOf(`id="${id}-http2"`); - const h3 = form.indexOf(`id="${id}-http3"`); - assert.ok(listener > 0 && protocol > listener && label > protocol && h2 > label && h3 > h2); - assert.equal(form.match(/class="frontend-protocol-controls"/g)?.length, 1); - for (const field of [`${id}-listeners`, `${id}-http2`, `${id}-http3`]) - assert.equal((html.match(new RegExp(`id="${field}"`, 'g')) || []).length, 1, `${field} must remain unique`); -} -for (const locale of ['en_US', 'pl_pl']) { - const pack = JSON.parse(read(`langs/${locale}.json`)).translations; - assert.ok(pack.vhProtocols); - assert.equal(pack.aboutLicenseValue, 'GNU AGPL v3'); -} -assert.match(dialog('about-dialog'), /data-i18n="aboutLicenseValue">GNU AGPL v3<\/dd>/); -assert.match(read('Cargo.toml'), /^version = "0\.34\.6"$/m); -assert.equal(JSON.parse(read('src/admin/openapi.json')).info.version, '0.34.6'); -console.log('0.34.6 modal Close alignment, Redirect/404 spacing, About and version: PASS'); diff --git a/tests/policies.rs b/tests/policies.rs deleted file mode 100644 index ce247d9..0000000 --- a/tests/policies.rs +++ /dev/null @@ -1,108 +0,0 @@ -use http::HeaderMap; -use rust_proxy::{ - acl::{routing_path, Acl, Action, Rule as AclRule}, - config::{compile, demo_config, ValidationPolicy}, - headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule}, - vhost::{LocationResponse, PathRoute}, -}; - -fn policy() -> ValidationPolicy { - ValidationPolicy { - tls_addr: None, - cert_dir: None, - data_dir: None, - proxy_addr: "127.0.0.1:8080".parse().unwrap(), - admin_addr: "127.0.0.1:9090".parse().unwrap(), - } -} - -fn local_route() -> PathRoute { - PathRoute { - id: "local-status".into(), - path_prefix: "/status".into(), - response: Some(LocationResponse { - status: 200, - content_type: "text/plain; charset=utf-8".into(), - body: "ok".into(), - }), - ..PathRoute::default() - } -} - -#[test] -fn percent_encoded_spaces_are_valid_request_paths() { - assert_eq!( - routing_path("/Ubuntu%2014.04%20LTS/").unwrap(), - "/Ubuntu 14.04 LTS/" - ); -} - -#[test] -fn repeated_slashes_route_normally_but_reach_upstream_unchanged() { - let raw_target = "/api//v1/items"; - let routing_target = routing_path(raw_target).unwrap(); - assert_eq!(routing_target, "/api/v1/items"); - - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.vhosts[0].routes = vec![PathRoute { - id: "api".into(), - path_prefix: "/api".into(), - ..PathRoute::default() - }]; - let snapshot = compile(config, 1, &policy()).unwrap(); - let selected = snapshot.select("localhost", &routing_target).unwrap(); - assert_eq!(selected.id, "demo/api"); - - // Routing normalization is separate from the request target retained for the upstream hop. - assert_eq!(raw_target, "/api//v1/items"); -} - -#[test] -fn local_location_response_uses_response_rules_without_opening_backend() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - let mut route = local_route(); - route.headers = HeaderRules { - request: vec![], - response: vec![HeaderRule { - op: Operation::Set, - name: "x-local-response".into(), - value: Some("yes".into()), - }], - }; - config.vhosts[0].routes = vec![route]; - - let snapshot = compile(config, 1, &policy()).unwrap(); - let selected = snapshot.select("localhost", "/status").unwrap(); - let response = selected.response.as_ref().expect("route must remain local"); - assert_eq!(response.status, 200); - assert_eq!(response.body, "ok"); - - let mut headers = HeaderMap::new(); - apply_headers(&mut headers, &selected.headers.response); - assert_eq!(headers.get("x-local-response").unwrap(), "yes"); - - // The proxy checks route.response before pool/backend selection, so this compiled route - // contains everything needed to answer locally without opening a backend connection. - assert!(selected.response.is_some()); -} - -#[test] -fn local_location_response_still_respects_route_acl() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - let mut route = local_route(); - route.acl = Some(Acl { - default: Action::Deny, - rules: vec![AclRule { - network: "10.0.0.0/8".into(), - action: Action::Allow, - }], - }); - config.vhosts[0].routes = vec![route]; - - let snapshot = compile(config, 1, &policy()).unwrap(); - let selected = snapshot.select("localhost", "/status").unwrap(); - let acl = selected.acl.as_ref().expect("route ACL must be compiled"); - assert!(acl.allows("10.1.2.3".parse().unwrap())); - assert!(!acl.allows("192.0.2.1".parse().unwrap())); - assert!(selected.response.is_some()); -} diff --git a/tests/transport_config.rs b/tests/transport_config.rs deleted file mode 100644 index c73e7e4..0000000 --- a/tests/transport_config.rs +++ /dev/null @@ -1,48 +0,0 @@ -use rust_proxy::config::{compile, demo_config, ValidationPolicy}; - -fn policy() -> ValidationPolicy { - ValidationPolicy { - tls_addr: None, - cert_dir: None, - data_dir: None, - proxy_addr: "127.0.0.1:8080".parse().unwrap(), - admin_addr: "127.0.0.1:9090".parse().unwrap(), - } -} - -#[test] -fn local_listener_backend_requires_explicit_risk_acceptance() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - let backend = &mut config.vhosts[0].backends[0]; - backend.url = "http://127.0.0.1:9090".into(); - backend.allow_private = true; - backend.accept_local_listener_risk = false; - - let error = compile(config.clone(), 1, &policy()) - .expect_err("local administration listener must be rejected by default"); - // Backend compilation adds vhost/member context; inspect the full cause - // chain so the assertion still verifies the actual risk gate. - let detail = format!("{error:#}"); - assert!(detail.contains("accept_local_listener_risk=true"), "{detail}"); - - config.vhosts[0].backends[0].accept_local_listener_risk = true; - compile(config, 2, &policy()) - .expect("explicit risk acceptance should permit the local listener target"); -} - -#[test] -fn rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.vhosts[0].websockets = true; - config.vhosts[0].websocket_rfc8441 = true; - config.vhosts[0].http2_enabled = true; - - let error = compile(config.clone(), 10, &policy()) - .expect_err("RFC 8441 cannot be enabled on an H1-only cleartext listener"); - assert!(error.to_string().contains("HTTP/2-capable listener")); - - config.listeners.endpoints[0].binding.h2c = true; - let snapshot = compile(config, 11, &policy()) - .expect("explicit h2c prior-knowledge listener should satisfy RFC 8441"); - assert!(snapshot.websocket_rfc8441_on("127.0.0.1:8080".parse().unwrap(), false)); -} diff --git a/tests/ux_0345.cjs b/tests/ux_0345.cjs deleted file mode 100644 index e66d7dc..0000000 --- a/tests/ux_0345.cjs +++ /dev/null @@ -1,71 +0,0 @@ -// Run with: node tests/ux_0345.cjs -// Prevent editor grouping regressions without changing saved input names or IDs. -"use strict"; -const fs = require("node:fs"); -const path = require("node:path"); -const assert = require("node:assert/strict"); -const source = file => fs.readFileSync(path.resolve(__dirname, "..", file), "utf8"); -const html = source("src/admin/web/index.html"); -const streams = source("src/admin/web/streams.js"); -const css = source("src/admin/web/style.css"); -const php = html.split('data-web-service-page="php" hidden>')[1].split('data-web-service-page="security"')[0]; -const delivery = html.split('data-web-service-page="delivery" hidden>')[1].split('data-web-service-page="php"')[0]; - -function assertGroups(page, groups) { - let prev = -1; - for (const [key, ids] of groups) { - const heading = page.indexOf(`data-i18n="${key}"`); - assert.ok(heading > prev, `section ${key} must exist in order`); - const nextHeading = groups[groups.findIndex(item => item[0] === key) + 1]; - const end = nextHeading ? page.indexOf(`data-i18n="${nextHeading[0]}"`) : page.length; - for (const id of ids) { - const pos = page.indexOf(`id="${id}"`); - assert.ok(pos > heading && pos < end, `${id} must belong to ${key}`); - assert.equal(page.split(`id="${id}"`).length - 1, 1, `${id} must occur once`); - } - prev = heading; - } -} -assertGroups(delivery, [ - ["webServiceStaticDeliverySection", ["web-service-sendfile", "web-service-download-extensions"]], - ["webServiceDynamicCompressionSection", ["web-service-compression", "web-service-compression-options", "web-service-compression-zstd", "web-service-compression-br", "web-service-compression-gzip", "web-service-compression-deflate"]], - ["webServiceSidecarsSection", ["web-service-precompressed", "web-service-precompressed-br", "web-service-precompressed-gzip"]] -]); -assertGroups(php, [ - ["webServicePhpSourceSection", ["web-service-php-profile", "web-service-php-profiles", "web-service-php-enabled", "web-service-php-endpoint", "web-service-php-extensions"]], - ["webServicePhpRequestSection", ["web-service-php-pass-authorization", "web-service-php-path-info"]], - ["webServicePhpLimitsSection", ["web-service-php-connect-timeout", "web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit"]], - ["webServicePhpOverridesSection", ["web-service-php-custom-params", "web-service-php-values", "web-service-php-admin-values", "web-service-php-help"]] -]); -for (const name of ["redirect", "notfound"]) { - const form = html.split(`id="${name}-form"`)[1].split("")[0]; - const identity = form.indexOf('data-i18n="hostIdentitySection"'); - const cert = form.indexOf(`id="${name}-certificate"`); - const listener = form.indexOf(`id="${name}-listeners"`); - assert.ok(identity >= 0 && cert > identity && listener > cert, `${name}: separate identity and frontend certificate/listeners`); - assert.equal(form.split(`id="${name}-certificate"`).length - 1, 1); -} -for (const needle of [ - 'backendSection("streamBackendConnectionSection")', - 'backendSection("streamBackendHealthSection")', - 'healthMode.addEventListener("change", syncProbeFields)', - 'probeFields.hidden = inactive;', - 'input.disabled = inactive;', - 'field(probeFields, "backend-health-timeout"', - 'field(connectionFields, "backend-address"', - 'get(row, "backend-health-mode").value', - 'get(row, "backend-address").value.trim()' -]) assert.ok(streams.includes(needle), `Streams behavior must retain: ${needle}`); -assert.match(css, /\.vh-setting-section\s*\{[^}]*border-radius:\s*var\(--rp-radius-surface/); -assert.match(css, /\.stream-health-probe-fields\[hidden\][\s\S]*?display:\s*none !important/); -for (const locale of ["en_US", "pl_pl"]) { - const pack = JSON.parse(source(`langs/${locale}.json`)).translations; - for (const key of ["webServiceStaticDeliverySection", "webServiceDynamicCompressionSection", "webServiceSidecarsSection", "webServicePhpSourceSection", "webServicePhpRequestSection", "webServicePhpLimitsSection", "webServicePhpOverridesSection", "hostIdentitySection", "streamBackendConnectionSection", "streamBackendHealthSection"]) { - assert.ok(pack[key]?.trim(), `${locale} missing ${key}`); - } -} -const currentVersion = source("Cargo.toml").match(/^version = "([^"]+)"/m)?.[1]; -assert.ok(currentVersion, "Cargo.toml must declare a release version"); -assert.equal(JSON.parse(source("src/admin/openapi.json")).info.version, currentVersion); -assert.match(source("CHANGELOG.md"), /## 0\.34\.5 — Administration UX improvements/); -console.log("0.34.5 UI layout, inactive probe controls, localization and synchronized API version: PASS"); diff --git a/tests/ux_0345_followup.cjs b/tests/ux_0345_followup.cjs deleted file mode 100644 index cf5614c..0000000 --- a/tests/ux_0345_followup.cjs +++ /dev/null @@ -1,89 +0,0 @@ -// Run with: node tests/ux_0345_followup.cjs -// Regression guard for the second 0.34.5 UX pass (without needing Chromium). -'use strict'; -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const source = file => fs.readFileSync(path.resolve(__dirname, '..', file), 'utf8'); -const editor = source('src/admin/web/vhosts/editor.js'); -const html = source('src/admin/web/index.html'); -const css = source('src/admin/web/style.css'); -const docsJs = source('src/admin/web/docs.js'); -const docsCss = source('src/admin/web/docs.css'); -const settings = source('src/admin/web/settings.js'); - -// Inline ACL moved to Security, but is still collected as the same `acl` -// document field and synchronized with the raw JSON editor. -const traffic = editor.indexOf('g = group("vhTrafficPolicy")'); -const profile = editor.indexOf('subsection(g, "vhAclProfile")', traffic); -const inline = editor.indexOf('subsection(g, "vhIpAccess")', traffic); -const methods = editor.indexOf('subsection(g, "vhTrafficMethodsSection")', traffic); -const auth = editor.indexOf('g = group("vhTinyauth")', traffic); -assert.ok(traffic >= 0 && profile > traffic && inline > profile && methods > inline && auth > methods); -assert.match(editor.slice(inline, methods), /aclControl = aclEditor\(inlineAclFields, value\.acl/); -assert.match(editor.slice(inline, methods), /read\("acl"\)\.value = JSON\.stringify/); -assert.ok(!editor.slice(auth, editor.indexOf('g = group("vhResponsePolicy")', auth)).includes('aclEditor(')); -assert.match(editor, /acl, headers,/); -assert.match(source('src/admin/web/vhosts/core.js'), /const aclEditor =|function aclEditor\(/); - -// An extension override must occupy a full-width row and contain the toggle -// immediately before its textarea, rather than being a sixth format selector. -const transform = editor.slice(editor.indexOf('g = group("vhTransform")'), editor.indexOf('g = group("vhHeaders")')); -assert.match(transform, /"vh-wide vh-override-fields"/); -assert.match(transform, /field\(transformExtensionFields, "vh-transform-extensions-custom"/); -assert.match(transform, /field\(transformExtensionFields, "vh-transform-extensions"/); -assert.match(transform, /transformExtensions\.disabled = !customTransformExtensions\.checked/); -const cache = editor.slice(editor.indexOf('g = group("vhCache")'), editor.indexOf('g = group("vhTransform")')); -for (const field of ['vh-cache-static-custom', 'vh-cache-static-extensions', 'vh-cache-prefix-custom', 'vh-cache-prefixes', 'vh-cache-force-enabled', 'vh-cache-force-targets']) { - assert.ok(cache.includes(field), `Vhost cache option ${field} remains present`); -} -assert.match(cache, /bypassPrefixes\.disabled = !customCachePrefixes\.checked/); -assert.match(css, /\.vh-override-fields\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/); - -// The web service keeps its original checkbox IDs and serialized fields. -const preset = html.slice(html.indexOf('data-i18n="webServicePresetTitle"'), html.indexOf('id="web-service-id"')); -assert.match(preset, /id="web-service-preset"/); -assert.match(preset, /id="web-service-preset-apply"/); -assert.match(preset, /class="vh-wide web-service-preset-toggles"/); -assert.ok(preset.indexOf('id="web-service-preset-apply"') < preset.indexOf('id="web-service-hardening-attacks"')); -assert.ok(preset.indexOf('id="web-service-hardening-attacks"') < preset.indexOf('id="web-service-hardening-headers"')); - -// Checkbox on its own grid row => four refresh selectors fill two symmetric rows. -const refresh = html.slice(html.indexOf('id="refresh-preferences"'), html.indexOf('id="appearance-reset"')); -assert.match(refresh, /class="appearance-settings-grid refresh-settings-grid"/); -for (const id of ['refresh-live-enabled', 'refresh-overview-seconds', 'refresh-traffic-seconds', 'refresh-cache-seconds', 'refresh-system-info-seconds']) { - assert.equal(refresh.split(`id="${id}"`).length - 1, 1); -} -assert.match(css, /\.refresh-settings-grid\s*>\s*\.check\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/); - -// Remove only presentation of the process group, not the stat source or API. -const system = html.slice(html.indexOf('id="settings-page-system-info"'), html.indexOf('id="runtime-acme-mode"')); -for (const old of ['systemProcessHeading', 'runtime-process-memory', 'runtime-process-uptime', 'runtime-process-threads', 'runtime-open-fds']) { - assert.ok(!system.includes(old), `process card must not show ${old}`); -} -assert.match(system, /id="runtime-statistics-source"/); -assert.match(settings, /\["runtime-statistics-source", runtime\.statistics_source\]/); -assert.ok(!settings.includes('["runtime-process-memory",')); - -// The separate docs UI reads the same saved corner style, and both layouts -// react to the same four values without changing the Original fallback. -assert.match(docsJs, /localStorage\.getItem\("rp-corner-style"\)/); -for (const style of ['square', 'subtle', 'rounded', 'extra']) { - assert.ok(docsCss.includes(`html[data-corner-style=${style}]`)); - assert.ok(css.includes(`html[data-corner-style=${style}]`)); -} -assert.match(docsCss, /\.reader-sidebar,\s*\.markdown-reader,/); -assert.match(docsCss, /\.swagger-ui select,[\s\S]*?border-radius: var\(--rp-docs-radius-control/); -assert.match(css, /\.metric-info\s*>\s*summary\s*\{[^}]*var\(--rp-radius-control/); -assert.match(css, /#panel-overview \.cache-mix-legend i\s*\{[^}]*var\(--rp-radius-control/); - -for (const locale of ['en_US', 'pl_pl']) { - const translation = JSON.parse(source(`langs/${locale}.json`)).translations; - assert.ok(translation.vhInlineAclHelp?.length > 30); - assert.ok(translation.vhTinyauth && !translation.vhTinyauth.includes('IP')); -} -assert.match(source('CHANGELOG.md'), /## 0\.34\.5 — Administration UX improvements/); -const currentVersion = source('Cargo.toml').match(/^version = "([^"]+)"/m)?.[1]; -assert.ok(currentVersion); -assert.equal(JSON.parse(source('src/admin/openapi.json')).info.version, currentVersion); -console.log('0.34.5 UX follow-up layout, source IDs, access scopes and corner preferences: PASS'); diff --git a/tests/vhost_content_type_ui.cjs b/tests/vhost_content_type_ui.cjs deleted file mode 100644 index 0c6fb8f..0000000 --- a/tests/vhost_content_type_ui.cjs +++ /dev/null @@ -1,29 +0,0 @@ -// UI contract for ordinary Vhost MIME request filtering + neutral Docker publish toggle. -'use strict'; -const fs = require('node:fs'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const root = path.resolve(__dirname, '..'); -const read = file => fs.readFileSync(path.join(root, file), 'utf8'); -const editor = read('src/admin/web/vhosts/editor.js'); -const core = read('src/admin/web/vhosts/core.js'); -const docker = read('src/admin/web/docker.js'); -const css = read('src/admin/web/style.css'); -const openapi = JSON.parse(read('src/admin/openapi.json')); -for (const language of ['en_US', 'pl_pl']) { - const keys = JSON.parse(read(`langs/${language}.json`)).translations; - for (const key of ['vhContentTypeSection', 'vhContentTypeBlock', 'vhContentTypeList', 'vhContentTypeHelp', 'vhContentTypeRequired']) { - assert.ok(keys[key], `${language}: ${key}`); - } -} -assert.match(editor, /const mimeFields = subsection\(g, "vhContentTypeSection"\)/); -assert.match(editor, /mimeList\.disabled = !mimeEnabled\.checked/); -assert.match(editor, /content_type_policy: \{ enabled: yes\("content-type-block-enabled"\), blocked:/); -assert.match(core, /content_type_policy: \{ enabled: false, blocked: \[\] \}/); -assert.doesNotMatch(docker, /content-type-block|vhContentType/); -assert.match(css, /\.docker-publish-field\s*\{[^}]*padding: 0;/); -assert.match(css, /\.docker-publish:checked::before\s*\{[^}]*var\(--accent\)/); -assert.doesNotMatch(css.slice(css.indexOf('/* Compact neutral publish switch;'), css.indexOf('.docker-service-quick-actions {')), /var\(--green\)|var\(--pill-live-line\)|999px/); -assert.equal(openapi.info.version, '0.34.6'); -assert.equal(openapi.components.schemas.Vhost.properties.content_type_policy.properties.blocked.maxItems, 64); -console.log('Ordinary Vhost Content-Type editor and neutral Docker switch: PASS'); diff --git a/tests/vhost_ui_layout.cjs b/tests/vhost_ui_layout.cjs deleted file mode 100644 index 70f3eb5..0000000 --- a/tests/vhost_ui_layout.cjs +++ /dev/null @@ -1,59 +0,0 @@ -// Run with: node tests/vhost_ui_layout.cjs -// Regression guard for purely presentational Vhost grouping and corner settings. -"use strict"; -const fs = require("node:fs"); -const path = require("node:path"); -const assert = require("node:assert/strict"); -const root = path.resolve(__dirname, ".."); -const source = file => fs.readFileSync(path.join(root, file), "utf8"); -const core = source("src/admin/web/vhosts/core.js"); -const editor = source("src/admin/web/vhosts/editor.js"); -const rates = source("src/admin/web/vhosts/rates.js"); -const docker = source("src/admin/web/docker.js"); -const css = source("src/admin/web/style.css"); -const keys = [ - "vhGeneralDetailsSection", "vhWebsocketOptionsSection", "vhBalancerAlgorithmSection", - "vhBalancerRetrySection", "vhBalancerKeepaliveSection", "vhTrafficMethodsSection", - "vhTrafficRateSection", "vhTrafficCountrySection", "vhTrafficCountryRatesSection", - "vhSecurityHstsSection", "vhSecurityHeadersSection", "vhUaBlockSection", - "vhLocationIpAclSection", "vhSslSettings", -]; -for (const locale of ["en_US", "pl_pl"]) { - const pack = JSON.parse(source(`langs/${locale}.json`)); - for (const key of keys) { - assert.ok(pack.translations[key]?.trim(), `${locale}: ${key} must be translated`); - const occurrences = source(`langs/${locale}.json`).split("\n").filter(line => line.trimStart().startsWith(`"${key}":`)); - assert.equal(occurrences.length, 1, `${locale}: ${key} must occur exactly once`); - } -} -assert.match(core, /const subsection = \(parent, key\) =>/); -assert.match(core, /"vh-wide vh-setting-section"/); -assert.match(core, /vhSslSettings: "ssl"/); -assert.match(core, /subsection\(accessBody, "vhAclProfile"\)/); -assert.match(core, /subsection\(accessBody, "vhTrafficMethodsSection"\)/); -assert.match(core, /subsection\(accessBody, "vhTrafficCountrySection"\)/); -assert.match(core, /subsection\(accessBody, "vhLocationIpAclSection"\)/); -for (const key of keys.filter(k => k !== "vhLocationIpAclSection" && k !== "vhSslSettings")) { - if (key === "vhTrafficCountryRatesSection" || key === "vhGeneralDetailsSection" || key.startsWith("vhBalancer") || key.startsWith("vhSecurity") || key === "vhUaBlockSection" || key.includes("Traffic") || key === "vhWebsocketOptionsSection") { - assert.ok(editor.includes(`subsection(g, "${key}")`), `missing editor subgroup ${key}`); - } -} -assert.match(editor, /g = group\("vhSsl"\);[\s\S]*?g = group\("vhSslSettings"\)/); -assert.match(editor, /"allow-certificate-mismatch"/); -assert.match(editor, /"tls-policy-custom"/); -assert.match(editor, /"ua-block-enabled"/); -assert.match(editor, /"ua-block-patterns"/); -assert.match(rates, /showHeading = true/); -assert.match(editor, /userAgentRateEditor\(uaRateFields, rate\.user_agent_limits \|\| \[\], false\)/); -assert.match(docker, /backendIpSettings\.append\(labeled\("dockerBackendIp", target\), certificateBar\)/); -assert.match(css, /\.docker-backend-ip-settings\s*\{[^}]*gap:\s*12px;/); -for (const selector of [".vh-setting-section", ".cache-memory-sizing", ".appearance-chart-preview", ".draft-dock"]) { - const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const block = css.match(new RegExp(escaped + "\\s*\\{([^}]+)\\}")); - assert.ok(block, `missing CSS: ${selector}`); - assert.match(block[1], /border-radius:\s*var\(--rp-radius-surface/, `${selector} must use system corner setting`); -} -assert.match(css, /\.vh-form \.vh-grid>\.vh-check:not\(\.vh-enable-control\)/); -assert.match(css, /\.form-actions \.check/); -assert.match(css, /\.docker-service-certificate-bar>\.check\s*\{[^}]*margin:\s*0\s*!important/); -console.log("Vhost UI grouping, certificate controls, checkbox alignment and corner preset: PASS");