From 5c2e50ec0624afd6206dba9978d16a4d21eff0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 26 Oct 2026 14:14:41 +0100 Subject: [PATCH] 0.34.13_cleaup_code --- SHA256SUMS | 8 +- grafana/rustproxy-overview.json | 1600 +++++++++++++++++--- src/acl.rs | 50 +- src/admin/api_token_access.rs | 9 +- src/admin/config_sections.rs | 170 ++- src/admin/file_logs.rs | 63 +- src/admin/openapi.json | 2 +- src/admin/router.rs | 5 +- src/admin/tests.rs | 247 ++- src/admin/web/css/10-base.css | 22 +- src/admin/web/css/20-shell.css | 2 +- src/backup/archive.rs | 21 +- src/backup/tests.rs | 9 +- src/backup/validation.rs | 4 +- src/cache/cold/startup.rs | 113 +- src/cache/dashboard_tests.rs | 62 +- src/cache/model/entry.rs | 39 +- src/cache/model/maintenance.rs | 2 +- src/cache/model/settings.rs | 2 - src/cache/model/views.rs | 3 +- src/cache/policy/request.rs | 56 +- src/cache/policy/response.rs | 35 +- src/cache/policy/scope_tests.rs | 53 +- src/cache/tickets.rs | 4 +- src/cache/varnish_compatible_tests.rs | 636 ++++++-- src/compression/tests.rs | 76 +- src/config/model.rs | 9 +- src/config/tests.rs | 84 +- src/db.rs | 4 +- src/diagnostics.rs | 3 +- src/forwarding.rs | 97 +- src/geoip.rs | 31 +- src/geoip/mmdb.rs | 214 ++- src/geoip/mmdb_tests.rs | 15 +- src/geoip/tests.rs | 35 +- src/geoip/update.rs | 228 ++- src/geoip/update_http.rs | 23 +- src/geoip/update_tests.rs | 27 +- src/headers/tests.rs | 5 +- src/lib.rs | 8 +- src/prometheus/vhosts_backends.rs | 12 +- src/proxy/body/tests.rs | 19 +- src/proxy/cache_helpers.rs | 5 +- src/proxy/cache_http_invalidation.rs | 132 +- src/proxy/cache_http_invalidation_tests.rs | 79 +- src/proxy/cache_plan/tests.rs | 106 +- src/proxy/error_fallback.rs | 22 +- src/proxy/forward/cache_fast_path_tests.rs | 31 +- src/proxy/forward_entry.rs | 74 +- src/proxy/headers/tests.rs | 25 +- src/proxy/identity.rs | 40 +- src/proxy/redirect_helpers.rs | 15 +- src/proxy/request_id/tests.rs | 4 +- src/proxy/web_cache_revalidation/mod.rs | 15 +- src/proxy/web_cache_revalidation/tests.rs | 4 +- src/rate_limit/tests.rs | 75 +- src/runtime/tests.rs | 109 +- src/server/http.rs | 11 +- src/server/http1_connection.rs | 1 - src/server/listeners.rs | 6 +- src/server/sendfile.rs | 265 +++- src/settings.rs | 20 +- src/settings/public_origin.rs | 31 +- src/settings/tests.rs | 71 +- src/tls/certificates.rs | 4 +- src/tls/server_config.rs | 5 +- src/traffic/tests.rs | 72 +- src/transform/tests.rs | 10 +- src/upstream/client_tests.rs | 15 +- src/upstream/protocol_tests.rs | 64 +- src/user_agent/tests.rs | 90 +- src/vhost/error_fallback.rs | 6 +- src/vhost/redirect.rs | 2 - src/vhost/validation.rs | 54 +- src/web_server/config/model.rs | 4 +- src/web_server/fastcgi/response.rs | 70 +- src/web_server/filesystem.rs | 40 +- src/web_server/htaccess/model.rs | 158 +- src/web_server/htaccess/rewrite.rs | 112 +- src/web_server/metrics.rs | 8 +- src/web_server/request.rs | 1 - src/web_server/response/file.rs | 22 +- src/web_server/response/file_body/mod.rs | 29 +- src/web_server/response/file_body/tests.rs | 139 +- src/web_server/routing.rs | 21 +- src/web_server/security.rs | 13 +- src/web_server/tests.rs | 41 +- 87 files changed, 4890 insertions(+), 1338 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index 9d4114e..581a494 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -2,12 +2,12 @@ 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 754db9d80640bdb05116d14cf91e1b69bfbbcfee2c522659ace4a4c7d48bb10d AGENTS.md -191e5abff142bf0353d4e5c29994d4516799f550ec25dc2173be3003a0028a58 CHANGELOG.md +df0f23d242cc45064e2e822bbd1fc8297acffa89f71eed2c0d4314695107d5a1 CHANGELOG.md b548ffccc83f59c886fd111bbfb63b40b34c35889f752e44e586c8e22131cfb8 Cargo.lock bb70eb65066a0aa23859a2ae006ba1bb75ff86b87afd4fa6160eae78a8d42af9 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -a698cd5979a56ea5f8ab8aa4ca8ccf5f6d72515f689fc833b73e16f37ca64a6c README.md -9edb7d4a4da46f0f836e504002f3676b88639208d28c1588e8637b085594c154 ROADMAP.md +3b0ef7ac49dfb35375780c53c48c03004fe240e68bb7337d3c8c5ad0a0e15998 README.md +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs 2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md @@ -115,7 +115,6 @@ f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/prefli 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py 174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh -7302f3446e2306a8618a1c026178e4c34f6215066ef7f7718bb29a42116bb354 scripts/test_admin_config_browser.py 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py @@ -527,7 +526,6 @@ f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json -0930ab8fae4eaaf76185bb1c6c9d4c2ee705a9e09a3ae74d81674ca4631a0169 tests/test_config_api_contract.py eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md 7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py diff --git a/grafana/rustproxy-overview.json b/grafana/rustproxy-overview.json index 514c957..7ae348a 100644 --- a/grafana/rustproxy-overview.json +++ b/grafana/rustproxy-overview.json @@ -314,6 +314,231 @@ } ] }, + { + "id": 112, + "type": "stat", + "title": "RustProxy build version", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 13 + }, + "fieldConfig": { + "defaults": { + "unit": "none", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "name", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "max by (version) (rust_proxy_build_info)", + "legendFormat": "{{version}}", + "range": true + } + ], + "description": "Value from the exporter build_info version label, one entry for each deployed version." + }, + { + "id": 113, + "type": "stat", + "title": "Youngest process uptime", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 13 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "min(rust_proxy_uptime_seconds)", + "legendFormat": "uptime", + "range": true + } + ], + "description": "Minimum process uptime across scraped RustProxy instances; identifies a recent restart." + }, + { + "id": 114, + "type": "gauge", + "title": "Request capacity in use", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 13 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": {}, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "100 * (sum(rust_proxy_request_capacity) - sum(rust_proxy_request_permits_available)) / clamp_min(sum(rust_proxy_request_capacity), 1)", + "legendFormat": "in use", + "range": true + } + ], + "description": "Percentage of configured admitted-exchange permits currently in use; not TCP connection capacity.", + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + } + }, + { + "id": 115, + "type": "stat", + "title": "Restart required", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 13 + }, + "fieldConfig": { + "defaults": { + "unit": "none", + "custom": {}, + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "No", + "color": "green" + }, + "1": { + "text": "Yes", + "color": "red" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "value", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "max(rust_proxy_restart_required)", + "legendFormat": "required", + "range": true + } + ], + "description": "1 if a running instance requires restart for listener or exporter changes." + }, { "id": 8, "type": "row", @@ -323,7 +548,7 @@ "h": 1, "w": 24, "x": 0, - "y": 13 + "y": 18 }, "panels": [] }, @@ -339,7 +564,7 @@ "h": 7, "w": 12, "x": 0, - "y": 14 + "y": 19 }, "fieldConfig": { "defaults": { @@ -388,7 +613,7 @@ "h": 7, "w": 12, "x": 12, - "y": 14 + "y": 19 }, "fieldConfig": { "defaults": { @@ -437,7 +662,7 @@ "h": 7, "w": 8, "x": 0, - "y": 21 + "y": 26 }, "fieldConfig": { "defaults": { @@ -486,7 +711,7 @@ "h": 7, "w": 8, "x": 8, - "y": 21 + "y": 26 }, "fieldConfig": { "defaults": { @@ -535,7 +760,7 @@ "h": 7, "w": 8, "x": 16, - "y": 21 + "y": 26 }, "fieldConfig": { "defaults": { @@ -584,7 +809,7 @@ "h": 7, "w": 12, "x": 0, - "y": 28 + "y": 33 }, "fieldConfig": { "defaults": { @@ -633,7 +858,7 @@ "h": 7, "w": 12, "x": 12, - "y": 28 + "y": 33 }, "fieldConfig": { "defaults": { @@ -682,7 +907,7 @@ "h": 7, "w": 8, "x": 0, - "y": 35 + "y": 40 }, "fieldConfig": { "defaults": { @@ -738,7 +963,7 @@ "h": 7, "w": 8, "x": 8, - "y": 35 + "y": 40 }, "fieldConfig": { "defaults": { @@ -788,7 +1013,7 @@ "h": 7, "w": 8, "x": 16, - "y": 35 + "y": 40 }, "fieldConfig": { "defaults": { @@ -838,6 +1063,114 @@ ], "description": "Backend retry attempts/successes and reusable upstream-client rotations per second." }, + { + "id": 116, + "type": "timeseries", + "title": "Backend queue and capacity rejections", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 47 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (vhost,backend) (rate(rust_proxy_backend_queue_timeouts_total[$__rate_interval]))", + "legendFormat": "{{vhost}} / {{backend}} queue timeout", + "range": true + }, + { + "refId": "B", + "expr": "sum by (vhost,backend) (rate(rust_proxy_backend_capacity_rejections_total[$__rate_interval]))", + "legendFormat": "{{vhost}} / {{backend}} capacity rejection", + "range": true + } + ], + "description": "Failed backend acquisitions per second. Gauges or status flags are not mixed with event rates." + }, + { + "id": 117, + "type": "timeseries", + "title": "Backend concurrency utilization", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 47 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + }, + "min": 0, + "max": 100 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "(100 * rust_proxy_backend_active_exchanges / clamp_min(rust_proxy_backend_max_concurrent_requests,1)) and on(vhost,backend) (rust_proxy_backend_max_concurrent_requests > 0)", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true + } + ], + "description": "In-use backend exchanges divided by a positive per-backend concurrency limit; unlimited backends are omitted." + }, { "id": 58, "type": "row", @@ -847,7 +1180,7 @@ "h": 1, "w": 24, "x": 0, - "y": 42 + "y": 54 }, "panels": [] }, @@ -863,7 +1196,7 @@ "h": 5, "w": 6, "x": 0, - "y": 43 + "y": 55 }, "fieldConfig": { "defaults": { @@ -907,7 +1240,7 @@ "h": 5, "w": 6, "x": 6, - "y": 43 + "y": 55 }, "fieldConfig": { "defaults": { @@ -951,7 +1284,7 @@ "h": 5, "w": 6, "x": 12, - "y": 43 + "y": 55 }, "fieldConfig": { "defaults": { @@ -995,7 +1328,7 @@ "h": 5, "w": 6, "x": 18, - "y": 43 + "y": 55 }, "fieldConfig": { "defaults": { @@ -1039,7 +1372,7 @@ "h": 7, "w": 8, "x": 0, - "y": 48 + "y": 60 }, "fieldConfig": { "defaults": { @@ -1088,7 +1421,7 @@ "h": 7, "w": 8, "x": 8, - "y": 48 + "y": 60 }, "fieldConfig": { "defaults": { @@ -1143,7 +1476,7 @@ "h": 7, "w": 8, "x": 16, - "y": 48 + "y": 60 }, "fieldConfig": { "defaults": { @@ -1192,7 +1525,7 @@ "h": 7, "w": 8, "x": 0, - "y": 55 + "y": 67 }, "fieldConfig": { "defaults": { @@ -1241,7 +1574,7 @@ "h": 7, "w": 8, "x": 8, - "y": 55 + "y": 67 }, "fieldConfig": { "defaults": { @@ -1290,7 +1623,7 @@ "h": 7, "w": 8, "x": 16, - "y": 55 + "y": 67 }, "fieldConfig": { "defaults": { @@ -1339,7 +1672,7 @@ "h": 7, "w": 8, "x": 0, - "y": 62 + "y": 74 }, "fieldConfig": { "defaults": { @@ -1388,7 +1721,7 @@ "h": 7, "w": 8, "x": 8, - "y": 62 + "y": 74 }, "fieldConfig": { "defaults": { @@ -1443,7 +1776,7 @@ "h": 7, "w": 8, "x": 16, - "y": 62 + "y": 74 }, "fieldConfig": { "defaults": { @@ -1492,7 +1825,7 @@ "h": 7, "w": 8, "x": 0, - "y": 69 + "y": 81 }, "fieldConfig": { "defaults": { @@ -1548,7 +1881,7 @@ "h": 7, "w": 8, "x": 8, - "y": 69 + "y": 81 }, "fieldConfig": { "defaults": { @@ -1598,7 +1931,7 @@ "h": 7, "w": 8, "x": 16, - "y": 69 + "y": 81 }, "fieldConfig": { "defaults": { @@ -1648,6 +1981,114 @@ ], "description": "Retry attempts/successes and reusable upstream-client rotations for Docker-discovered backends." }, + { + "id": 118, + "type": "timeseries", + "title": "Docker backend queue and capacity rejections", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 88 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (agent,service,backend) (rate(rust_proxy_docker_backend_queue_timeouts_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}} / {{backend}} queue timeout", + "range": true + }, + { + "refId": "B", + "expr": "sum by (agent,service,backend) (rate(rust_proxy_docker_backend_capacity_rejections_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}} / {{backend}} capacity rejection", + "range": true + } + ], + "description": "Docker backend acquisition failures per second; respects Docker agent and service filters." + }, + { + "id": 119, + "type": "timeseries", + "title": "Docker backend concurrency utilization", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 88 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + }, + "min": 0, + "max": 100 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "(100 * rust_proxy_docker_backend_active_exchanges{agent=~\"$docker_agent\",service=~\"$docker_service\"} / clamp_min(rust_proxy_docker_backend_max_concurrent_requests{agent=~\"$docker_agent\",service=~\"$docker_service\"},1)) and on(agent,service,vhost,backend) (rust_proxy_docker_backend_max_concurrent_requests{agent=~\"$docker_agent\",service=~\"$docker_service\"} > 0)", + "legendFormat": "{{agent}} / {{service}} / {{backend}}", + "range": true + } + ], + "description": "Percentage of configured positive Docker backend concurrency limit that is in use." + }, { "id": 16, "type": "row", @@ -1657,7 +2098,7 @@ "h": 1, "w": 24, "x": 0, - "y": 76 + "y": 95 }, "panels": [] }, @@ -1673,7 +2114,7 @@ "h": 5, "w": 6, "x": 0, - "y": 77 + "y": 96 }, "fieldConfig": { "defaults": { @@ -1717,7 +2158,7 @@ "h": 5, "w": 6, "x": 6, - "y": 77 + "y": 96 }, "fieldConfig": { "defaults": { @@ -1761,7 +2202,7 @@ "h": 5, "w": 6, "x": 12, - "y": 77 + "y": 96 }, "fieldConfig": { "defaults": { @@ -1805,7 +2246,7 @@ "h": 5, "w": 6, "x": 18, - "y": 77 + "y": 96 }, "fieldConfig": { "defaults": { @@ -1849,7 +2290,7 @@ "h": 7, "w": 12, "x": 0, - "y": 82 + "y": 101 }, "fieldConfig": { "defaults": { @@ -1916,7 +2357,7 @@ "h": 7, "w": 12, "x": 12, - "y": 82 + "y": 101 }, "fieldConfig": { "defaults": { @@ -1983,7 +2424,7 @@ "h": 7, "w": 12, "x": 0, - "y": 89 + "y": 108 }, "fieldConfig": { "defaults": { @@ -2056,7 +2497,7 @@ "h": 7, "w": 12, "x": 12, - "y": 89 + "y": 108 }, "fieldConfig": { "defaults": { @@ -2093,6 +2534,390 @@ } ] }, + { + "id": 120, + "type": "gauge", + "title": "Hot cache usage", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 115 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": {}, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "100 * sum(rust_proxy_cache_resident_bytes) / clamp_min(sum(rust_proxy_cache_hot_limit_bytes),1)", + "legendFormat": "used", + "range": true + } + ], + "description": "Usage relative to configured limit: rust_proxy_cache_resident_bytes / rust_proxy_cache_hot_limit_bytes.", + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + } + }, + { + "id": 121, + "type": "gauge", + "title": "Transient RAM usage", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 115 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": {}, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "100 * sum(rust_proxy_cache_transient_bytes) / clamp_min(sum(rust_proxy_cache_transient_limit_bytes),1)", + "legendFormat": "used", + "range": true + } + ], + "description": "Usage relative to configured limit: rust_proxy_cache_transient_bytes / rust_proxy_cache_transient_limit_bytes.", + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + } + }, + { + "id": 122, + "type": "gauge", + "title": "Cold cache usage", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 115 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": {}, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "100 * sum(rust_proxy_cache_cold_bytes) / clamp_min(sum(rust_proxy_cache_cold_limit_bytes),1)", + "legendFormat": "used", + "range": true + } + ], + "description": "Usage relative to configured limit: rust_proxy_cache_cold_bytes / rust_proxy_cache_cold_limit_bytes.", + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + } + }, + { + "id": 123, + "type": "gauge", + "title": "Cold queue usage", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 115 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": {}, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "100 * sum(rust_proxy_cache_cold_queue_bytes) / clamp_min(sum(rust_proxy_cache_cold_queue_limit_bytes),1)", + "legendFormat": "used", + "range": true + } + ], + "description": "Usage relative to configured limit: rust_proxy_cache_cold_queue_bytes / rust_proxy_cache_cold_queue_limit_bytes.", + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + } + }, + { + "id": 124, + "type": "timeseries", + "title": "Cache pressure and contention", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 120 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_cache_memory_pressure_rejections_total[$__rate_interval]))", + "legendFormat": "memory pressure rejects/s", + "range": true + }, + { + "refId": "B", + "expr": "sum(rate(rust_proxy_cache_hot_eviction_limited_total[$__rate_interval]))", + "legendFormat": "hot eviction limited/s", + "range": true + }, + { + "refId": "C", + "expr": "sum(rate(rust_proxy_cache_cold_queue_dropped_total[$__rate_interval]))", + "legendFormat": "cold queue drops/s", + "range": true + }, + { + "refId": "D", + "expr": "sum(rate(rust_proxy_cache_collapse_timeouts_total[$__rate_interval]))", + "legendFormat": "collapsed fill timeouts/s", + "range": true + } + ], + "description": "Cache pressure, eviction limitations, asynchronous cold-write queue losses and collapsed-fill timeouts (events per second)." + }, + { + "id": 125, + "type": "timeseries", + "title": "Cache fill and proactive activity", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 120 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_cache_collapsed_fills_total[$__rate_interval]))", + "legendFormat": "collapsed fills/s", + "range": true + }, + { + "refId": "B", + "expr": "sum(rate(rust_proxy_cache_proactive_scheduled_total[$__rate_interval]))", + "legendFormat": "proactive scheduled/s", + "range": true + }, + { + "refId": "C", + "expr": "sum(rate(rust_proxy_cache_proactive_started_total[$__rate_interval]))", + "legendFormat": "proactive started/s", + "range": true + }, + { + "refId": "D", + "expr": "sum(rate(rust_proxy_cache_proactive_skipped_total[$__rate_interval]))", + "legendFormat": "proactive skipped/s", + "range": true + } + ], + "description": "Rate of cache fill collapsing and proactive refresh scheduling, starts and skips." + }, { "id": 25, "type": "row", @@ -2102,7 +2927,7 @@ "h": 1, "w": 24, "x": 0, - "y": 96 + "y": 127 }, "panels": [] }, @@ -2118,7 +2943,7 @@ "h": 8, "w": 12, "x": 0, - "y": 97 + "y": 128 }, "fieldConfig": { "defaults": { @@ -2197,7 +3022,7 @@ "h": 8, "w": 12, "x": 12, - "y": 97 + "y": 128 }, "fieldConfig": { "defaults": { @@ -2252,7 +3077,7 @@ "h": 7, "w": 8, "x": 0, - "y": 105 + "y": 136 }, "fieldConfig": { "defaults": { @@ -2307,7 +3132,7 @@ "h": 7, "w": 8, "x": 8, - "y": 105 + "y": 136 }, "fieldConfig": { "defaults": { @@ -2368,7 +3193,7 @@ "h": 7, "w": 8, "x": 16, - "y": 105 + "y": 136 }, "fieldConfig": { "defaults": { @@ -2423,7 +3248,7 @@ "h": 7, "w": 12, "x": 0, - "y": 112 + "y": 143 }, "fieldConfig": { "defaults": { @@ -2478,7 +3303,7 @@ "h": 7, "w": 12, "x": 12, - "y": 112 + "y": 143 }, "fieldConfig": { "defaults": { @@ -2509,17 +3334,18 @@ "targets": [ { "refId": "A", - "expr": "rate(rust_proxy_process_read_bytes[$__rate_interval])", + "expr": "clamp_min(deriv(rust_proxy_process_read_bytes[$__rate_interval]), 0)", "legendFormat": "read", "range": true }, { "refId": "B", - "expr": "rate(rust_proxy_process_write_bytes[$__rate_interval])", + "expr": "clamp_min(deriv(rust_proxy_process_write_bytes[$__rate_interval]), 0)", "legendFormat": "write", "range": true } - ] + ], + "description": "Approximate bytes/second from cumulative process I/O gauges sampled by Linux; negative deltas are clamped to zero." }, { "id": 33, @@ -2530,7 +3356,7 @@ "h": 1, "w": 24, "x": 0, - "y": 119 + "y": 150 }, "panels": [] }, @@ -2546,7 +3372,7 @@ "h": 7, "w": 8, "x": 0, - "y": 120 + "y": 151 }, "fieldConfig": { "defaults": { @@ -2607,7 +3433,7 @@ "h": 7, "w": 8, "x": 8, - "y": 120 + "y": 151 }, "fieldConfig": { "defaults": { @@ -2662,7 +3488,7 @@ "h": 7, "w": 8, "x": 16, - "y": 120 + "y": 151 }, "fieldConfig": { "defaults": { @@ -2708,7 +3534,7 @@ { "id": 37, "type": "timeseries", - "title": "WebSocket tunnels", + "title": "Active WebSocket tunnels", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" @@ -2717,7 +3543,7 @@ "h": 7, "w": 12, "x": 0, - "y": 127 + "y": 158 }, "fieldConfig": { "defaults": { @@ -2748,29 +3574,12 @@ "targets": [ { "refId": "A", - "expr": "rust_proxy_websocket_tunnels_active", + "expr": "sum(rust_proxy_websocket_tunnels_active)", "legendFormat": "active", "range": true - }, - { - "refId": "B", - "expr": "rate(rust_proxy_websocket_tunnels_total[$__rate_interval])", - "legendFormat": "opened/s", - "range": true - }, - { - "refId": "C", - "expr": "rate(rust_proxy_websocket_errors_total[$__rate_interval])", - "legendFormat": "errors/s", - "range": true - }, - { - "refId": "D", - "expr": "rate(rust_proxy_websocket_handshake_errors_total[$__rate_interval])", - "legendFormat": "handshake errors/s", - "range": true } - ] + ], + "description": "Open HTTP/1.1, HTTP/2 and HTTP/3 WebSocket transports (gauge), not tunnel creation rate." }, { "id": 38, @@ -2784,7 +3593,7 @@ "h": 7, "w": 12, "x": 12, - "y": 127 + "y": 158 }, "fieldConfig": { "defaults": { @@ -2840,22 +3649,9 @@ ] }, { - "id": 39, - "type": "row", - "title": "Layer-4 streams and logging", - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 134 - }, - "panels": [] - }, - { - "id": 40, + "id": 126, "type": "timeseries", - "title": "Stream sessions", + "title": "WebSocket tunnel events", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" @@ -2864,7 +3660,69 @@ "h": 7, "w": 12, "x": 0, - "y": 135 + "y": 165 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_websocket_tunnels_total[$__rate_interval]))", + "legendFormat": "opened/s", + "range": true + }, + { + "refId": "B", + "expr": "sum(rate(rust_proxy_websocket_errors_total[$__rate_interval]))", + "legendFormat": "transport errors/s", + "range": true + }, + { + "refId": "C", + "expr": "sum(rate(rust_proxy_websocket_handshake_errors_total[$__rate_interval]))", + "legendFormat": "handshake errors/s", + "range": true + } + ], + "description": "Rates for established WebSocket tunnels, transport errors and rejected upgrade handshakes." + }, + { + "id": 127, + "type": "timeseries", + "title": "Active WebSockets by HTTP version", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 165 }, "fieldConfig": { "defaults": { @@ -2895,23 +3753,87 @@ "targets": [ { "refId": "A", - "expr": "rust_proxy_stream_active", - "legendFormat": "{{stream}} active", + "expr": "sum(rust_proxy_websocket_h1_tunnels_active)", + "legendFormat": "HTTP/1.1", "range": true }, { "refId": "B", - "expr": "sum by (stream) (rate(rust_proxy_stream_errors_total[$__rate_interval]))", - "legendFormat": "{{stream}} errors/s", + "expr": "sum(rust_proxy_websocket_h2_rfc8441_tunnels_active)", + "legendFormat": "HTTP/2 RFC 8441", "range": true }, { "refId": "C", - "expr": "sum by (stream) (rate(rust_proxy_stream_rejected_total[$__rate_interval]))", - "legendFormat": "{{stream}} rejects/s", + "expr": "sum(rust_proxy_websocket_h3_rfc9220_tunnels_active)", + "legendFormat": "HTTP/3 RFC 9220", "range": true } - ] + ], + "description": "Currently open WebSocket tunnels per public transport protocol, all gauges." + }, + { + "id": 39, + "type": "row", + "title": "Layer-4 streams and logging", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 172 + }, + "panels": [] + }, + { + "id": 40, + "type": "timeseries", + "title": "Active stream sessions", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 173 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (stream, protocol) (rust_proxy_stream_active)", + "legendFormat": "{{stream}} ({{protocol}})", + "range": true + } + ], + "description": "Active Layer-4 TCP/UDP sessions by stream and protocol (gauge)." }, { "id": 41, @@ -2925,7 +3847,7 @@ "h": 7, "w": 12, "x": 12, - "y": 135 + "y": 173 }, "fieldConfig": { "defaults": { @@ -2968,10 +3890,204 @@ } ] }, + { + "id": 128, + "type": "timeseries", + "title": "Stream session event rates", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 180 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (stream,protocol) (rate(rust_proxy_stream_sessions_total[$__rate_interval]))", + "legendFormat": "{{stream}} ({{protocol}}) started", + "range": true + }, + { + "refId": "B", + "expr": "sum by (stream,protocol) (rate(rust_proxy_stream_errors_total[$__rate_interval]))", + "legendFormat": "{{stream}} ({{protocol}}) errors", + "range": true + }, + { + "refId": "C", + "expr": "sum by (stream,protocol) (rate(rust_proxy_stream_rejected_total[$__rate_interval]))", + "legendFormat": "{{stream}} ({{protocol}}) rejected", + "range": true + } + ], + "description": "Layer-4 TCP/UDP started, failed and rejected sessions per second." + }, + { + "id": 129, + "type": "timeseries", + "title": "Stream backend health state", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 180 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + }, + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "Unverified", + "color": "gray" + }, + "1": { + "text": "Checking", + "color": "yellow" + }, + "2": { + "text": "Healthy", + "color": "green" + }, + "3": { + "text": "Unhealthy", + "color": "red" + }, + "4": { + "text": "Disabled", + "color": "gray" + } + } + } + ], + "min": 0, + "max": 4, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_stream_backend_health_state", + "legendFormat": "{{stream}} ({{protocol}}) / {{backend}}", + "range": true + } + ], + "description": "Backend health state codes: 0 unverified, 1 checking, 2 healthy, 3 unhealthy, 4 disabled. Available only for configured active stream backends." + }, + { + "id": 130, + "type": "timeseries", + "title": "Stream capacity utilization", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 180 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + }, + "min": 0, + "max": 100 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "100 * rust_proxy_stream_active / clamp_min(rust_proxy_stream_max_connections,1)", + "legendFormat": "{{stream}} ({{protocol}})", + "range": true + } + ], + "description": "Active stream sessions relative to its configured maximum connection/session capacity." + }, { "id": 42, "type": "timeseries", - "title": "Structured log sink", + "title": "Structured log queue capacity", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" @@ -2980,7 +4096,7 @@ "h": 7, "w": 12, "x": 0, - "y": 142 + "y": 187 }, "fieldConfig": { "defaults": { @@ -3011,28 +4127,17 @@ "targets": [ { "refId": "A", - "expr": "rust_proxy_log_sink_queue_capacity", + "expr": "sum(rust_proxy_log_sink_queue_capacity)", "legendFormat": "queue capacity", "range": true - }, - { - "refId": "B", - "expr": "rate(rust_proxy_log_sink_queue_dropped_total[$__rate_interval])", - "legendFormat": "dropped/s", - "range": true - }, - { - "refId": "C", - "expr": "rate(rust_proxy_log_sink_errors_total[$__rate_interval])", - "legendFormat": "errors/s", - "range": true } - ] + ], + "description": "Configured number of log sink queue slots (gauge); log delivery and dropped event rates are shown separately." }, { "id": 43, "type": "timeseries", - "title": "Admin/control plane", + "title": "Admin request rate", "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" @@ -3041,7 +4146,119 @@ "h": 7, "w": 12, "x": 12, - "y": 142 + "y": 187 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_admin_requests_total[$__rate_interval]))", + "legendFormat": "admin requests/s", + "range": true + } + ], + "description": "Requests to the control plane per second. Request permits and required restarts are shown separately." + }, + { + "id": 131, + "type": "timeseries", + "title": "Structured log sink event rates", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 194 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_log_sink_sent_total[$__rate_interval]))", + "legendFormat": "delivered/s", + "range": true + }, + { + "refId": "B", + "expr": "sum(rate(rust_proxy_log_sink_queue_dropped_total[$__rate_interval]))", + "legendFormat": "dropped/s", + "range": true + }, + { + "refId": "C", + "expr": "sum(rate(rust_proxy_log_sink_errors_total[$__rate_interval]))", + "legendFormat": "delivery errors/s", + "range": true + } + ], + "description": "Log delivery, queue overflow and sink failure event rates per second." + }, + { + "id": 132, + "type": "timeseries", + "title": "Request permits and limits", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 194 }, "fieldConfig": { "defaults": { @@ -3072,23 +4289,24 @@ "targets": [ { "refId": "A", - "expr": "rate(rust_proxy_admin_requests_total[$__rate_interval])", - "legendFormat": "admin requests/s", + "expr": "sum(rust_proxy_request_capacity)", + "legendFormat": "capacity", "range": true }, { "refId": "B", - "expr": "rust_proxy_request_permits_available", - "legendFormat": "request permits", + "expr": "sum(rust_proxy_request_permits_available)", + "legendFormat": "available", "range": true }, { "refId": "C", - "expr": "rust_proxy_restart_required", - "legendFormat": "restart required", + "expr": "sum(rust_proxy_request_capacity - rust_proxy_request_permits_available)", + "legendFormat": "in use", "range": true } - ] + ], + "description": "Concurrent request capacity, available permits and in-use permits across scraped instances (gauges)." }, { "id": 44, @@ -3099,7 +4317,7 @@ "h": 1, "w": 24, "x": 0, - "y": 149 + "y": 201 }, "panels": [] }, @@ -3115,7 +4333,7 @@ "h": 7, "w": 12, "x": 0, - "y": 150 + "y": 202 }, "fieldConfig": { "defaults": { @@ -3200,7 +4418,7 @@ "h": 7, "w": 12, "x": 12, - "y": 150 + "y": 202 }, "fieldConfig": { "defaults": { @@ -3246,7 +4464,7 @@ "h": 1, "w": 24, "x": 0, - "y": 157 + "y": 209 }, "panels": [] }, @@ -3288,7 +4506,7 @@ "h": 7, "w": 12, "x": 0, - "y": 158 + "y": 210 }, "description": "Seconds until the observed certificate notAfter timestamp.", "targets": [ @@ -3338,7 +4556,7 @@ "h": 7, "w": 6, "x": 12, - "y": 158 + "y": 210 }, "targets": [ { @@ -3361,7 +4579,7 @@ "h": 7, "w": 6, "x": 18, - "y": 158 + "y": 210 }, "fieldConfig": { "defaults": { @@ -3399,7 +4617,7 @@ "h": 1, "w": 24, "x": 0, - "y": 165 + "y": 217 }, "id": 51, "panels": [], @@ -3469,7 +4687,7 @@ "h": 7, "w": 8, "x": 0, - "y": 166 + "y": 218 }, "id": 52, "options": { @@ -3565,7 +4783,7 @@ "h": 7, "w": 8, "x": 8, - "y": 166 + "y": 218 }, "id": 53, "options": { @@ -3672,7 +4890,7 @@ "h": 7, "w": 8, "x": 16, - "y": 166 + "y": 218 }, "id": 54, "options": { @@ -3711,7 +4929,7 @@ "h": 1, "w": 24, "x": 0, - "y": 173 + "y": 225 }, "id": 55, "panels": [], @@ -3781,7 +4999,7 @@ "h": 7, "w": 12, "x": 0, - "y": 174 + "y": 226 }, "id": 56, "options": { @@ -3888,7 +5106,7 @@ "h": 7, "w": 12, "x": 12, - "y": 174 + "y": 226 }, "id": 57, "options": { @@ -3941,7 +5159,7 @@ "h": 1, "w": 24, "x": 0, - "y": 181 + "y": 233 }, "panels": [] }, @@ -3957,7 +5175,7 @@ "h": 7, "w": 6, "x": 0, - "y": 182 + "y": 234 }, "fieldConfig": { "defaults": { @@ -4001,7 +5219,7 @@ "h": 7, "w": 6, "x": 6, - "y": 182 + "y": 234 }, "fieldConfig": { "defaults": { @@ -4045,7 +5263,7 @@ "h": 7, "w": 6, "x": 12, - "y": 182 + "y": 234 }, "fieldConfig": { "defaults": { @@ -4090,7 +5308,7 @@ "h": 7, "w": 6, "x": 18, - "y": 182 + "y": 234 }, "fieldConfig": { "defaults": { @@ -4135,7 +5353,7 @@ "h": 7, "w": 12, "x": 0, - "y": 189 + "y": 241 }, "fieldConfig": { "defaults": { @@ -4184,7 +5402,7 @@ "h": 7, "w": 12, "x": 12, - "y": 189 + "y": 241 }, "fieldConfig": { "defaults": { @@ -4233,7 +5451,7 @@ "h": 7, "w": 12, "x": 0, - "y": 196 + "y": 248 }, "fieldConfig": { "defaults": { @@ -4282,7 +5500,7 @@ "h": 7, "w": 12, "x": 12, - "y": 196 + "y": 248 }, "fieldConfig": { "defaults": { @@ -4331,7 +5549,7 @@ "h": 5, "w": 12, "x": 0, - "y": 203 + "y": 255 }, "fieldConfig": { "defaults": { @@ -4386,7 +5604,7 @@ "h": 5, "w": 12, "x": 12, - "y": 203 + "y": 255 }, "fieldConfig": { "defaults": { @@ -4422,13 +5640,13 @@ { "id": 89, "type": "row", - "title": "Web Server 0.33.0", + "title": "Web Server", "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, - "y": 208 + "y": 260 }, "panels": [] }, @@ -4444,7 +5662,7 @@ "h": 7, "w": 12, "x": 0, - "y": 209 + "y": 261 }, "fieldConfig": { "defaults": { @@ -4504,7 +5722,7 @@ "h": 7, "w": 12, "x": 12, - "y": 209 + "y": 261 }, "fieldConfig": { "defaults": { @@ -4564,7 +5782,7 @@ "h": 7, "w": 8, "x": 0, - "y": 216 + "y": 268 }, "fieldConfig": { "defaults": { @@ -4621,7 +5839,7 @@ "h": 7, "w": 8, "x": 8, - "y": 216 + "y": 268 }, "fieldConfig": { "defaults": { @@ -4678,7 +5896,7 @@ "h": 7, "w": 8, "x": 16, - "y": 216 + "y": 268 }, "fieldConfig": { "defaults": { @@ -4723,7 +5941,7 @@ "h": 7, "w": 12, "x": 0, - "y": 223 + "y": 275 }, "fieldConfig": { "defaults": { @@ -4783,7 +6001,7 @@ "h": 7, "w": 12, "x": 12, - "y": 223 + "y": 275 }, "fieldConfig": { "defaults": { @@ -4834,7 +6052,7 @@ "h": 1, "w": 24, "x": 0, - "y": 230 + "y": 282 }, "panels": [] }, @@ -4850,7 +6068,7 @@ "h": 6, "w": 6, "x": 0, - "y": 231 + "y": 283 }, "fieldConfig": { "defaults": { @@ -4895,7 +6113,7 @@ "h": 6, "w": 6, "x": 6, - "y": 231 + "y": 283 }, "fieldConfig": { "defaults": { @@ -4940,7 +6158,7 @@ "h": 6, "w": 12, "x": 12, - "y": 231 + "y": 283 }, "fieldConfig": { "defaults": { @@ -5006,7 +6224,7 @@ "h": 7, "w": 12, "x": 0, - "y": 237 + "y": 289 }, "fieldConfig": { "defaults": { @@ -5060,7 +6278,7 @@ "h": 7, "w": 12, "x": 12, - "y": 237 + "y": 289 }, "fieldConfig": { "defaults": { @@ -5114,7 +6332,7 @@ "h": 1, "w": 24, "x": 0, - "y": 244 + "y": 296 }, "panels": [] }, @@ -5130,7 +6348,7 @@ "h": 7, "w": 12, "x": 0, - "y": 245 + "y": 297 }, "fieldConfig": { "defaults": { @@ -5173,7 +6391,7 @@ "h": 7, "w": 12, "x": 12, - "y": 245 + "y": 297 }, "fieldConfig": { "defaults": { @@ -5227,7 +6445,7 @@ "h": 7, "w": 12, "x": 0, - "y": 252 + "y": 304 }, "fieldConfig": { "defaults": { @@ -5281,7 +6499,7 @@ "h": 7, "w": 12, "x": 12, - "y": 252 + "y": 304 }, "fieldConfig": { "defaults": { @@ -5332,7 +6550,7 @@ "h": 1, "w": 24, "x": 0, - "y": 259 + "y": 311 }, "panels": [] }, @@ -5348,7 +6566,7 @@ "h": 7, "w": 12, "x": 0, - "y": 260 + "y": 312 }, "fieldConfig": { "defaults": { @@ -5408,7 +6626,7 @@ "h": 7, "w": 12, "x": 12, - "y": 260 + "y": 312 }, "fieldConfig": { "defaults": { @@ -5468,7 +6686,7 @@ "h": 7, "w": 12, "x": 0, - "y": 267 + "y": 319 }, "fieldConfig": { "defaults": { @@ -5507,7 +6725,9 @@ "rustproxy", "reverse-proxy", "web-server", - "php-fpm" + "php-fpm", + "prometheus", + "operations" ], "templating": { "list": [ @@ -5529,8 +6749,10 @@ "uid": "${DS_PROMETHEUS}" }, "query": { - "query": "label_values(rust_proxy_traffic_requests_total, vhost)", - "refId": "StandardVariableQuery" + "qryType": 1, + "query": "rust_proxy_traffic_requests_total", + "label": "vhost", + "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "definition": "label_values(rust_proxy_traffic_requests_total, vhost)", "current": { @@ -5557,8 +6779,10 @@ "uid": "${DS_PROMETHEUS}" }, "query": { - "query": "label_values(rust_proxy_docker_agent_enabled, agent)", - "refId": "StandardVariableQuery" + "qryType": 1, + "query": "rust_proxy_docker_agent_enabled", + "label": "agent", + "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "definition": "label_values(rust_proxy_docker_agent_enabled, agent)", "current": { @@ -5585,8 +6809,10 @@ "uid": "${DS_PROMETHEUS}" }, "query": { - "query": "label_values(rust_proxy_docker_vhost_enabled{agent=~\"$docker_agent\"}, service)", - "refId": "StandardVariableQuery" + "qryType": 1, + "query": "rust_proxy_docker_vhost_enabled{agent=~\"$docker_agent\"}", + "label": "service", + "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "definition": "label_values(rust_proxy_docker_vhost_enabled{agent=~\"$docker_agent\"}, service)", "current": { @@ -5613,8 +6839,10 @@ "uid": "${DS_PROMETHEUS}" }, "query": { - "query": "label_values(rust_proxy_web_server_requests_total, web_service)", - "refId": "StandardVariableQuery" + "qryType": 1, + "query": "rust_proxy_web_server_requests_total", + "label": "web_service", + "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "definition": "label_values(rust_proxy_web_server_requests_total, web_service)", "current": { @@ -5640,9 +6868,9 @@ }, "timepicker": {}, "timezone": "browser", - "title": "RustProxy 0.33.3 - Operations", + "title": "RustProxy 0.34.13 - Operations", "uid": "rustproxy-0170", - "version": 6, + "version": 7, "weekStart": "", - "description": "RustProxy 0.29.2 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic." -} + "description": "RustProxy 0.34.13 Prometheus operations dashboard: traffic and errors, vhosts and backends, Docker discovery, cache capacity and pressure, process and system diagnostics, HTTP/2, HTTP/3, TLS, WebSockets, Layer-4 streams, log sinks, Web Server and PHP-FPM. Some metrics are optional depending on exporter collector settings and enabled services." +} \ No newline at end of file diff --git a/src/acl.rs b/src/acl.rs index ec17ab7..2fe32d9 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -58,13 +58,17 @@ impl Acl { ensure!(rule.network.len() <= 254, "ACL network is too long"); let target = if let Ok(network) = parse_network(&rule.network) { if let IpNet::V6(net) = network { - ensure!(net.addr().to_ipv4_mapped().is_none(), - "use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"); + ensure!( + net.addr().to_ipv4_mapped().is_none(), + "use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules" + ); } RuleMatch::Ip(network) } else { - ensure!(rule.action == Action::Allow, - "dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"); + ensure!( + rule.action == Action::Allow, + "dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny" + ); RuleMatch::Dns(dynamic::hostname(&rule.network)?) }; rules.push((target, rule.action)); @@ -94,9 +98,16 @@ pub fn allows_profiles_with_country( let mut explicit_allow = false; let mut defaults_allow = true; for profile in profiles { - let ip_action = profile.acl.as_ref().and_then(|acl| acl.explicit_action(peer)); + let ip_action = profile + .acl + .as_ref() + .and_then(|acl| acl.explicit_action(peer)); let country_listed = country.is_some_and(|code| { - profile.countries.countries.iter().any(|value| value.eq_ignore_ascii_case(code)) + profile + .countries + .countries + .iter() + .any(|value| value.eq_ignore_ascii_case(code)) }); if ip_action == Some(Action::Deny) || (profile.countries.mode == crate::rate_limit::CountryMode::Block && country_listed) @@ -106,20 +117,32 @@ pub fn allows_profiles_with_country( explicit_allow |= ip_action == Some(Action::Allow) || (profile.countries.mode == crate::rate_limit::CountryMode::Allow && country_listed); defaults_allow &= profile.countries.mode != crate::rate_limit::CountryMode::Allow - && profile.acl.as_ref().is_none_or(|acl| acl.default == Action::Allow); + && profile + .acl + .as_ref() + .is_none_or(|acl| acl.default == Action::Allow); } explicit_allow || defaults_allow } -pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator { +pub fn profile_ids<'a>( + legacy: Option<&'a str>, + ids: &'a [String], +) -> impl Iterator { legacy.into_iter().chain(ids.iter().map(String::as_str)) } pub fn validate_profile_ids(legacy: Option<&str>, ids: &[String]) -> Result<()> { - ensure!(ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8, "at most 8 ACL profiles may be assigned"); + ensure!( + ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8, + "at most 8 ACL profiles may be assigned" + ); let mut seen = std::collections::HashSet::new(); for id in profile_ids(legacy, ids) { - ensure!(crate::config::valid_id(id) && seen.insert(id), "invalid or duplicate ACL profile id"); + ensure!( + crate::config::valid_id(id) && seen.insert(id), + "invalid or duplicate ACL profile id" + ); } Ok(()) } @@ -181,9 +204,7 @@ pub fn allows_with_country( && (ip_action == Some(Action::Allow) || acl.is_none_or(|value| value.default == Action::Allow)) } - crate::rate_limit::CountryMode::Allow => { - ip_action == Some(Action::Allow) || country_listed - } + crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed, } } pub fn validate_decoded_routing_path(path: &str) -> Result<()> { @@ -220,7 +241,8 @@ pub fn routing_path_view(raw: &str) -> Result> { ); } ensure!( - !raw.split('/').any(|segment| segment == "." || segment == ".."), + !raw.split('/') + .any(|segment| segment == "." || segment == ".."), "ambiguous path segments" ); Ok(std::borrow::Cow::Borrowed(raw)) diff --git a/src/admin/api_token_access.rs b/src/admin/api_token_access.rs index 8e300ec..44d67d9 100644 --- a/src/admin/api_token_access.rs +++ b/src/admin/api_token_access.rs @@ -21,13 +21,16 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b | "/api/metrics/json" | "/api/metrics/summary" | "/api/cache/bans" - ) || (method == Method::GET - && path.strip_prefix("/api/config/") + ) + || (method == Method::GET + && path + .strip_prefix("/api/config/") .is_some_and(|section| ConfigSection::from_name(section).is_some())); let write = permission == "rw" && ((method == Method::PUT && (path == "/api/config" - || path.strip_prefix("/api/config/") + || path + .strip_prefix("/api/config/") .is_some_and(|section| ConfigSection::from_name(section).is_some()))) || (method == Method::POST && matches!( diff --git a/src/admin/config_sections.rs b/src/admin/config_sections.rs index c0e9930..9abf483 100644 --- a/src/admin/config_sections.rs +++ b/src/admin/config_sections.rs @@ -1,6 +1,3 @@ -// GET and PUT resolve exactly the same named sections. All writes go through -// App::update_as: full validation, revision check under the update lock, and -// one durable transaction. These handlers do not implement partial patches. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ConfigSection { Listeners, @@ -93,66 +90,109 @@ impl ConfigSection { } } - fn replace(self, config: &mut crate::config::Config, name: &str, value: Value) -> Result<(), ApiError> { + fn replace( + self, + config: &mut crate::config::Config, + name: &str, + value: Value, + ) -> Result<(), ApiError> { match self { - Self::Listeners => config.listeners = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::DockerAgents => config.docker_agents = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Http2 => config.http2 = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Pages => config.pages = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Cache => config.cache = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Transform => config.transform = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Forwarding => config.forwarding = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Headers => config.headers = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Prometheus => config.prometheus = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Geoip => config.geoip_updates = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Administration => config.administration = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Tls => config.tls = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Vhosts => config.vhosts = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::WebServices => config.web_services = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::PhpFpmProfiles => config.php_fpm_profiles = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Redirects => config.redirects = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::NotFoundHosts => config.not_found_hosts = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::Streams => config.streams = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::AclProfiles => config.acl_profiles = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, - Self::HostPolicies => config.host_policies = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?, + Self::Listeners => { + config.listeners = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::DockerAgents => { + config.docker_agents = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Http2 => { + config.http2 = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Pages => { + config.pages = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Cache => { + config.cache = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Transform => { + config.transform = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Forwarding => { + config.forwarding = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Headers => { + config.headers = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Prometheus => { + config.prometheus = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Geoip => { + config.geoip_updates = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Administration => { + config.administration = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Tls => { + config.tls = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Vhosts => { + config.vhosts = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::WebServices => { + config.web_services = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::PhpFpmProfiles => { + config.php_fpm_profiles = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Redirects => { + config.redirects = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::NotFoundHosts => { + config.not_found_hosts = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::Streams => { + config.streams = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::AclProfiles => { + config.acl_profiles = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } + Self::HostPolicies => { + config.host_policies = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))? + } Self::Response => { - let part: ResponseSection = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?; + let part: ResponseSection = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))?; config.server_header = part.server_header; config.diagnostic_headers = part.diagnostic_headers; config.compression = part.compression; } Self::Logging => { - // A logging save must include both sinks; there is no implicit fallback - // to a single-field payload that could reset error_log accidentally. - let part: LoggingSection = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?; + let part: LoggingSection = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))?; config.logging = part.logging; config.error_log = part.error_log; } Self::GlobalSecurity => { - let part: crate::config::GlobalSecurity = serde_json::from_value(value) - .map_err(|error| invalid_section(name, error))?; + let part: crate::config::GlobalSecurity = + serde_json::from_value(value).map_err(|error| invalid_section(name, error))?; if part.user_agent_blocking.is_none() { return Err(ApiError( StatusCode::BAD_REQUEST, @@ -167,10 +207,12 @@ impl ConfigSection { } fn requested_config_section(name: &str) -> Result { - ConfigSection::from_name(name).ok_or_else(|| ApiError( - StatusCode::NOT_FOUND, - "unknown configuration section".into(), - )) + ConfigSection::from_name(name).ok_or_else(|| { + ApiError( + StatusCode::NOT_FOUND, + "unknown configuration section".into(), + ) + }) } async fn configuration_section( @@ -192,7 +234,6 @@ async fn update_section( Path(section): Path, Json(input): Json, ) -> Result, ApiError> { - // Resolve the name before taking the snapshot, including for stale requests. let selected = requested_config_section(§ion)?; let current = state.config.load_full(); if current.envelope.revision != input.expected_revision { @@ -203,12 +244,18 @@ async fn update_section( } let mut config = current.envelope.config.clone(); selected.replace(&mut config, §ion, input.value)?; - state.update_as(Update { expected_revision: input.expected_revision, config }, session.username) - .await.map(Json) + state + .update_as( + Update { + expected_revision: input.expected_revision, + config, + }, + session.username, + ) + .await + .map(Json) } -// GeoIP is an explicit side-effecting action within the geoip settings section. -// GET and PUT are only for desired settings; POST refreshes external data. async fn geoip_update( State(state): State>, Extension(session): Extension, @@ -239,4 +286,3 @@ async fn geoip_update( .map_err(auth::internal)?; Ok(Json(result)) } - diff --git a/src/admin/file_logs.rs b/src/admin/file_logs.rs index da50067..065cb6a 100644 --- a/src/admin/file_logs.rs +++ b/src/admin/file_logs.rs @@ -95,32 +95,37 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec 180 { - 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, path, file_modified_ms(&meta))) - }), 256); + let entries = newest_log_files( + std::fs::read_dir(&directory) + .ok() + .into_iter() + .flat_map(|iter| iter.filter_map(|entry| entry.ok())) + .filter_map(|entry| { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + let plain = name.ends_with(".jsonl"); + let rotated = name.contains(".jsonl.") && name.ends_with(".gz"); + if (!plain && !rotated) || name.len() > 180 { + 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, path, file_modified_ms(&meta))) + }), + 256, + ); for (name, path, _) in entries { let id = if name.ends_with(".jsonl") { format!("vhost:{}", name.trim_end_matches(".jsonl")) } else { format!("vhost-gz:{name}") }; - let vhost_name = name.split_once(".jsonl").map_or(name.as_str(), |(prefix, _)| prefix); + let vhost_name = name + .split_once(".jsonl") + .map_or(name.as_str(), |(prefix, _)| prefix); let group_id = format!("vhost:{vhost_name}"); sources.push(source_from_path( id, @@ -144,7 +149,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec "JSONL", @@ -160,7 +172,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec Value { diff --git a/src/admin/openapi.json b/src/admin/openapi.json index c855b17..a4d12e5 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -14305,4 +14305,4 @@ } }, "tags": [] -} +} \ No newline at end of file diff --git a/src/admin/router.rs b/src/admin/router.rs index e532d33..384cdeb 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -260,7 +260,10 @@ pub fn router(state: Arc) -> Router { post(restore_config_history).layer(DefaultBodyLimit::max(4096)), ) .route("/api/config/geoip/update", post(geoip_update)) - .route("/api/config/{section}", get(configuration_section).put(update_section)) + .route( + "/api/config/{section}", + get(configuration_section).put(update_section), + ) .route("/api/logs", get(logs)) .route("/api/live-logs", get(live_logs_stream)) .route("/api/file-logs", get(file_logs)) diff --git a/src/admin/tests.rs b/src/admin/tests.rs index a5143a0..620d1ca 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -654,11 +654,29 @@ mod tests { #[test] fn config_section_registry_read_and_replace_are_symmetric() { let names = [ - "listeners", "docker-agents", "http2", "pages", "cache", "transform", - "forwarding", "headers", "prometheus", "geoip", "administration", - "tls", "vhosts", "web-services", "php-fpm-profiles", "redirects", - "not-found-hosts", "streams", "acl-profiles", "host-policies", - "response", "logging", "global-security", + "listeners", + "docker-agents", + "http2", + "pages", + "cache", + "transform", + "forwarding", + "headers", + "prometheus", + "geoip", + "administration", + "tls", + "vhosts", + "web-services", + "php-fpm-profiles", + "redirects", + "not-found-hosts", + "streams", + "acl-profiles", + "host-policies", + "response", + "logging", + "global-security", ]; let mut original = Config::default(); original.global_security.compile().unwrap(); @@ -671,7 +689,14 @@ mod tests { assert_eq!(serde_json::to_value(&updated).unwrap(), expected, "{name}"); assert_eq!(section.read(&updated), value, "{name}"); } - for name in ["user-agent-blocking", "error_log", "history", "storage", "unknown", "tls/nested"] { + for name in [ + "user-agent-blocking", + "error_log", + "history", + "storage", + "unknown", + "tls/nested", + ] { assert!(ConfigSection::from_name(name).is_none(), "{name}"); } } @@ -681,17 +706,40 @@ mod tests { let (router, store, _dir, secret, _csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); for name in [ - "listeners", "docker-agents", "http2", "pages", "cache", "transform", - "forwarding", "headers", "prometheus", "geoip", "administration", "tls", - "vhosts", "web-services", "php-fpm-profiles", "redirects", "not-found-hosts", - "streams", "acl-profiles", "host-policies", "response", "logging", + "listeners", + "docker-agents", + "http2", + "pages", + "cache", + "transform", + "forwarding", + "headers", + "prometheus", + "geoip", + "administration", + "tls", + "vhosts", + "web-services", + "php-fpm-profiles", + "redirects", + "not-found-hosts", + "streams", + "acl-profiles", + "host-policies", + "response", + "logging", "global-security", ] { - let response = router.clone().oneshot( - request(&format!("/api/config/{name}")) - .header("cookie", &cookie) - .body(Body::empty()).unwrap(), - ).await.unwrap(); + let response = router + .clone() + .oneshot( + request(&format!("/api/config/{name}")) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK, "{name}"); let data = json_body(response).await; assert_eq!(data["section"], name); @@ -708,24 +756,57 @@ mod tests { assert!(data["value"]["user_agent_blocking"].is_object()); } } - let unknown = router.clone().oneshot( - request("/api/config/user-agent-blocking") - .header("cookie", &cookie) - .body(Body::empty()).unwrap(), - ).await.unwrap(); + let unknown = router + .clone() + .oneshot( + request("/api/config/user-agent-blocking") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(unknown.status(), StatusCode::NOT_FOUND); store.close().await; } #[test] fn geoip_update_is_scoped_to_settings_for_general_api_tokens() { - assert!(general_api_token_allowed(&Method::GET, "/api/config/geoip", "ro")); - assert!(general_api_token_allowed(&Method::PUT, "/api/config/geoip", "rw")); - assert!(!general_api_token_allowed(&Method::PUT, "/api/config/geoip", "ro")); - assert!(general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "rw")); - assert!(!general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "ro")); - assert!(!general_api_token_allowed(&Method::POST, "/api/geoip/update", "rw")); - assert!(!general_api_token_allowed(&Method::GET, "/api/config/geoip/update", "rw")); + assert!(general_api_token_allowed( + &Method::GET, + "/api/config/geoip", + "ro" + )); + assert!(general_api_token_allowed( + &Method::PUT, + "/api/config/geoip", + "rw" + )); + assert!(!general_api_token_allowed( + &Method::PUT, + "/api/config/geoip", + "ro" + )); + assert!(general_api_token_allowed( + &Method::POST, + "/api/config/geoip/update", + "rw" + )); + assert!(!general_api_token_allowed( + &Method::POST, + "/api/config/geoip/update", + "ro" + )); + assert!(!general_api_token_allowed( + &Method::POST, + "/api/geoip/update", + "rw" + )); + assert!(!general_api_token_allowed( + &Method::GET, + "/api/config/geoip/update", + "rw" + )); } #[tokio::test] @@ -736,9 +817,16 @@ mod tests { ("/api/config/geoip/update", StatusCode::METHOD_NOT_ALLOWED), ("/api/geoip/update", StatusCode::NOT_FOUND), ] { - let response = router.clone().oneshot( - request(path).header("cookie", &cookie).body(Body::empty()).unwrap(), - ).await.unwrap(); + let response = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), expected, "{path}"); } store.close().await; @@ -813,12 +901,10 @@ mod tests { .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( - serde_json::to_vec( - &json!({"expected_revision":0,"value":{ - "logging":Config::default().logging, - "error_log":Config::default().error_log - }}), - ) + serde_json::to_vec(&json!({"expected_revision":0,"value":{ + "logging":Config::default().logging, + "error_log":Config::default().error_log + }})) .unwrap(), )) .unwrap(), @@ -846,7 +932,6 @@ mod tests { store.close().await; } - #[tokio::test] async fn global_security_section_saves_rules_and_existing_ua_atomically() { let (router, _store, _dir, secret, csrf) = fixture().await; @@ -858,53 +943,81 @@ mod tests { "user_agent_blocking": {"enabled":true, "patterns":["bad-bot"]} }); let body = serde_json::to_vec(&json!({"expected_revision":0, "value":value})).unwrap(); - let response = router.clone().oneshot( - request("/api/config/global-security") - .method("PUT") - .header("content-type", "application/json") - .header("cookie", &cookie) - .header("origin", "http://127.0.0.1:9090") - .header("x-rustproxy-request", "1") - .header("x-csrf-token", &csrf) - .body(Body::from(body)).unwrap(), - ).await.unwrap(); + let response = router + .clone() + .oneshot( + request("/api/config/global-security") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let json = json_body(response).await; assert_eq!(json["revision"], 1); - assert_eq!(json["config"]["global_security"]["country_access"]["countries"][0], "RU"); - assert_eq!(json["config"]["global_security"]["security_headers"]["nosniff"], true); - assert_eq!(json["config"]["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot"); + assert_eq!( + json["config"]["global_security"]["country_access"]["countries"][0], + "RU" + ); + assert_eq!( + json["config"]["global_security"]["security_headers"]["nosniff"], + true + ); + assert_eq!( + json["config"]["global_security"]["user_agent_blocking"]["patterns"][0], + "bad-bot" + ); assert!(json["config"].get("user_agent_blocking").is_none()); // A partial section update must not silently reset the existing UA policy. let body = serde_json::to_vec(&json!({ "expected_revision":1, "value": {"method_policy":{"mode":"off","methods":[]}} - })).unwrap(); - let response = router.clone().oneshot( - request("/api/config/global-security") - .method("PUT") - .header("content-type", "application/json") - .header("cookie", &cookie) - .header("origin", "http://127.0.0.1:9090") - .header("x-rustproxy-request", "1") - .header("x-csrf-token", &csrf) - .body(Body::from(body)).unwrap(), - ).await.unwrap(); + })) + .unwrap(); + let response = router + .clone() + .oneshot( + request("/api/config/global-security") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); // GET returns the same standalone section shape accepted by PUT, at the // matching revision and without unrequested configuration fields. - let read = router.clone().oneshot( - request("/api/config/global-security") - .header("cookie", &cookie) - .body(Body::empty()).unwrap(), - ).await.unwrap(); + let read = router + .clone() + .oneshot( + request("/api/config/global-security") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(read.status(), StatusCode::OK); let view = json_body(read).await; assert_eq!(view["revision"], 1); assert_eq!(view["section"], "global-security"); - assert_eq!(view["value"]["user_agent_blocking"]["patterns"][0], "bad-bot"); + assert_eq!( + view["value"]["user_agent_blocking"]["patterns"][0], + "bad-bot" + ); assert!(view.get("config").is_none()); assert!(view["value"].get("global_security").is_none()); } diff --git a/src/admin/web/css/10-base.css b/src/admin/web/css/10-base.css index 366026b..c8234c6 100644 --- a/src/admin/web/css/10-base.css +++ b/src/admin/web/css/10-base.css @@ -1505,8 +1505,22 @@ html[data-table-density=ultra] :is(table th, table td) { gap: 6px; max-width: 260px; } + /* Permission guidance belongs to its generated command block, not the path inputs. */ -#certificate-external-acl-example.path-permissions { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 15px; } -.path-permissions-commands { margin-bottom: 0; } -.path-permissions-commands + .path-permissions-description { margin: 7px 0 0; } -.path-permissions-actions { margin-top: 10px; } +#certificate-external-acl-example.path-permissions { + margin-top: 20px; + border-top: 1px solid var(--line); + padding-top: 15px; +} + +.path-permissions-commands { + margin-bottom: 0; +} + +.path-permissions-commands+.path-permissions-description { + margin: 7px 0 0; +} + +.path-permissions-actions { + margin-top: 10px; +} \ No newline at end of file diff --git a/src/admin/web/css/20-shell.css b/src/admin/web/css/20-shell.css index 44ea84e..a005894 100644 --- a/src/admin/web/css/20-shell.css +++ b/src/admin/web/css/20-shell.css @@ -770,4 +770,4 @@ body[data-density=compact] .page-intro .lead { to { visibility: visible; } -} +} \ No newline at end of file diff --git a/src/backup/archive.rs b/src/backup/archive.rs index 035b03f..b2dfccc 100644 --- a/src/backup/archive.rs +++ b/src/backup/archive.rs @@ -231,8 +231,12 @@ fn derive_key(password: &str, salt: &[u8]) -> Result> { fn new_crypto(password: &str) -> Result { let mut salt = [0u8; 16]; let mut nonce_prefix = [0u8; 4]; - SysRng.try_fill_bytes(&mut salt).expect("secure operating system random source unavailable"); - SysRng.try_fill_bytes(&mut nonce_prefix).expect("secure operating system random source unavailable"); + SysRng + .try_fill_bytes(&mut salt) + .expect("secure operating system random source unavailable"); + SysRng + .try_fill_bytes(&mut nonce_prefix) + .expect("secure operating system random source unavailable"); let header = CryptoHeader { format_version: FORMAT_VERSION, kdf: "Argon2id".into(), @@ -395,7 +399,9 @@ fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result Result> { ensure!(master.len() == 32, "invalid secret store master key"); let mut nonce_bytes = [0u8; 12]; - SysRng.try_fill_bytes(&mut nonce_bytes).expect("secure operating system random source unavailable"); + SysRng + .try_fill_bytes(&mut nonce_bytes) + .expect("secure operating system random source unavailable"); let cipher = cipher(backup_key)?; let mut encrypted = Zeroizing::new(master.to_vec()); cipher @@ -462,13 +468,6 @@ fn hash_bytes(bytes: &[u8]) -> String { fn excluded_directory(name: &str) -> bool { matches!( name, - "cache" - | "logs" - | "tmp" - | "temp" - | "work" - | "acme-http01" - | "acme-python" - | "acme_python" + "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01" | "acme-python" | "acme_python" ) } diff --git a/src/backup/tests.rs b/src/backup/tests.rs index cb70042..0f7e5d3 100644 --- a/src/backup/tests.rs +++ b/src/backup/tests.rs @@ -14,7 +14,9 @@ mod tests { let crypto = new_crypto("correct horse battery staple").unwrap(); encrypt_archive(&input, &encrypted, &crypto).unwrap(); let error = decrypt_archive(&encrypted, &wrong_output, "wrong password").unwrap_err(); - assert!(error.to_string().contains("incorrect backup password or corrupted archive")); + assert!(error + .to_string() + .contains("incorrect backup password or corrupted archive")); decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap(); assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); @@ -24,7 +26,9 @@ mod tests { fs::write(&tampered, bytes).unwrap(); let error = decrypt_archive(&tampered, &tampered_output, "correct horse battery staple") .unwrap_err(); - assert!(error.to_string().contains("incorrect backup password or corrupted archive")); + assert!(error + .to_string() + .contains("incorrect backup password or corrupted archive")); assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); } @@ -98,7 +102,6 @@ mod tests { assert_eq!(fs::read(restored_link).unwrap(), b"certificate-v1"); } - #[cfg(unix)] #[test] fn restore_owner_is_applied_recursively() { diff --git a/src/backup/validation.rs b/src/backup/validation.rs index 51ddc43..eda2b65 100644 --- a/src/backup/validation.rs +++ b/src/backup/validation.rs @@ -426,6 +426,8 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result< pub fn generated_password() -> String { let mut bytes = [0u8; 32]; - SysRng.try_fill_bytes(&mut bytes).expect("secure operating system random source unavailable"); + SysRng + .try_fill_bytes(&mut bytes) + .expect("secure operating system random source unavailable"); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } diff --git a/src/cache/cold/startup.rs b/src/cache/cold/startup.rs index 976ec14..6ab1ccc 100644 --- a/src/cache/cold/startup.rs +++ b/src/cache/cold/startup.rs @@ -70,9 +70,13 @@ impl Cache { continue; }; let body_path = shard.join(&file.body_file); - let expected_scope_revision = self.scope_revisions - .lock().unwrap_or_else(|e| e.into_inner()) - .get(&file.scope).copied().unwrap_or(0); + let expected_scope_revision = self + .scope_revisions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&file.scope) + .copied() + .unwrap_or(0); if file.format != COLD_FORMAT_VERSION || file.revision != revision || file.scope_revision != expected_scope_revision @@ -142,57 +146,60 @@ impl Cache { let _ = std::fs::remove_file(&body_path); continue; } - loaded.push((key, ColdEntry { - base_key, - vary, - revision, - scope: file.scope, - scheme: file.scheme, - authority: if file.authority.is_empty() { - file.domain.clone() - } else { - file.authority + loaded.push(( + key, + ColdEntry { + base_key, + vary, + revision, + scope: file.scope, + scheme: file.scheme, + authority: if file.authority.is_empty() { + file.domain.clone() + } else { + file.authority + }, + domain: file.domain, + path: file.path.clone(), + target: if file.target.is_empty() { + file.path + } else { + file.target + }, + tags: file.tags, + created_at: if file.created_at_ms == 0 { + stored_at + } else { + system_time_ms(file.created_at_ms) + }, + body_path, + meta_path: path, + body_bytes: file.body_bytes, + body_sha256, + disk_bytes: body_meta.len().saturating_add(bytes.len() as u64), + stored_at, + last_access: if file.last_access_ms == 0 { + stored_at + } else { + system_time_ms(file.last_access_ms) + }, + age_at_store_seconds: file.age_at_store_seconds, + resident_age_at_store_seconds: file.resident_age_at_store_seconds, + ttl: file.ttl, + stale_while_revalidate: file.stale_while_revalidate, + stale_if_error: file.stale_if_error, + must_revalidate: file.must_revalidate, + proxy_revalidate: file.proxy_revalidate, + has_validator: origin_etag.is_some() || origin_last_modified.is_some(), + hits: file.hits, + transformed: file.transformed, + sequence: 0, + generation: 0, + skip_compression: file.skip_compression, + requires_revalidation: file.requires_revalidation, + pinned_reads: 0, }, - domain: file.domain, - path: file.path.clone(), - target: if file.target.is_empty() { - file.path - } else { - file.target - }, - tags: file.tags, - created_at: if file.created_at_ms == 0 { - stored_at - } else { - system_time_ms(file.created_at_ms) - }, - body_path, - meta_path: path, - body_bytes: file.body_bytes, - body_sha256, - disk_bytes: body_meta.len().saturating_add(bytes.len() as u64), - stored_at, - last_access: if file.last_access_ms == 0 { - stored_at - } else { - system_time_ms(file.last_access_ms) - }, - age_at_store_seconds: file.age_at_store_seconds, - resident_age_at_store_seconds: file.resident_age_at_store_seconds, - ttl: file.ttl, - stale_while_revalidate: file.stale_while_revalidate, - stale_if_error: file.stale_if_error, - must_revalidate: file.must_revalidate, - proxy_revalidate: file.proxy_revalidate, - has_validator: origin_etag.is_some() || origin_last_modified.is_some(), - hits: file.hits, - transformed: file.transformed, - sequence: 0, - generation: 0, - skip_compression: file.skip_compression, - requires_revalidation: file.requires_revalidation, - pinned_reads: 0, - })); + )); } } loaded.sort_by_key(|(_, entry)| entry.last_access); diff --git a/src/cache/dashboard_tests.rs b/src/cache/dashboard_tests.rs index 8b44c9a..f9bd25a 100644 --- a/src/cache/dashboard_tests.rs +++ b/src/cache/dashboard_tests.rs @@ -427,7 +427,12 @@ mod dashboard_tests { ); let mut headers = HeaderMap::new(); settings.annotate_reason_transformed_freshness( - &mut headers, "HIT", 3, -7, "cache_max_stale", false, + &mut headers, + "HIT", + 3, + -7, + "cache_max_stale", + false, ); assert_eq!( headers["cache-status"], @@ -435,7 +440,12 @@ mod dashboard_tests { ); let mut headers = HeaderMap::new(); settings.annotate_reason_transformed_freshness( - &mut headers, "HIT", 3, -5, "cache_stale_while_revalidate", false, + &mut headers, + "HIT", + 3, + -5, + "cache_stale_while_revalidate", + false, ); assert_eq!( headers["cache-status"], @@ -453,20 +463,38 @@ mod dashboard_tests { let mut headers = HeaderMap::new(); headers.append("cache-status", HeaderValue::from_static("OriginCache; hit")); settings.annotate_reason_transformed_forwarded( - &mut headers, "HIT", 3, 0, "cache_stale_if_error", false, - "stale", Some(StatusCode::SERVICE_UNAVAILABLE), + &mut headers, + "HIT", + 3, + 0, + "cache_stale_if_error", + false, + "stale", + Some(StatusCode::SERVICE_UNAVAILABLE), ); assert_eq!(headers["x-cache"], "HIT"); - let values: Vec<_> = headers.get_all("cache-status").iter() - .map(|value| value.to_str().unwrap()).collect(); - assert_eq!(values, vec![ - "OriginCache; hit", - "RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"", - ]); + let values: Vec<_> = headers + .get_all("cache-status") + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); + assert_eq!( + values, + vec![ + "OriginCache; hit", + "RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"", + ] + ); let mut headers = HeaderMap::new(); settings.annotate_reason_transformed_forwarded( - &mut headers, "HIT", 2, 42, "cache_revalidated", false, - "request", Some(StatusCode::NOT_MODIFIED), + &mut headers, + "HIT", + 2, + 42, + "cache_revalidated", + false, + "request", + Some(StatusCode::NOT_MODIFIED), ); assert_eq!( headers["cache-status"], @@ -474,8 +502,14 @@ mod dashboard_tests { ); let mut headers = HeaderMap::new(); settings.annotate_reason_transformed_forwarded( - &mut headers, "HIT", 2, 0, "cache_stale_if_error", false, - "stale", None, + &mut headers, + "HIT", + 2, + 0, + "cache_stale_if_error", + false, + "stale", + None, ); assert_eq!( headers["cache-status"], diff --git a/src/cache/model/entry.rs b/src/cache/model/entry.rs index 813a775..b7d2772 100644 --- a/src/cache/model/entry.rs +++ b/src/cache/model/entry.rs @@ -182,7 +182,15 @@ impl Settings { ttl: u64, reason: &'static str, ) { - self.annotate_internal(headers, outcome, hits, ttl as i64, Some(reason), false, None); + self.annotate_internal( + headers, + outcome, + hits, + ttl as i64, + Some(reason), + false, + None, + ); } pub fn annotate_reason_transformed( @@ -194,7 +202,15 @@ impl Settings { reason: &'static str, transformed: bool, ) { - self.annotate_internal(headers, outcome, hits, ttl as i64, Some(reason), transformed, None); + self.annotate_internal( + headers, + outcome, + hits, + ttl as i64, + Some(reason), + transformed, + None, + ); } pub fn annotate_reason_transformed_freshness( @@ -207,12 +223,16 @@ impl Settings { transformed: bool, ) { self.annotate_internal( - headers, outcome, hits, freshness_ttl, Some(reason), transformed, None, + headers, + outcome, + hits, + freshness_ttl, + Some(reason), + transformed, + None, ); } - // RFC 9211 distinguishes whether this request was forwarded from whether - // the legacy X-Cache metric counted the reused representation as a HIT. pub fn annotate_reason_transformed_forwarded( &self, headers: &mut HeaderMap, @@ -285,8 +305,11 @@ impl Settings { } if self.expose_cache_status { let forwarded = forwarded.or_else(|| { - matches!(reason, Some("cache_revalidated" | "cache_revalidated_uncached")) - .then_some(("stale", Some(StatusCode::NOT_MODIFIED))) + matches!( + reason, + Some("cache_revalidated" | "cache_revalidated_uncached") + ) + .then_some(("stale", Some(StatusCode::NOT_MODIFIED))) }); let mut value = if let Some((why, status)) = forwarded { let mut value = format!("RustProxy; fwd={why}"); @@ -296,8 +319,6 @@ impl Settings { value } else { match outcome { - // RFC 9211 permits a negative ttl when stale data is served - // without sending the current request to the origin. "HIT" => format!("RustProxy; hit; ttl={ttl}"), "MISS" => "RustProxy; fwd=miss".to_owned(), "BYPASS" if reason == Some("request_method") => { diff --git a/src/cache/model/maintenance.rs b/src/cache/model/maintenance.rs index 1339a08..d778379 100644 --- a/src/cache/model/maintenance.rs +++ b/src/cache/model/maintenance.rs @@ -126,7 +126,7 @@ impl Drop for ColdReadGuard<'_> { } } } -// RFC 9211 uses signed structured-field integers for freshness (negative when stale). + fn cache_status_ttl(ttl: u64, age: u64) -> i64 { const MAX: i128 = 999_999_999_999_999; (i128::from(ttl) - i128::from(age)).clamp(-MAX, MAX) as i64 diff --git a/src/cache/model/settings.rs b/src/cache/model/settings.rs index c14a18a..e7f8c04 100644 --- a/src/cache/model/settings.rs +++ b/src/cache/model/settings.rs @@ -87,9 +87,7 @@ pub enum ColdPreset { #[serde(default, deny_unknown_fields)] pub struct Settings { pub enabled: bool, - /// Ignore client freshness directives on cache lookups, like Varnish builtin VCL. pub varnish_compatible: bool, - /// Implicit grace in seconds for eligible fresh-at-storage cached responses. pub varnish_grace_seconds: u64, pub memory_preset: Option, pub max_bytes: usize, diff --git a/src/cache/model/views.rs b/src/cache/model/views.rs index 8a6a7cd..6fb4b44 100644 --- a/src/cache/model/views.rs +++ b/src/cache/model/views.rs @@ -133,8 +133,7 @@ fn stale_mode( } let stale_for = age.saturating_sub(ttl); let max_stale = client_max_stale(request_headers); - // An explicit client limit must also constrain implicit Varnish grace. - // In particular, max-stale=0 must not serve a stale representation. + if matches!(max_stale, Some(Some(limit)) if stale_for > limit) { return None; } diff --git a/src/cache/policy/request.rs b/src/cache/policy/request.rs index 691cbdb..9905491 100644 --- a/src/cache/policy/request.rs +++ b/src/cache/policy/request.rs @@ -44,9 +44,7 @@ fn directives(headers: &HeaderMap) -> Option>> { } Some(result) } -/// Keep all client cache restrictions except the hard-reload directives that -/// Varnish normally disregards for a fresh shared object. Never mutate the -/// actual request (it is still forwarded to the origin and used for Vary). + fn compatible_freshness_headers<'a>( headers: &'a HeaderMap, compatible: bool, @@ -54,29 +52,31 @@ fn compatible_freshness_headers<'a>( if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) { return std::borrow::Cow::Borrowed(headers); } - // An invalid cache-control header cannot be made valid by filtering it. - // Also avoid cloning the full map for ordinary requests with no reload - // directives, including min-fresh, max-stale and only-if-cached. + let Some(control) = directives(headers) else { return std::borrow::Cow::Borrowed(headers); }; let remove_reload = control.contains_key("no-cache") - || control.get("max-age").and_then(|value| value.as_deref()) - .and_then(|value| value.parse::().ok()) == Some(0); + || control + .get("max-age") + .and_then(|value| value.as_deref()) + .and_then(|value| value.parse::().ok()) + == Some(0); if !remove_reload && !pragma_no_cache(headers) { return std::borrow::Cow::Borrowed(headers); } let mut freshness = headers.clone(); + let mut changed = false; + if pragma_no_cache(headers) { - // Pragma only affects freshness through its `no-cache` token in this - // implementation. Preserve other tokens, if any. freshness.remove("pragma"); for header in headers.get_all("pragma") { let Ok(text) = header.to_str() else { return std::borrow::Cow::Borrowed(headers); }; - let retained = text.split(',') + let retained = text + .split(',') .map(str::trim) .filter(|token| !token.eq_ignore_ascii_case("no-cache")) .collect::>(); @@ -95,15 +95,21 @@ fn compatible_freshness_headers<'a>( let Ok(text) = header.to_str() else { return std::borrow::Cow::Borrowed(headers); }; - let retained = text.split(',').map(str::trim).filter(|token| { - let (name, value) = token.split_once('=').unwrap_or((token, "")); - let name = name.trim(); - let skip = name.eq_ignore_ascii_case("no-cache") - || (name.eq_ignore_ascii_case("max-age") - && value.trim().trim_matches('"').parse::() == Ok(0)); - if skip { changed = true; } - !skip - }).collect::>(); + let retained = text + .split(',') + .map(str::trim) + .filter(|token| { + let (name, value) = token.split_once('=').unwrap_or((token, "")); + let name = name.trim(); + let skip = name.eq_ignore_ascii_case("no-cache") + || (name.eq_ignore_ascii_case("max-age") + && value.trim().trim_matches('"').parse::() == Ok(0)); + if skip { + changed = true; + } + !skip + }) + .collect::>(); if !retained.is_empty() { let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else { return std::borrow::Cow::Borrowed(headers); @@ -118,8 +124,11 @@ fn compatible_freshness_headers<'a>( } } } - if changed { std::borrow::Cow::Owned(freshness) } - else { std::borrow::Cow::Borrowed(headers) } + if changed { + std::borrow::Cow::Owned(freshness) + } else { + std::borrow::Cow::Borrowed(headers) + } } pub fn request_freshness_headers<'a>( @@ -243,8 +252,7 @@ pub fn request_policy( return RequestPolicy::Bypass(reason); } } - // Compatibility only relaxes browser hard-reload freshness requests. - // Retain no-store, only-if-cached, min-fresh and max-stale semantics. + let Some(control) = directives(headers) else { return RequestPolicy::Bypass("request_cache_control_invalid"); }; diff --git a/src/cache/policy/response.rs b/src/cache/policy/response.rs index f884f2b..91ef6ce 100644 --- a/src/cache/policy/response.rs +++ b/src/cache/policy/response.rs @@ -127,24 +127,23 @@ pub fn plan( Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?, None => now, }; - let declared_ttl = - if override_public_no_cache { - Some(settings.default_ttl_seconds) - } else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) { - Some(value.as_ref()?.parse::().ok()?) - } else if let Some(expires) = headers.get("expires") { - Some( - httpdate::parse_http_date(expires.to_str().ok()?) - .ok()? - .duration_since(date) - .ok()? - .as_secs(), - ) - } else if control.contains_key("public") { - Some(settings.default_ttl_seconds) - } else { - None - }; + let declared_ttl = if override_public_no_cache { + Some(settings.default_ttl_seconds) + } else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) { + Some(value.as_ref()?.parse::().ok()?) + } else if let Some(expires) = headers.get("expires") { + Some( + httpdate::parse_http_date(expires.to_str().ok()?) + .ok()? + .duration_since(date) + .ok()? + .as_secs(), + ) + } else if control.contains_key("public") { + Some(settings.default_ttl_seconds) + } else { + None + }; let age = match headers.get("age") { Some(v) => v.to_str().ok()?.parse::().ok()?, None => 0, diff --git a/src/cache/policy/scope_tests.rs b/src/cache/policy/scope_tests.rs index 844509e..39776a6 100644 --- a/src/cache/policy/scope_tests.rs +++ b/src/cache/policy/scope_tests.rs @@ -58,14 +58,29 @@ mod scope_tests { fn stale_snapshot_cannot_issue_cache_ticket_after_config_commit() { let cache = Cache::new(1); cache.set_control_revision(7); - let ticket = |revision| cache.ticket_scoped_for_config( - revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new() - ); + let ticket = |revision| { + cache.ticket_scoped_for_config( + revision, + 1, + "vhost-a", + "http", + "same.test", + "main", + "/resource", + &HeaderMap::new(), + ) + }; assert!(ticket(7).is_some(), "the current snapshot can cache"); cache.set_control_revision(8); assert_eq!(cache.revision(), 1); - assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass"); - assert!(ticket(8).is_some(), "the new snapshot can reuse the same cache identity"); + assert!( + ticket(7).is_none(), + "late request from the previous snapshot must bypass" + ); + assert!( + ticket(8).is_some(), + "the new snapshot can reuse the same cache identity" + ); } #[test] @@ -82,18 +97,30 @@ mod scope_tests { assert!(effective.enabled && effective.expose_status); let mut globally_disabled = global.clone(); globally_disabled.enabled = false; - assert!(!Overrides { enabled: Some(true), ..Overrides::default() } + assert!( + !Overrides { + enabled: Some(true), + ..Overrides::default() + } .resolve(&globally_disabled) .unwrap() - .enabled); - assert!(!Overrides { enabled: Some(false), ..Overrides::default() } + .enabled + ); + assert!( + !Overrides { + enabled: Some(false), + ..Overrides::default() + } .resolve(&global) .unwrap() - .enabled); - assert!(!Overrides::default() - .resolve(&globally_disabled) - .unwrap() - .enabled); + .enabled + ); + assert!( + !Overrides::default() + .resolve(&globally_disabled) + .unwrap() + .enabled + ); assert_eq!(effective.max_bytes, global.max_bytes); assert_eq!(effective.max_ttl_seconds, 30); assert!(Overrides { diff --git a/src/cache/tickets.rs b/src/cache/tickets.rs index 43662d2..b002ec5 100644 --- a/src/cache/tickets.rs +++ b/src/cache/tickets.rs @@ -408,6 +408,8 @@ impl Ticket { } fn freshness_headers(&self) -> &HeaderMap { - self.freshness_headers.as_ref().unwrap_or(&self.request_headers) + self.freshness_headers + .as_ref() + .unwrap_or(&self.request_headers) } } diff --git a/src/cache/varnish_compatible_tests.rs b/src/cache/varnish_compatible_tests.rs index 1cb00eb..4ef1d87 100644 --- a/src/cache/varnish_compatible_tests.rs +++ b/src/cache/varnish_compatible_tests.rs @@ -4,7 +4,10 @@ mod varnish_compatible_tests { fn headers(cache_control: &str) -> HeaderMap { let mut h = HeaderMap::new(); - h.insert("cache-control", HeaderValue::from_str(cache_control).unwrap()); + h.insert( + "cache-control", + HeaderValue::from_str(cache_control).unwrap(), + ); h } @@ -24,7 +27,10 @@ mod varnish_compatible_tests { assert_eq!(customized.varnish_grace_seconds, 17); let mut invalid = customized.clone(); invalid.varnish_grace_seconds = 604_801; - assert!(invalid.validate().is_err(), "oversized grace must be rejected"); + assert!( + invalid.validate().is_err(), + "oversized grace must be rejected" + ); } #[test] @@ -32,18 +38,58 @@ mod varnish_compatible_tests { let mut compatible = Settings::default(); for value in ["no-cache", "max-age=0", "max-age=\"0\"", "max-age=000"] { let h = headers(value); - assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use); + assert_eq!( + request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), + RequestPolicy::Use + ); let freshness = request_freshness_headers(&h, &compatible); - assert!(!freshness.contains_key("cache-control"), "hard reload must not constrain lookup: {value}"); - assert!(h.contains_key("cache-control"), "origin request and Vary must remain unchanged"); + assert!( + !freshness.contains_key("cache-control"), + "hard reload must not constrain lookup: {value}" + ); + assert!( + h.contains_key("cache-control"), + "origin request and Vary must remain unchanged" + ); } - for value in ["max-age=1", "min-fresh=600", "max-stale=9999", "only-if-cached"] { + for value in [ + "max-age=1", + "min-fresh=600", + "max-stale=9999", + "only-if-cached", + ] { let h = headers(value); - assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use); - assert!(request_freshness_headers(&h, &compatible).contains_key("cache-control"), "preserve {value}"); + assert_eq!( + request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), + RequestPolicy::Use + ); + assert!( + request_freshness_headers(&h, &compatible).contains_key("cache-control"), + "preserve {value}" + ); } - assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store")); - assert_eq!(request_policy(&Method::GET, &headers("invalid directive"), true, "/", false, &compatible), RequestPolicy::Bypass("request_cache_control_invalid")); + assert_eq!( + request_policy( + &Method::GET, + &headers("no-store"), + true, + "/", + false, + &compatible + ), + RequestPolicy::Bypass("request_no_store") + ); + assert_eq!( + request_policy( + &Method::GET, + &headers("invalid directive"), + true, + "/", + false, + &compatible + ), + RequestPolicy::Bypass("request_cache_control_invalid") + ); assert!(only_if_cached(&headers("only-if-cached"))); let h = headers("no-cache, max-age=0, min-fresh=45, max-stale=90, only-if-cached"); let freshness = request_freshness_headers(&h, &compatible); @@ -55,12 +101,48 @@ mod varnish_compatible_tests { assert!(parsed.contains_key("only-if-cached")); let mut pragma = HeaderMap::new(); pragma.insert("pragma", HeaderValue::from_static("no-cache, custom-token")); - assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use); - assert_eq!(request_freshness_headers(&pragma, &compatible)["pragma"], "custom-token"); + assert_eq!( + request_policy(&Method::GET, &pragma, true, "/", false, &compatible), + RequestPolicy::Use + ); + assert_eq!( + request_freshness_headers(&pragma, &compatible)["pragma"], + "custom-token" + ); compatible.varnish_compatible = false; - assert_eq!(request_policy(&Method::GET, &headers("no-cache"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_no_cache")); - assert_eq!(request_policy(&Method::GET, &headers("max-age=0"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_max_age")); - assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store")); + assert_eq!( + request_policy( + &Method::GET, + &headers("no-cache"), + true, + "/", + false, + &compatible + ), + RequestPolicy::Revalidate("request_no_cache") + ); + assert_eq!( + request_policy( + &Method::GET, + &headers("max-age=0"), + true, + "/", + false, + &compatible + ), + RequestPolicy::Revalidate("request_max_age") + ); + assert_eq!( + request_policy( + &Method::GET, + &headers("no-store"), + true, + "/", + false, + &compatible + ), + RequestPolicy::Bypass("request_no_store") + ); } #[test] @@ -73,16 +155,55 @@ mod varnish_compatible_tests { ("upgrade", "websocket"), ] { let mut h = headers("no-cache"); - h.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap()); - assert!(matches!(request_policy(&Method::GET, &h, true, "/index.html", false, &settings), RequestPolicy::Bypass(_))); + h.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + assert!(matches!( + request_policy(&Method::GET, &h, true, "/index.html", false, &settings), + RequestPolicy::Bypass(_) + )); } settings.bypass_query = true; - assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/style.css", true, &settings), RequestPolicy::Bypass("query_excluded")); + assert_eq!( + request_policy( + &Method::GET, + &HeaderMap::new(), + true, + "/style.css", + true, + &settings + ), + RequestPolicy::Bypass("query_excluded") + ); settings.bypass_query = false; settings.bypass_prefixes = vec!["/private".into()]; - assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/private/account", false, &settings), RequestPolicy::Bypass("path_excluded")); - assert_eq!(request_policy(&Method::POST, &HeaderMap::new(), true, "/style.css", false, &settings), RequestPolicy::Bypass("request_method")); - assert!(settings.ignore_cookie_for_path("/style.css"), "existing static cookie feature remains enabled"); + assert_eq!( + request_policy( + &Method::GET, + &HeaderMap::new(), + true, + "/private/account", + false, + &settings + ), + RequestPolicy::Bypass("path_excluded") + ); + assert_eq!( + request_policy( + &Method::POST, + &HeaderMap::new(), + true, + "/style.css", + false, + &settings + ), + RequestPolicy::Bypass("request_method") + ); + assert!( + settings.ignore_cookie_for_path("/style.css"), + "existing static cookie feature remains enabled" + ); assert!(!settings.ignore_cookie_for_path("/index.html")); } @@ -92,7 +213,13 @@ mod varnish_compatible_tests { let mut strict = compatible.clone(); strict.varnish_compatible = false; for control in ["private, max-age=60", "no-store, max-age=60"] { - assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none()); + assert!(plan( + StatusCode::OK, + &headers(control), + &compatible, + Duration::ZERO + ) + .is_none()); assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_none()); } let mut no_cache = headers("no-cache, max-age=60"); @@ -101,7 +228,13 @@ mod varnish_compatible_tests { .expect("no-cache with ETag remains revalidatable without fallback"); assert!(validator_plan.requires_revalidation); assert!(plan(StatusCode::OK, &no_cache, &strict, Duration::ZERO).is_some()); - assert!(plan(StatusCode::OK, &headers("no-cache, max-age=60"), &compatible, Duration::ZERO).is_none()); + assert!(plan( + StatusCode::OK, + &headers("no-cache, max-age=60"), + &compatible, + Duration::ZERO + ) + .is_none()); let mut fallback = compatible.clone(); fallback.default_ttl_seconds = 42; @@ -111,21 +244,48 @@ mod varnish_compatible_tests { assert_eq!(fallback_plan.ttl, 42); assert!(!fallback_plan.requires_revalidation); let nonpublic_no_cache = headers("no-cache, max-age=60"); - assert!(plan(StatusCode::OK, &nonpublic_no_cache, &fallback, Duration::ZERO).is_none()); + assert!(plan( + StatusCode::OK, + &nonpublic_no_cache, + &fallback, + Duration::ZERO + ) + .is_none()); let mut surrogate = public_no_cache.clone(); - surrogate.insert("surrogate-control", HeaderValue::from_static("max-age=120; NO-STORE")); + surrogate.insert( + "surrogate-control", + HeaderValue::from_static("max-age=120; NO-STORE"), + ); assert!(plan(StatusCode::OK, &surrogate, &fallback, Duration::ZERO).is_none()); - assert_eq!(response_reason(StatusCode::OK, &surrogate, &fallback), "response_surrogate_control"); - assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(), "strict no-cache without ETag remains uncached"); + assert_eq!( + response_reason(StatusCode::OK, &surrogate, &fallback), + "response_surrogate_control" + ); + assert!( + plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(), + "strict no-cache without ETag remains uncached" + ); let mut private = headers("public, private, no-cache"); private.insert("etag", HeaderValue::from_static("\"secret\"")); assert!(plan(StatusCode::OK, &private, &fallback, Duration::ZERO).is_none()); - for control in ["max-age=0", "max-age=60, s-maxage=0", "max-age=0, stale-if-error=120"] { - let p = plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO) - .expect("zero TTL is retained for max-stale, validation or stale-if-error"); + for control in [ + "max-age=0", + "max-age=60, s-maxage=0", + "max-age=0, stale-if-error=120", + ] { + let p = plan( + StatusCode::OK, + &headers(control), + &compatible, + Duration::ZERO, + ) + .expect("zero TTL is retained for max-stale, validation or stale-if-error"); assert_eq!(p.ttl, 0, "{control}"); - assert_eq!(p.stale_while_revalidate, 0, "zero TTL must not receive implicit grace"); + assert_eq!( + p.stale_while_revalidate, 0, + "zero TTL must not receive implicit grace" + ); } let mut cookie = headers("max-age=60"); cookie.insert("set-cookie", HeaderValue::from_static("session=private")); @@ -138,24 +298,66 @@ mod varnish_compatible_tests { #[test] fn explicit_zero_grace_and_stale_if_error_are_preserved() { let compatible = Settings::default(); - let with_zero = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=0"), &compatible, Duration::ZERO).unwrap(); + let with_zero = plan( + StatusCode::OK, + &headers("public, max-age=60, stale-while-revalidate=0"), + &compatible, + Duration::ZERO, + ) + .unwrap(); assert_eq!(with_zero.stale_while_revalidate, 0); - let with_grace = plan(StatusCode::OK, &headers("public, max-age=60"), &compatible, Duration::ZERO).unwrap(); + let with_grace = plan( + StatusCode::OK, + &headers("public, max-age=60"), + &compatible, + Duration::ZERO, + ) + .unwrap(); assert_eq!(with_grace.stale_while_revalidate, 60); let mut custom_settings = compatible.clone(); custom_settings.varnish_grace_seconds = 25; - let with_custom_default = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); + let with_custom_default = plan( + StatusCode::OK, + &headers("public, max-age=60"), + &custom_settings, + Duration::ZERO, + ) + .unwrap(); assert_eq!(with_custom_default.stale_while_revalidate, 25); custom_settings.varnish_grace_seconds = 0; - let grace_disabled = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); + let grace_disabled = plan( + StatusCode::OK, + &headers("public, max-age=60"), + &custom_settings, + Duration::ZERO, + ) + .unwrap(); assert_eq!(grace_disabled.stale_while_revalidate, 0); custom_settings.varnish_compatible = false; custom_settings.varnish_grace_seconds = 60; - let strict = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); + let strict = plan( + StatusCode::OK, + &headers("public, max-age=60"), + &custom_settings, + Duration::ZERO, + ) + .unwrap(); assert_eq!(strict.stale_while_revalidate, 0); - let with_custom = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=30"), &compatible, Duration::ZERO).unwrap(); + let with_custom = plan( + StatusCode::OK, + &headers("public, max-age=60, stale-while-revalidate=30"), + &compatible, + Duration::ZERO, + ) + .unwrap(); assert_eq!(with_custom.stale_while_revalidate, 30); - let zero = plan(StatusCode::OK, &headers("public, max-age=0, stale-if-error=120"), &compatible, Duration::ZERO).unwrap(); + let zero = plan( + StatusCode::OK, + &headers("public, max-age=0, stale-if-error=120"), + &compatible, + Duration::ZERO, + ) + .unwrap(); assert_eq!(zero.ttl, 0); assert_eq!(zero.stale_if_error, 120); assert_eq!(zero.stale_while_revalidate, 0); @@ -165,17 +367,47 @@ mod varnish_compatible_tests { async fn origin_no_cache_etag_can_still_revalidate_without_fallback() { let cache = Cache::new(1); let settings = Settings::default(); - let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/backend.css", + &HeaderMap::new(), + ) + .unwrap(); let mut response = Response::new(Body::from("etag css")); *response.headers_mut() = headers("public, no-cache, max-age=600"); - response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\"")); - let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin-v1\"")); + let p = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); assert!(p.requires_revalidation); - cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); - assert!(cache.get(&ticket).is_none(), "backend no-cache cannot produce an unvalidated HIT"); - let stored = cache.revalidation(&ticket).expect("validator and body retained for 304"); + cache + .wrap(response, ticket.clone(), p, &settings) + .into_body() + .collect() + .await + .unwrap(); + assert!( + cache.get(&ticket).is_none(), + "backend no-cache cannot produce an unvalidated HIT" + ); + let stored = cache + .revalidation(&ticket) + .expect("validator and body retained for 304"); let mut request = HeaderMap::new(); - assert!(stored.apply_validators(&mut request), "conditional request can validate stored object"); + assert!( + stored.apply_validators(&mut request), + "conditional request can validate stored object" + ); assert_eq!(request["if-none-match"], "\"origin-v1\""); } @@ -184,14 +416,36 @@ mod varnish_compatible_tests { let cache = Cache::new(1); let mut settings = Settings::default(); settings.default_ttl_seconds = 75; - let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/backend.css", + &HeaderMap::new(), + ) + .unwrap(); let mut response = Response::new(Body::from("public css")); *response.headers_mut() = headers("public, no-cache"); - let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + let p = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); assert_eq!(p.ttl, 75); assert!(!p.requires_revalidation); - cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); - let hit = cache.get(&ticket).expect("explicit public override allows fresh HIT without ETag"); + cache + .wrap(response, ticket.clone(), p, &settings) + .into_body() + .collect() + .await + .unwrap(); + let hit = cache + .get(&ticket) + .expect("explicit public override allows fresh HIT without ETag"); assert!(hit.remaining_ttl > 0); } @@ -199,59 +453,189 @@ mod varnish_compatible_tests { async fn zero_ttl_stale_if_error_fallback_survives_in_varnish_mode() { let cache = Cache::new(1); let settings = Settings::default(); - let ticket = cache.ticket(1, "https", "example.test", "app", "/fallback.css", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/fallback.css", + &HeaderMap::new(), + ) + .unwrap(); let mut response = Response::new(Body::from("public fallback")); *response.headers_mut() = headers("public, max-age=0, stale-if-error=120"); - let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); - cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); - assert!(cache.get(&ticket).is_none(), "zero TTL must never create a fresh HIT"); - assert!(cache.revalidation(&ticket).unwrap().can_stale_if_error(), "body remains available on backend failure"); + let p = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, ticket.clone(), p, &settings) + .into_body() + .collect() + .await + .unwrap(); + assert!( + cache.get(&ticket).is_none(), + "zero TTL must never create a fresh HIT" + ); + assert!( + cache.revalidation(&ticket).unwrap().can_stale_if_error(), + "body remains available on backend failure" + ); } #[tokio::test] async fn min_fresh_and_max_stale_still_constrain_compatibility_hits() { let cache = Cache::new(1); let settings = Settings::default(); - let ticket = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/fresh.css", + &HeaderMap::new(), + ) + .unwrap(); let mut response = Response::new(Body::from("css")); *response.headers_mut() = headers("public, max-age=60"); - let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); - cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); + let p = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, ticket.clone(), p, &settings) + .into_body() + .collect() + .await + .unwrap(); { let mut store = cache.store.lock().unwrap(); for entry in store.entries.values_mut() { entry.received = Instant::now() - Duration::from_secs(45); } } - let too_fresh = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-age=0, min-fresh=20")).unwrap().with_varnish_compatible(true); - assert!(cache.get(&too_fresh).is_none(), "min-fresh requires 20s but only 15s remain"); - let enough = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("min-fresh=10")).unwrap().with_varnish_compatible(true); - assert!(cache.get(&enough).is_some(), "10s remaining freshness is available"); + let too_fresh = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/fresh.css", + &headers("no-cache, max-age=0, min-fresh=20"), + ) + .unwrap() + .with_varnish_compatible(true); + assert!( + cache.get(&too_fresh).is_none(), + "min-fresh requires 20s but only 15s remain" + ); + let enough = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/fresh.css", + &headers("min-fresh=10"), + ) + .unwrap() + .with_varnish_compatible(true); + assert!( + cache.get(&enough).is_some(), + "10s remaining freshness is available" + ); { let mut store = cache.store.lock().unwrap(); for entry in store.entries.values_mut() { entry.received = Instant::now() - Duration::from_secs(90); } } - let max_stale = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-stale=45")).unwrap().with_varnish_compatible(true); - let stale = cache.get(&max_stale).expect("max-stale explicitly allows retained stale object"); + let max_stale = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/fresh.css", + &headers("no-cache, max-stale=45"), + ) + .unwrap() + .with_varnish_compatible(true); + let stale = cache + .get(&max_stale) + .expect("max-stale explicitly allows retained stale object"); assert!(stale.stale); - assert!(!stale.background_revalidate, "explicit max-stale never triggers implicit background refresh"); - let too_old = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("max-stale=10")).unwrap().with_varnish_compatible(true); + assert!( + !stale.background_revalidate, + "explicit max-stale never triggers implicit background refresh" + ); + let too_old = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/fresh.css", + &headers("max-stale=10"), + ) + .unwrap() + .with_varnish_compatible(true); assert!(cache.get(&too_old).is_none()); - let default = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap().with_varnish_compatible(true); - assert!(cache.get(&default).is_none(), "expired object cannot be served without stale allowance"); + let default = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/fresh.css", + &HeaderMap::new(), + ) + .unwrap() + .with_varnish_compatible(true); + assert!( + cache.get(&default).is_none(), + "expired object cannot be served without stale allowance" + ); } #[tokio::test] async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() { let cache = Cache::new(1); let settings = Settings::default(); - let ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/style.css", + &HeaderMap::new(), + ) + .unwrap(); let mut response = Response::new(Body::from("public css")); *response.headers_mut() = headers("public, max-age=600"); - let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); - cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap(); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); { let mut store = cache.store.lock().unwrap(); for entry in store.entries.values_mut() { @@ -260,27 +644,68 @@ mod varnish_compatible_tests { } let mut request = headers("no-cache, max-age=0"); request.insert("pragma", HeaderValue::from_static("no-cache")); - let compatibility_ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap().with_varnish_compatible(true); - let first = cache.get(&compatibility_ticket).expect("hard reload should keep a fresh HIT"); - assert!(first.response.headers().get("age").unwrap().to_str().unwrap().parse::().unwrap() >= 45); + let compatibility_ticket = cache + .ticket(1, "https", "example.test", "app", "/style.css", &request) + .unwrap() + .with_varnish_compatible(true); + let first = cache + .get(&compatibility_ticket) + .expect("hard reload should keep a fresh HIT"); + assert!( + first + .response + .headers() + .get("age") + .unwrap() + .to_str() + .unwrap() + .parse::() + .unwrap() + >= 45 + ); assert_eq!(first.hits, 1); let again = cache.get(&compatibility_ticket).unwrap(); assert_eq!(again.hits, 2, "hard reload must not reset HIT counter"); assert_eq!(cache.view().entries, 1); - let strict = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap(); - assert!(cache.get(&strict).is_none(), "strict max-age=0 requires revalidation"); + let strict = cache + .ticket(1, "https", "example.test", "app", "/style.css", &request) + .unwrap(); + assert!( + cache.get(&strict).is_none(), + "strict max-age=0 requires revalidation" + ); } #[tokio::test] async fn sixty_second_grace_and_no_unconditional_stale_outside_window() { let cache = Cache::new(1); let settings = Settings::default(); - let ticket = cache.ticket(1, "https", "example.test", "app", "/asset.js", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/asset.js", + &HeaderMap::new(), + ) + .unwrap(); let mut response = Response::new(Body::from("js")); *response.headers_mut() = headers("public, max-age=60"); - let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); assert_eq!(plan.stale_while_revalidate, 60); - cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap(); + cache + .wrap(response, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); { let mut store = cache.store.lock().unwrap(); for entry in store.entries.values_mut() { @@ -289,12 +714,50 @@ mod varnish_compatible_tests { } let hit = cache.get(&ticket).expect("grace HIT"); assert!(hit.stale && hit.background_revalidate); - let max_stale_zero = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("max-stale=0")).unwrap().with_varnish_compatible(true); - assert!(cache.get(&max_stale_zero).is_none(), "explicit max-stale=0 must override implicit grace"); - let max_stale_small = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=3")).unwrap().with_varnish_compatible(true); - assert!(cache.get(&max_stale_small).is_none(), "max-stale=3 rejects object stale by 5s, even in grace"); - let max_stale_enough = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=9")).unwrap().with_varnish_compatible(true); - let accepted = cache.get(&max_stale_enough).expect("max-stale=9 admits object 5s stale"); + let max_stale_zero = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/asset.js", + &headers("max-stale=0"), + ) + .unwrap() + .with_varnish_compatible(true); + assert!( + cache.get(&max_stale_zero).is_none(), + "explicit max-stale=0 must override implicit grace" + ); + let max_stale_small = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/asset.js", + &headers("no-cache, max-stale=3"), + ) + .unwrap() + .with_varnish_compatible(true); + assert!( + cache.get(&max_stale_small).is_none(), + "max-stale=3 rejects object stale by 5s, even in grace" + ); + let max_stale_enough = cache + .ticket( + 1, + "https", + "example.test", + "app", + "/asset.js", + &headers("no-cache, max-stale=9"), + ) + .unwrap() + .with_varnish_compatible(true); + let accepted = cache + .get(&max_stale_enough) + .expect("max-stale=9 admits object 5s stale"); assert!(accepted.stale); { let mut store = cache.store.lock().unwrap(); @@ -302,6 +765,9 @@ mod varnish_compatible_tests { entry.received = Instant::now() - Duration::from_secs(125); } } - assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT"); + assert!( + cache.get(&ticket).is_none(), + "expired beyond grace does not produce HIT" + ); } } diff --git a/src/compression/tests.rs b/src/compression/tests.rs index c323b23..a9e9b89 100644 --- a/src/compression/tests.rs +++ b/src/compression/tests.rs @@ -34,14 +34,26 @@ mod tests { headers.append("accept-encoding", "deflate;q=0".parse().unwrap()); headers.append("cache-control", "max-age=0".parse().unwrap()); if let Some(name) = skip { - headers.append(name, if name == "range" { "bytes=0-3" } else { "no-transform" }.parse().unwrap()); + headers.append( + name, + if name == "range" { + "bytes=0-3" + } else { + "no-transform" + } + .parse() + .unwrap(), + ); } headers.insert("cookie", "session=private".parse().unwrap()); headers.insert("authorization", "Bearer test".parse().unwrap()); headers.insert("x-large-custom", "x".repeat(4096).parse().unwrap()); let selected = request_headers(&headers); for name in ["accept-encoding", "range", "cache-control"] { - assert_eq!(headers.get_all(name).iter().collect::>(), selected.get_all(name).iter().collect::>()); + assert_eq!( + headers.get_all(name).iter().collect::>(), + selected.get_all(name).iter().collect::>() + ); } assert!(!selected.contains_key("cookie")); assert!(!selected.contains_key("authorization")); @@ -49,7 +61,10 @@ mod tests { let narrow = encode(response(), &selected, &method, &Settings::default()).await; assert_eq!(full.status(), narrow.status()); assert_eq!(full.headers(), narrow.headers()); - assert_eq!(full.into_body().collect().await.unwrap().to_bytes(), narrow.into_body().collect().await.unwrap().to_bytes()); + assert_eq!( + full.into_body().collect().await.unwrap().to_bytes(), + narrow.into_body().collect().await.unwrap().to_bytes() + ); } } } @@ -59,7 +74,10 @@ mod tests { #[test] fn unaccepted_codecs_cannot_be_reenabled_by_scoped_overrides() { let global = Settings::default(); - let force_on = Overrides { enabled: Some(true), ..Default::default() }; + let force_on = Overrides { + enabled: Some(true), + ..Default::default() + }; for offer in ["identity", "gzip;q=0, br;q=0, zstd;q=0", "deflate", "*;q=0"] { let mut headers = HeaderMap::new(); headers.insert("accept-encoding", offer.parse().unwrap()); @@ -86,10 +104,16 @@ mod tests { assert!(!selective.gzip); assert!(selective.br); assert!(!selective.zstd); - assert!(!selective.deflate, "a scope cannot enable a globally disabled codec"); + assert!( + !selective.deflate, + "a scope cannot enable a globally disabled codec" + ); - let disabled = Overrides { enabled: Some(false), ..Default::default() } - .resolve(&global, &inherited); + let disabled = Overrides { + enabled: Some(false), + ..Default::default() + } + .resolve(&global, &inherited); assert!(!disabled.enabled()); let parent_disabled = global.scoped(false); @@ -164,7 +188,16 @@ mod tests { .get_all("vary") .iter() .any(|v| v.to_str().unwrap().contains("accept-encoding"))); - assert!(response.into_body().collect().await.unwrap().to_bytes().len() < 1000); + assert!( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .len() + < 1000 + ); } } @@ -244,10 +277,9 @@ mod tests { dynamic .headers_mut() .insert("set-cookie", "session=next; HttpOnly".parse().unwrap()); - dynamic.headers_mut().insert( - "cache-control", - "private, no-store".parse().unwrap(), - ); + dynamic + .headers_mut() + .insert("cache-control", "private, no-store".parse().unwrap()); let encoded = encode(dynamic, &headers, &Method::GET, &settings).await; assert_eq!(encoded.headers()["content-encoding"], "gzip"); } @@ -306,19 +338,23 @@ mod tests { let mut no_transform_request = headers.clone(); no_transform_request.insert("cache-control", "no-transform".parse().unwrap()); - assert!(!encode(response(), &no_transform_request, &Method::GET, &settings) - .await - .headers() - .contains_key("content-encoding")); + assert!( + !encode(response(), &no_transform_request, &Method::GET, &settings) + .await + .headers() + .contains_key("content-encoding") + ); let mut no_transform_response = response(); no_transform_response .headers_mut() .insert("cache-control", "public, no-transform".parse().unwrap()); - assert!(!encode(no_transform_response, &headers, &Method::GET, &settings) - .await - .headers() - .contains_key("content-encoding")); + assert!( + !encode(no_transform_response, &headers, &Method::GET, &settings) + .await + .headers() + .contains_key("content-encoding") + ); let mut sse = response(); sse.headers_mut() diff --git a/src/config/model.rs b/src/config/model.rs index 4fed464..9401620 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -72,7 +72,11 @@ pub struct Config { pub host_policies: Vec, // Preserve old 0.34.13 and earlier config/backup data until compilation migrates it. // Migration happens in compile_with_active, before validation and persistence. - #[serde(default, rename = "user_agent_blocking", skip_serializing_if = "Option::is_none")] + #[serde( + default, + rename = "user_agent_blocking", + skip_serializing_if = "Option::is_none" + )] pub legacy_user_agent_blocking: Option, #[serde(default)] pub global_security: GlobalSecurity, @@ -124,7 +128,8 @@ impl Config { impl GlobalSecurity { pub fn compile(&mut self) -> Result { - let user_agent_blocking = self.user_agent_blocking + let user_agent_blocking = self + .user_agent_blocking .get_or_insert_with(Default::default) .compile()?; self.country_access.validate()?; diff --git a/src/config/tests.rs b/src/config/tests.rs index 0dad57f..feb5871 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1189,21 +1189,41 @@ mod tests { fn old_configuration_retains_user_agent_policy_and_global_security_defaults_off() { let config: Config = serde_json::from_value(serde_json::json!({ "user_agent_blocking": {"enabled":true,"patterns":["bad-bot"]} - })).unwrap(); - assert_eq!(config.global_security.country_access.mode, crate::rate_limit::CountryMode::Off); + })) + .unwrap(); + assert_eq!( + config.global_security.country_access.mode, + crate::rate_limit::CountryMode::Off + ); assert!(config.global_security.acl.is_none()); - assert_eq!(config.global_security.method_policy.mode, crate::vhost::MethodPolicyMode::Off); + assert_eq!( + config.global_security.method_policy.mode, + crate::vhost::MethodPolicyMode::Off + ); assert!(!config.global_security.content_type_policy.enabled); assert!(!config.global_security.rate_limit.enabled); assert!(!config.global_security.security_headers.hsts.enabled); let snapshot = compile(config, 1, &policy()).unwrap(); let mut headers = http::HeaderMap::new(); - headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("BAD-BOT/2")); - assert!(snapshot.global_security.user_agent_blocking.blocks(&headers)); - assert!(snapshot.envelope.config.legacy_user_agent_blocking.is_none()); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_static("BAD-BOT/2"), + ); + assert!(snapshot + .global_security + .user_agent_blocking + .blocks(&headers)); + assert!(snapshot + .envelope + .config + .legacy_user_agent_blocking + .is_none()); let canonical = serde_json::to_value(&snapshot.envelope.config).unwrap(); assert!(canonical.get("user_agent_blocking").is_none()); - assert_eq!(canonical["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot"); + assert_eq!( + canonical["global_security"]["user_agent_blocking"]["patterns"][0], + "bad-bot" + ); assert!(snapshot.global_security.country_access.allows(None)); } @@ -1214,7 +1234,8 @@ mod tests { "global_security": { "user_agent_blocking": { "enabled":false,"patterns":[] } } - })).unwrap(); + })) + .unwrap(); let err = compile(old_and_new, 1, &policy()).unwrap_err().to_string(); assert!(err.contains("conflicting legacy"), "{err}"); } @@ -1228,12 +1249,20 @@ mod tests { }); let json = serde_json::to_value(&config).unwrap(); assert!(json.get("user_agent_blocking").is_none()); - assert!(json["global_security"]["user_agent_blocking"]["enabled"].as_bool().unwrap()); + assert!(json["global_security"]["user_agent_blocking"]["enabled"] + .as_bool() + .unwrap()); let restored: Config = serde_json::from_value(json).unwrap(); let snapshot = compile(restored, 1, &policy()).unwrap(); let mut headers = http::HeaderMap::new(); - headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("trusted-test-bot/1")); - assert!(snapshot.global_security.user_agent_blocking.blocks(&headers)); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_static("trusted-test-bot/1"), + ); + assert!(snapshot + .global_security + .user_agent_blocking + .blocks(&headers)); } #[test] @@ -1260,16 +1289,35 @@ mod tests { let snapshot = compile(config, 2, &policy()).unwrap(); let rules = &snapshot.global_security; - assert_eq!(snapshot.envelope.config.global_security.country_access.countries, vec!["RU"]); + assert_eq!( + snapshot + .envelope + .config + .global_security + .country_access + .countries, + vec!["RU"] + ); assert!(!rules.country_access.allows(Some("RU"))); assert!(rules.country_access.allows(None)); - assert!(!rules.acl.as_ref().unwrap().allows("192.0.2.2".parse().unwrap())); - assert!(rules.acl.as_ref().unwrap().allows("198.51.100.1".parse().unwrap())); + assert!(!rules + .acl + .as_ref() + .unwrap() + .allows("192.0.2.2".parse().unwrap())); + assert!(rules + .acl + .as_ref() + .unwrap() + .allows("198.51.100.1".parse().unwrap())); assert!(!rules.method_policy.allows(&http::Method::TRACE)); assert!(rules.method_policy.allows(&http::Method::GET)); assert!(rules.rate_limit.enabled); let mut headers = http::HeaderMap::new(); - headers.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/x-danger; charset=utf-8")); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/x-danger; charset=utf-8"), + ); assert!(rules.content_type_policy.blocks(&headers)); headers.clear(); rules.headers.apply(&mut headers, true); @@ -1292,7 +1340,11 @@ mod tests { config.global_security.security_headers.hsts.preload = true; assert!(compile(config.clone(), 1, &policy()).is_err()); config.global_security.security_headers.hsts.enabled = true; - config.global_security.security_headers.hsts.include_subdomains = true; + config + .global_security + .security_headers + .hsts + .include_subdomains = true; assert!(compile(config, 1, &policy()).is_ok()); } diff --git a/src/db.rs b/src/db.rs index 1703a7e..7656e35 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,10 +1,10 @@ use crate::config::{Config, Envelope}; +mod api_tokens; mod certificate_api; mod dashboard_presets; mod history; -mod api_tokens; -pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY}; use anyhow::{ensure, Context, Result}; +pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY}; pub use history::{ConfigurationHistoryDetail, ConfigurationRevision}; use serde::Serialize; use sqlx::{ diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 2cac37a..15a85e1 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -21,7 +21,8 @@ impl Diagnostics { tokio::spawn(async move { let mut previous: Option<(View, Counters)> = None; loop { - let sample = tokio::task::spawn_blocking(move || system_stats::sample(previous)).await; + let sample = + tokio::task::spawn_blocking(move || system_stats::sample(previous)).await; if let Ok((view, counters)) = sample { *sampler.latest.write().unwrap_or_else(|e| e.into_inner()) = view.clone(); previous = Some((view, counters)); diff --git a/src/forwarding.rs b/src/forwarding.rs index b54ace0..2a3c2ef 100644 --- a/src/forwarding.rs +++ b/src/forwarding.rs @@ -221,8 +221,15 @@ impl Compiled { include_original_url: bool, ) -> Result { self.context_with_peer( - incoming, peer, local, tls, authority, uri, include_original_url, - None, false, + incoming, + peer, + local, + tls, + authority, + uri, + include_original_url, + None, + false, ) } @@ -245,7 +252,9 @@ impl Compiled { if !trusted_peer { let scheme = if tls { "https" } else { "http" }; let authority_text = authority.to_str()?; - if !validated_host { crate::config::request_host(authority_text)?; } + if !validated_host { + crate::config::request_host(authority_text)?; + } let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/"); let original_url = if include_original_url { format!("{scheme}://{authority_text}{target}") @@ -267,12 +276,19 @@ impl Compiled { }; let by_text = cached.is_none().then(|| node(canonical(local.ip()))); let peer_node = cached.map_or_else( - || if peer_ip.is_ipv4() { peer_text.as_deref().unwrap_or("") } - else { ipv6_node.as_deref().unwrap_or("") }, + || { + if peer_ip.is_ipv4() { + peer_text.as_deref().unwrap_or("") + } else { + ipv6_node.as_deref().unwrap_or("") + } + }, |cached| cached.peer_node.as_str(), ); - let by_node = cached.map_or_else(|| by_text.as_deref().unwrap_or(""), - |cached| cached.by_node.as_str()); + let by_node = cached.map_or_else( + || by_text.as_deref().unwrap_or(""), + |cached| cached.by_node.as_str(), + ); let forwarded = format!( "for={peer_node};by={by_node};proto={scheme};host=\"{authority_text}\"" ); @@ -282,11 +298,14 @@ impl Compiled { values.insert("x-real-ip", peer_header); values.insert("x-forwarded-host", authority.clone()); values.insert("x-forwarded-proto", HeaderValue::from_static(scheme)); - values.insert("x-forwarded-port", if let Some(cached) = cached { - cached.port_header.clone() - } else { - local.port().to_string().parse()? - }); + values.insert( + "x-forwarded-port", + if let Some(cached) = cached { + cached.port_header.clone() + } else { + local.port().to_string().parse()? + }, + ); if let Some(peer) = cached { peer.remember_untrusted(authority, tls, &values); } @@ -320,7 +339,9 @@ impl Compiled { }; } let authority_text = authority.to_str()?; - if !validated_host { crate::config::request_host(authority_text)?; } + 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 { format!("{scheme}://{authority_text}{original_target}") @@ -331,24 +352,36 @@ impl Compiled { let mut forwarded = String::with_capacity(ips.len() * 52 + 80 + authority_text.len()); let mut xff = String::with_capacity(ips.len() * 44); for (index, ip) in ips.iter().enumerate() { - if index > 0 { forwarded.push_str(", "); xff.push_str(", "); } + if index > 0 { + forwarded.push_str(", "); + xff.push_str(", "); + } forwarded.push_str("for="); if index + 1 == ips.len() { - if let Some(cached) = cached { forwarded.push_str(&cached.peer_node); } - else { forwarded.push_str(&node(*ip)); } + if let Some(cached) = cached { + forwarded.push_str(&cached.peer_node); + } else { + forwarded.push_str(&node(*ip)); + } } else { forwarded.push_str(&node(*ip)); } if index + 1 == ips.len() { - if let Some(cached) = cached { xff.push_str(cached.peer_header.to_str()?); } - else { write!(&mut xff, "{ip}")?; } + if let Some(cached) = cached { + xff.push_str(cached.peer_header.to_str()?); + } else { + write!(&mut xff, "{ip}")?; + } } else { write!(&mut xff, "{ip}")?; } } forwarded.push_str(";by="); - if let Some(cached) = cached { forwarded.push_str(&cached.by_node); } - else { forwarded.push_str(&node(canonical(local.ip()))); } + if let Some(cached) = cached { + forwarded.push_str(&cached.by_node); + } else { + forwarded.push_str(&node(canonical(local.ip()))); + } write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?; let mut values = HeaderMap::with_capacity(7); values.insert("forwarded", forwarded.parse()?); @@ -364,11 +397,14 @@ impl Compiled { } else { local.port() }; - values.insert("x-forwarded-port", if cached.is_some() && !(trusted_peer && self.trust_proto) { - cached.expect("tested above").port_header.clone() - } else { - port.to_string().parse()? - }); + values.insert( + "x-forwarded-port", + if cached.is_some() && !(trusted_peer && self.trust_proto) { + cached.expect("tested above").port_header.clone() + } else { + port.to_string().parse()? + }, + ); values.insert("x-forwarded-uri", original_target.parse()?); Ok(Context { client_ip, @@ -423,14 +459,15 @@ impl Context { for name in headers.keys() { let text = name.as_str(); if matches!(text, "x-original-url" | "x-original-method") - || (text.starts_with("x-forwarded-") - && !self.values.contains_key(text)) + || (text.starts_with("x-forwarded-") && !self.values.contains_key(text)) { remove.get_or_insert_with(Vec::new).push(name.clone()); } } if let Some(remove) = remove { - for name in remove { headers.remove(name); } + for name in remove { + headers.remove(name); + } } if !include_client_ip { for name in ["forwarded", "x-forwarded-for", "x-real-ip"] { @@ -438,7 +475,9 @@ impl Context { } } for (name, value) in &self.values { - if !include_client_ip && matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip") { + if !include_client_ip + && matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip") + { continue; } headers.insert(name.clone(), value.clone()); diff --git a/src/geoip.rs b/src/geoip.rs index d43de40..d12e293 100644 --- a/src/geoip.rs +++ b/src/geoip.rs @@ -1,11 +1,11 @@ mod mmdb; mod update; mod update_http; +pub(crate) use update::{poll_loop as update_poll_loop, update_now}; pub use update::{ UpdateResult, UpdateSettings, DEFAULT_INTERVAL_SECONDS, DEFAULT_IPV4_URL, DEFAULT_IPV6_URL, DEFAULT_MAX_BYTES, DEFAULT_MMDB_URL, }; -pub(crate) use update::{poll_loop as update_poll_loop, update_now}; use anyhow::{bail, ensure, Context, Result}; use serde::{Deserialize, Serialize}; @@ -46,7 +46,10 @@ impl Default for DatabaseFormat { #[derive(Debug)] enum Backend { - Csv { ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]> }, + Csv { + ipv4: Box<[V4Range]>, + ipv6: Box<[V6Range]>, + }, Mmdb(mmdb::Database), } @@ -64,7 +67,12 @@ pub struct View { } impl Database { pub fn empty() -> Self { - Self { backend: Backend::Csv { ipv4: Box::new([]), ipv6: Box::new([]) } } + Self { + backend: Backend::Csv { + ipv4: Box::new([]), + ipv6: Box::new([]), + }, + } } pub fn load(directory: &Path) -> Result { if directory.join("country.mmdb").is_file() { @@ -78,11 +86,22 @@ impl Database { DatabaseFormat::CsvPair => { let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?; let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?; - ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty"); - Ok(Self { backend: Backend::Csv { ipv4: ipv4.into_boxed_slice(), ipv6: ipv6.into_boxed_slice() } }) + ensure!( + !ipv4.is_empty() && !ipv6.is_empty(), + "GeoIP country database is empty" + ); + Ok(Self { + backend: Backend::Csv { + ipv4: ipv4.into_boxed_slice(), + ipv6: ipv6.into_boxed_slice(), + }, + }) } DatabaseFormat::Mmdb => Ok(Self { - backend: Backend::Mmdb(mmdb::Database::load(&directory.join("country.mmdb"), MAX_FILE_BYTES)?), + backend: Backend::Mmdb(mmdb::Database::load( + &directory.join("country.mmdb"), + MAX_FILE_BYTES, + )?), }), } } diff --git a/src/geoip/mmdb.rs b/src/geoip/mmdb.rs index 5fe66cc..dea13d5 100644 --- a/src/geoip/mmdb.rs +++ b/src/geoip/mmdb.rs @@ -36,7 +36,10 @@ struct Budget { impl Budget { fn charge(&mut self) -> Result<()> { self.values = self.values.saturating_add(1); - ensure!(self.values <= MAX_VALUES, "MMDB value traversal limit exceeded"); + ensure!( + self.values <= MAX_VALUES, + "MMDB value traversal limit exceeded" + ); Ok(()) } } @@ -45,20 +48,56 @@ impl Database { pub(super) fn load(path: &Path, max_bytes: u64) -> Result { let metadata = fs::symlink_metadata(path) .with_context(|| format!("open GeoIP MMDB {}", path.display()))?; - ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP MMDB must be a regular file"); - ensure!(metadata.len() <= max_bytes, "GeoIP MMDB exceeds configured maximum size"); - let bytes = fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?; + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "GeoIP MMDB must be a regular file" + ); + ensure!( + metadata.len() <= max_bytes, + "GeoIP MMDB exceeds configured maximum size" + ); + let bytes = + fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?; let marker = metadata_marker(&bytes).context("MMDB metadata marker not found")?; let metadata_offset = marker + METADATA_MARKER.len(); let base = metadata_offset; - let node_count = u32::try_from(read_uint_path(&bytes, metadata_offset, base, &["node_count"])?).context("MMDB node_count is too large")?; - let record_size = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["record_size"])?).context("MMDB record_size is too large")?; - let ip_version = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["ip_version"])?).context("MMDB ip_version is too large")?; - let major = read_uint_path(&bytes, metadata_offset, base, &["binary_format_major_version"])?; + let node_count = u32::try_from(read_uint_path( + &bytes, + metadata_offset, + base, + &["node_count"], + )?) + .context("MMDB node_count is too large")?; + let record_size = u16::try_from(read_uint_path( + &bytes, + metadata_offset, + base, + &["record_size"], + )?) + .context("MMDB record_size is too large")?; + let ip_version = u16::try_from(read_uint_path( + &bytes, + metadata_offset, + base, + &["ip_version"], + )?) + .context("MMDB ip_version is too large")?; + let major = read_uint_path( + &bytes, + metadata_offset, + base, + &["binary_format_major_version"], + )?; let database_type = read_string_path(&bytes, metadata_offset, base, &["database_type"])?; ensure!(node_count > 0, "MMDB node_count must be positive"); - ensure!(matches!(record_size, 24 | 28 | 32), "unsupported MMDB record_size {record_size}"); - ensure!(matches!(ip_version, 4 | 6), "invalid MMDB ip_version {ip_version}"); + ensure!( + matches!(record_size, 24 | 28 | 32), + "unsupported MMDB record_size {record_size}" + ); + ensure!( + matches!(ip_version, 4 | 6), + "invalid MMDB ip_version {ip_version}" + ); ensure!(major == 2, "unsupported MMDB major format version {major}"); let node_bytes = usize::from(record_size) .checked_mul(2) @@ -67,9 +106,17 @@ impl Database { let search_tree_size = (node_count as usize) .checked_mul(node_bytes) .context("MMDB search tree size overflow")?; - let data_section_start = search_tree_size.checked_add(16).context("MMDB data section overflow")?; - ensure!(data_section_start <= marker, "MMDB search tree overlaps metadata"); - ensure!(bytes.get(search_tree_size..data_section_start) == Some(&[0u8; 16][..]), "MMDB data-section separator is invalid"); + let data_section_start = search_tree_size + .checked_add(16) + .context("MMDB data section overflow")?; + ensure!( + data_section_start <= marker, + "MMDB search tree overlaps metadata" + ); + ensure!( + bytes.get(search_tree_size..data_section_start) == Some(&[0u8; 16][..]), + "MMDB data-section separator is invalid" + ); Ok(Self { bytes, node_count, @@ -100,7 +147,8 @@ impl Database { } } let mut budget = Budget::default(); - if let Ok(code) = read_string(&self.bytes, offset, self.data_section_start, 0, &mut budget) { + if let Ok(code) = read_string(&self.bytes, offset, self.data_section_start, 0, &mut budget) + { if let Some(code) = super::normalize_country_code(&code) { return Some(String::from_utf8_lossy(&code).into_owned()); } @@ -135,17 +183,27 @@ impl Database { if node == self.node_count { return Ok(None); } - ensure!(node >= self.node_count.saturating_add(16), "invalid MMDB data pointer"); + ensure!( + node >= self.node_count.saturating_add(16), + "invalid MMDB data pointer" + ); let offset = (node as usize) .checked_sub(self.node_count as usize) .and_then(|value| value.checked_add(self.search_tree_size)) .context("MMDB data pointer overflow")?; - ensure!(offset >= self.data_section_start && offset < self.metadata_offset - METADATA_MARKER.len(), "MMDB data pointer is out of bounds"); + ensure!( + offset >= self.data_section_start + && offset < self.metadata_offset - METADATA_MARKER.len(), + "MMDB data pointer is out of bounds" + ); Ok(Some(offset)) } fn read_node(&self, node: u32, side: usize) -> Result { - ensure!((node as usize) < self.node_count as usize, "MMDB node is out of range"); + ensure!( + (node as usize) < self.node_count as usize, + "MMDB node is out of range" + ); let base = match self.record_size { 24 => node as usize * 6, 28 => node as usize * 7, @@ -170,7 +228,9 @@ impl Database { } fn metadata_marker(bytes: &[u8]) -> Option { - let start = bytes.len().saturating_sub(MAX_METADATA_BYTES + METADATA_MARKER.len()); + let start = bytes + .len() + .saturating_sub(MAX_METADATA_BYTES + METADATA_MARKER.len()); bytes[start..] .windows(METADATA_MARKER.len()) .rposition(|window| window == METADATA_MARKER) @@ -179,17 +239,26 @@ fn metadata_marker(bytes: &[u8]) -> Option { fn read_uint_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result { let mut budget = Budget::default(); - let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB metadata field missing")?; + let value = find_path(bytes, offset, base, path, 0, &mut budget)? + .context("MMDB metadata field missing")?; read_uint(bytes, value, base, 0, &mut budget) } fn read_string_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result { let mut budget = Budget::default(); - let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB string field missing")?; + let value = find_path(bytes, offset, base, path, 0, &mut budget)? + .context("MMDB string field missing")?; read_string(bytes, value, base, 0, &mut budget) } -fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usize, budget: &mut Budget) -> Result> { +fn find_path( + bytes: &[u8], + offset: usize, + base: usize, + path: &[&str], + depth: usize, + budget: &mut Budget, +) -> Result> { ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); budget.charge()?; let header = header(bytes, offset, base)?; @@ -209,7 +278,9 @@ fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usi if path.len() == 1 { return Ok(Some(value_offset)); } - if let Some(found) = find_path(bytes, value_offset, base, &path[1..], depth + 1, budget)? { + if let Some(found) = + find_path(bytes, value_offset, base, &path[1..], depth + 1, budget)? + { return Ok(Some(found)); } } @@ -218,7 +289,13 @@ fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usi Ok(None) } -fn read_string(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result { +fn read_string( + bytes: &[u8], + offset: usize, + base: usize, + depth: usize, + budget: &mut Budget, +) -> Result { ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); budget.charge()?; let h = header(bytes, offset, base)?; @@ -226,31 +303,61 @@ fn read_string(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: & return read_string(bytes, pointer, base, depth + 1, budget); } ensure!(h.kind == 2, "MMDB value is not a string"); - ensure!(h.size <= MAX_STRING_BYTES, "MMDB string exceeds the decode limit"); - let data = bytes.get(h.payload..h.next).context("MMDB string is truncated")?; - Ok(std::str::from_utf8(data).context("MMDB string is not UTF-8")?.to_owned()) + ensure!( + h.size <= MAX_STRING_BYTES, + "MMDB string exceeds the decode limit" + ); + let data = bytes + .get(h.payload..h.next) + .context("MMDB string is truncated")?; + Ok(std::str::from_utf8(data) + .context("MMDB string is not UTF-8")? + .to_owned()) } -fn read_uint(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result { +fn read_uint( + bytes: &[u8], + offset: usize, + base: usize, + depth: usize, + budget: &mut Budget, +) -> Result { ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); budget.charge()?; let h = header(bytes, offset, base)?; if let Some(pointer) = h.pointer { return read_uint(bytes, pointer, base, depth + 1, budget); } - ensure!(matches!(h.kind, 5 | 6 | 9), "MMDB value is not an unsigned integer"); + ensure!( + matches!(h.kind, 5 | 6 | 9), + "MMDB value is not an unsigned integer" + ); ensure!(h.size <= 8, "MMDB integer is too wide"); read_be(bytes, h.payload, h.size) } fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> { let h = header(bytes, offset, base)?; - ensure!(h.pointer.is_none() && h.kind == 2, "MMDB map key is not an inline string"); - let value = bytes.get(h.payload..h.next).context("MMDB map key is truncated")?; - Ok((std::str::from_utf8(value).context("MMDB map key is not UTF-8")?, h.next)) + ensure!( + h.pointer.is_none() && h.kind == 2, + "MMDB map key is not an inline string" + ); + let value = bytes + .get(h.payload..h.next) + .context("MMDB map key is truncated")?; + Ok(( + std::str::from_utf8(value).context("MMDB map key is not UTF-8")?, + h.next, + )) } -fn skip(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result { +fn skip( + bytes: &[u8], + offset: usize, + base: usize, + depth: usize, + budget: &mut Budget, +) -> Result { ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); budget.charge()?; let h = header(bytes, offset, base)?; @@ -290,18 +397,30 @@ fn header(bytes: &[u8], offset: usize, base: usize) -> Result
{ let raw = read_be(bytes, payload, count)? as usize; let pointer = match class { 0 => (low << 8) | raw, - 1 => ((low << 16) | raw).checked_add(2048).context("MMDB pointer overflow")?, - 2 => ((low << 24) | raw).checked_add(526_336).context("MMDB pointer overflow")?, + 1 => ((low << 16) | raw) + .checked_add(2048) + .context("MMDB pointer overflow")?, + 2 => ((low << 24) | raw) + .checked_add(526_336) + .context("MMDB pointer overflow")?, 3 => raw, _ => unreachable!(), }; let target = base.checked_add(pointer).context("MMDB pointer overflow")?; ensure!(target < bytes.len(), "MMDB pointer is out of bounds"); - return Ok(Header { kind: 1, size: 0, payload, next: payload + count, pointer: Some(target) }); + return Ok(Header { + kind: 1, + size: 0, + payload, + next: payload + count, + pointer: Some(target), + }); } let mut cursor = offset + 1; let kind = if primary == 0 { - let extended = *bytes.get(cursor).context("MMDB extended type is truncated")?; + let extended = *bytes + .get(cursor) + .context("MMDB extended type is truncated")?; cursor += 1; extended.checked_add(7).context("MMDB type overflow")? } else { @@ -314,20 +433,35 @@ fn header(bytes: &[u8], offset: usize, base: usize) -> Result
{ 31 => 65_821 + read_be(bytes, cursor, 3)? as usize, _ => unreachable!(), }; - cursor += match size_bits { 29 => 1, 30 => 2, 31 => 3, _ => 0 }; + cursor += match size_bits { + 29 => 1, + 30 => 2, + 31 => 3, + _ => 0, + }; let next = match kind { 7 | 11 | 14 => cursor, - _ => cursor.checked_add(size).context("MMDB field size overflow")?, + _ => cursor + .checked_add(size) + .context("MMDB field size overflow")?, }; ensure!(next <= bytes.len(), "MMDB field is out of bounds"); - Ok(Header { kind, size, payload: cursor, next, pointer: None }) + Ok(Header { + kind, + size, + payload: cursor, + next, + pointer: None, + }) } fn read_be(bytes: &[u8], offset: usize, count: usize) -> Result { ensure!(count <= 8, "MMDB integer is too wide"); let end = offset.checked_add(count).context("MMDB read overflow")?; let data = bytes.get(offset..end).context("MMDB value is truncated")?; - Ok(data.iter().fold(0u64, |value, byte| (value << 8) | u64::from(*byte))) + Ok(data + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte))) } #[cfg(test)] diff --git a/src/geoip/mmdb_tests.rs b/src/geoip/mmdb_tests.rs index 142089a..97fdb35 100644 --- a/src/geoip/mmdb_tests.rs +++ b/src/geoip/mmdb_tests.rs @@ -59,7 +59,10 @@ mod tests { std::fs::write(&path, fixture("PL")).unwrap(); let database = Database::load(&path, 1024 * 1024).unwrap(); assert_eq!(database.database_type(), "GeoLite2-Country"); - assert_eq!(database.country("192.0.2.1".parse().unwrap()).as_deref(), Some("PL")); + assert_eq!( + database.country("192.0.2.1".parse().unwrap()).as_deref(), + Some("PL") + ); assert!(database.country("2001:db8::1".parse().unwrap()).is_none()); } @@ -69,7 +72,10 @@ mod tests { let path = directory.path().join("country.mmdb"); std::fs::write(&path, sapics_fixture("DE")).unwrap(); let database = Database::load(&path, 1024 * 1024).unwrap(); - assert_eq!(database.country("198.51.100.8".parse().unwrap()).as_deref(), Some("DE")); + assert_eq!( + database.country("198.51.100.8".parse().unwrap()).as_deref(), + Some("DE") + ); } #[test] @@ -78,7 +84,10 @@ mod tests { let path = directory.path().join("country.mmdb"); std::fs::write(&path, fixture_with_record(string("FR"))).unwrap(); let database = Database::load(&path, 1024 * 1024).unwrap(); - assert_eq!(database.country("192.0.2.44".parse().unwrap()).as_deref(), Some("FR")); + assert_eq!( + database.country("192.0.2.44".parse().unwrap()).as_deref(), + Some("FR") + ); } #[test] diff --git a/src/geoip/tests.rs b/src/geoip/tests.rs index 5d91bef..f5fa3b5 100644 --- a/src/geoip/tests.rs +++ b/src/geoip/tests.rs @@ -6,14 +6,32 @@ mod tests { assert_eq!(std::mem::size_of::(), 34); assert_eq!(std::mem::size_of::(), 12); let ranges = [ - V6Range { start: 0u128.to_be_bytes(), end: 127u128.to_be_bytes(), country: *b"PL" }, - V6Range { start: 256u128.to_be_bytes(), end: 65535u128.to_be_bytes(), country: *b"DE" }, - V6Range { start: (1u128 << 120).to_be_bytes(), end: u128::MAX.to_be_bytes(), country: *b"US" }, + V6Range { + start: 0u128.to_be_bytes(), + end: 127u128.to_be_bytes(), + country: *b"PL", + }, + V6Range { + start: 256u128.to_be_bytes(), + end: 65535u128.to_be_bytes(), + country: *b"DE", + }, + V6Range { + start: (1u128 << 120).to_be_bytes(), + end: u128::MAX.to_be_bytes(), + country: *b"US", + }, ]; for (address, expected) in [ - (0, Some(*b"PL")), (127, Some(*b"PL")), (128, None), - (255, None), (256, Some(*b"DE")), (65535, Some(*b"DE")), - (65536, None), (1u128 << 120, Some(*b"US")), (u128::MAX, Some(*b"US")), + (0, Some(*b"PL")), + (127, Some(*b"PL")), + (128, None), + (255, None), + (256, Some(*b"DE")), + (65535, Some(*b"DE")), + (65536, None), + (1u128 << 120, Some(*b"US")), + (u128::MAX, Some(*b"US")), ] { assert_eq!(find_v6(&ranges, address.to_be_bytes()), expected); } @@ -33,7 +51,10 @@ mod tests { ) .unwrap(); let db = Database::load_format(directory.path(), DatabaseFormat::CsvPair).unwrap(); - assert_eq!(db.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL")); + assert_eq!( + db.country("192.0.2.7".parse().unwrap()).as_deref(), + Some("PL") + ); assert!(db.country("198.51.100.1".parse().unwrap()).is_none()); } } diff --git a/src/geoip/update.rs b/src/geoip/update.rs index a521202..9840c07 100644 --- a/src/geoip/update.rs +++ b/src/geoip/update.rs @@ -94,9 +94,12 @@ impl UpdateSettings { } } } - self.ipv4_checksum_url = validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_checksum_url.take())?; - self.ipv6_checksum_url = validate_optional_url("GeoIP IPv6 checksum URL", self.ipv6_checksum_url.take())?; - self.mmdb_checksum_url = validate_optional_url("GeoIP MMDB checksum URL", self.mmdb_checksum_url.take())?; + self.ipv4_checksum_url = + validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_checksum_url.take())?; + self.ipv6_checksum_url = + validate_optional_url("GeoIP IPv6 checksum URL", self.ipv6_checksum_url.take())?; + self.mmdb_checksum_url = + validate_optional_url("GeoIP MMDB checksum URL", self.mmdb_checksum_url.take())?; ensure!( (1024 * 1024..=MAX_CONFIGURED_BYTES).contains(&self.max_bytes), "GeoIP maximum file size must be between 1 MiB and 256 MiB" @@ -166,8 +169,14 @@ fn validate_direct_url(label: &str, value: &str) -> Result { let parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?; ensure!(parsed.scheme() == "https", "{label} must use https://"); ensure!(parsed.host_str().is_some(), "{label} must contain a host"); - ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain URL credentials"); - ensure!(parsed.fragment().is_none(), "{label} must not contain a fragment"); + ensure!( + parsed.username().is_empty() && parsed.password().is_none(), + "{label} must not contain URL credentials" + ); + ensure!( + parsed.fragment().is_none(), + "{label} must not contain a fragment" + ); Ok(parsed.to_string()) } @@ -183,9 +192,18 @@ fn validate_optional_url(label: &str, value: Option) -> Result Result { let mut parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?; - ensure!(parsed.scheme() == "https" && parsed.host_str().is_some(), "{label} must be an HTTPS URL"); - ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain credentials"); - ensure!(parsed.query().is_none() && parsed.fragment().is_none(), "{label} must not contain a query or fragment"); + ensure!( + parsed.scheme() == "https" && parsed.host_str().is_some(), + "{label} must be an HTTPS URL" + ); + ensure!( + parsed.username().is_empty() && parsed.password().is_none(), + "{label} must not contain credentials" + ); + ensure!( + parsed.query().is_none() && parsed.fragment().is_none(), + "{label} must not contain a query or fragment" + ); if !parsed.path().ends_with('/') { parsed.set_path(&format!("{}/", parsed.path())); } @@ -208,7 +226,9 @@ fn try_update_lock(data_dir: &Path) -> Result> { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600).custom_flags(libc::O_NOFOLLOW); } - let file = options.open(&path).with_context(|| format!("open GeoIP update lock {}", path.display()))?; + let file = options + .open(&path) + .with_context(|| format!("open GeoIP update lock {}", path.display()))?; match file.try_lock_exclusive() { Ok(()) => Ok(Some(UpdateLock(file))), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None), @@ -220,8 +240,12 @@ pub async fn poll_loop(app: Weak, shutdown: CancellationTok let mut observed: Option = None; let mut next_attempt = tokio::time::Instant::now(); loop { - if shutdown.is_cancelled() { return; } - let Some(app) = app.upgrade() else { return; }; + if shutdown.is_cancelled() { + return; + } + let Some(app) = app.upgrade() else { + return; + }; let settings = app.config.load().envelope.config.geoip_updates.clone(); if observed.as_ref() != Some(&settings) { next_attempt = tokio::time::Instant::now(); @@ -235,7 +259,9 @@ pub async fn poll_loop(app: Weak, shutdown: CancellationTok Duration::from_secs(settings.interval_seconds) } Ok(UpdateOutcome::Current) => Duration::from_secs(settings.interval_seconds), - Ok(UpdateOutcome::Busy) => RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds)), + Ok(UpdateOutcome::Busy) => { + RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds)) + } Err(error) => { tracing::warn!(error = %format!("{error:#}"), "GeoIP update failed; proxy traffic is unaffected"); RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds)) @@ -244,7 +270,9 @@ pub async fn poll_loop(app: Weak, shutdown: CancellationTok next_attempt = tokio::time::Instant::now() + interval; } let wait = if settings.enabled { - next_attempt.saturating_duration_since(tokio::time::Instant::now()).min(CONFIG_POLL_INTERVAL) + next_attempt + .saturating_duration_since(tokio::time::Instant::now()) + .min(CONFIG_POLL_INTERVAL) } else { CONFIG_POLL_INTERVAL }; @@ -256,7 +284,11 @@ pub async fn poll_loop(app: Weak, shutdown: CancellationTok } } -enum UpdateOutcome { Updated(Vec), Current, Busy } +enum UpdateOutcome { + Updated(Vec), + Current, + Busy, +} #[derive(Debug, Clone, Serialize)] pub struct UpdateResult { @@ -273,12 +305,25 @@ pub async fn update_now(app: &Arc) -> Result UpdateOutcome::Current => ("current", Vec::new()), UpdateOutcome::Busy => ("busy", Vec::new()), }; - Ok(UpdateResult { outcome, updated_files, database: app.geoip.load().view() }) + Ok(UpdateResult { + outcome, + updated_files, + database: app.geoip.load().view(), + }) } -async fn update_once(app: &Arc, settings: &UpdateSettings) -> Result { - let data_dir = app.policy.data_dir.as_deref().context("GeoIP updater requires a data directory")?; - let Some(_lock) = try_update_lock(data_dir)? else { return Ok(UpdateOutcome::Busy); }; +async fn update_once( + app: &Arc, + settings: &UpdateSettings, +) -> Result { + let data_dir = app + .policy + .data_dir + .as_deref() + .context("GeoIP updater requires a data directory")?; + let Some(_lock) = try_update_lock(data_dir)? else { + return Ok(UpdateOutcome::Busy); + }; let directory = data_dir.join("geoip"); ensure_safe_directory(&directory)?; if settings.format == super::DatabaseFormat::Mmdb && settings.mmdb_url.trim().is_empty() { @@ -295,9 +340,16 @@ async fn update_once(app: &Arc, settings: &UpdateSettings) } let mut current = Vec::with_capacity(assets.len()); for asset in &assets { - current.push(file_digest(&directory.join(asset.name), settings.max_bytes)?); + current.push(file_digest( + &directory.join(asset.name), + settings.max_bytes, + )?); } - if assets.iter().enumerate().all(|(index, _)| expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest))) { + if assets.iter().enumerate().all(|(index, _)| { + expected[index] + .as_ref() + .is_some_and(|digest| current[index].as_deref() == Some(digest)) + }) { reload_if_needed(app, directory, settings.format).await?; return Ok(UpdateOutcome::Current); } @@ -307,35 +359,64 @@ async fn update_once(app: &Arc, settings: &UpdateSettings) let result = async { for (index, asset) in assets.iter().enumerate() { let target = stage.join(asset.name); - if expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest)) { + if expected[index] + .as_ref() + .is_some_and(|digest| current[index].as_deref() == Some(digest)) + { copy_private(&directory.join(asset.name), &target, settings.max_bytes)?; } else { - super::update_http::download(&asset.url, &target, settings.max_bytes, expected[index].as_deref()).await?; + super::update_http::download( + &asset.url, + &target, + settings.max_bytes, + expected[index].as_deref(), + ) + .await?; } } let validation_stage = stage.clone(); let format = settings.format; - tokio::task::spawn_blocking(move || super::validate_directory_format(&validation_stage, format)) - .await.context("GeoIP validation worker failed")??; + tokio::task::spawn_blocking(move || { + super::validate_directory_format(&validation_stage, format) + }) + .await + .context("GeoIP validation worker failed")??; let mut changed = Vec::new(); for (index, asset) in assets.iter().enumerate() { - let staged = file_digest(&stage.join(asset.name), settings.max_bytes)?.context("staged GeoIP file disappeared")?; + let staged = file_digest(&stage.join(asset.name), settings.max_bytes)? + .context("staged GeoIP file disappeared")?; if current[index].as_deref() != Some(staged.as_str()) { - publish_private(&stage.join(asset.name), &directory.join(asset.name), settings.max_bytes)?; + publish_private( + &stage.join(asset.name), + &directory.join(asset.name), + settings.max_bytes, + )?; changed.push(asset.name.to_string()); } } reload(app, directory.clone(), settings.format).await?; Ok::, anyhow::Error>(changed) - }.await; + } + .await; let _ = fs::remove_dir_all(&stage); let changed = result?; - if changed.is_empty() { Ok(UpdateOutcome::Current) } else { Ok(UpdateOutcome::Updated(changed)) } + if changed.is_empty() { + Ok(UpdateOutcome::Current) + } else { + Ok(UpdateOutcome::Updated(changed)) + } } -async fn reload_if_needed(app: &Arc, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> { - let expected = match format { super::DatabaseFormat::CsvPair => "csv_pair", super::DatabaseFormat::Mmdb => "mmdb" }; +async fn reload_if_needed( + app: &Arc, + directory: PathBuf, + format: super::DatabaseFormat, +) -> Result<()> { + let expected = match format { + super::DatabaseFormat::CsvPair => "csv_pair", + super::DatabaseFormat::Mmdb => "mmdb", + }; let view = app.geoip.load().view(); if !view.loaded || view.format != expected { reload(app, directory, format).await?; @@ -343,9 +424,15 @@ async fn reload_if_needed(app: &Arc, directory: PathBuf, fo Ok(()) } -async fn reload(app: &Arc, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> { - let database = tokio::task::spawn_blocking(move || super::Database::load_format(&directory, format)) - .await.context("GeoIP reload worker failed")??; +async fn reload( + app: &Arc, + directory: PathBuf, + format: super::DatabaseFormat, +) -> Result<()> { + let database = + tokio::task::spawn_blocking(move || super::Database::load_format(&directory, format)) + .await + .context("GeoIP reload worker failed")??; publish_database(&app.geoip, database); Ok(()) } @@ -365,7 +452,10 @@ async fn publisher_checksum(url: &str) -> Result { fn ensure_safe_directory(directory: &Path) -> Result<()> { match fs::symlink_metadata(directory) { - Ok(metadata) => ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must be a real directory"), + Ok(metadata) => ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "GeoIP directory must be a real directory" + ), Err(error) if error.kind() == std::io::ErrorKind::NotFound => private_directory(directory)?, Err(error) => return Err(error.into()), } @@ -375,7 +465,10 @@ fn ensure_safe_directory(directory: &Path) -> Result<()> { fn private_directory(path: &Path) -> Result<()> { fs::create_dir_all(path)?; let metadata = fs::symlink_metadata(path)?; - ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must not be a symlink"); + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "GeoIP directory must not be a symlink" + ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -390,15 +483,24 @@ fn file_digest(path: &Path, max_bytes: u64) -> Result> { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error.into()), }; - if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes { return Ok(None); } - let mut options = OpenOptions::new(); options.read(true); - #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); } + if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes { + return Ok(None); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } let mut file = options.open(path)?; let mut digest = Sha256::new(); let mut buffer = [0u8; 1024 * 1024]; loop { let read = file.read(&mut buffer)?; - if read == 0 { break; } + if read == 0 { + break; + } digest.update(&buffer[..read]); } Ok(Some(format!("{:x}", digest.finalize()))) @@ -406,11 +508,20 @@ fn file_digest(path: &Path, max_bytes: u64) -> Result> { fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> { let metadata = fs::symlink_metadata(source)?; - ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP source must be a regular file"); - ensure!(metadata.len() <= max_bytes, "GeoIP source exceeds configured maximum size"); - let mut source_options = OpenOptions::new(); source_options.read(true); - let mut target_options = OpenOptions::new(); target_options.write(true).create_new(true); - #[cfg(unix)] { + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "GeoIP source must be a regular file" + ); + ensure!( + metadata.len() <= max_bytes, + "GeoIP source exceeds configured maximum size" + ); + let mut source_options = OpenOptions::new(); + source_options.read(true); + let mut target_options = OpenOptions::new(); + target_options.write(true).create_new(true); + #[cfg(unix)] + { use std::os::unix::fs::OpenOptionsExt; source_options.custom_flags(libc::O_NOFOLLOW); target_options.mode(0o600).custom_flags(libc::O_NOFOLLOW); @@ -419,7 +530,10 @@ fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> { let mut input = source.take(max_bytes.saturating_add(1)); let mut output = target_options.open(target)?; let copied = std::io::copy(&mut input, &mut output)?; - ensure!(copied <= max_bytes, "GeoIP source exceeds configured maximum size"); + ensure!( + copied <= max_bytes, + "GeoIP source exceeds configured maximum size" + ); output.sync_all()?; Ok(()) } @@ -427,12 +541,26 @@ fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> { fn publish_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> { let parent = target.parent().context("GeoIP target has no parent")?; ensure_safe_directory(parent)?; - let temporary = parent.join(format!(".{}.tmp-{:016x}", target.file_name().unwrap_or_default().to_string_lossy(), rand::random::())); - if let Err(error) = copy_private(source, &temporary, max_bytes) { let _ = fs::remove_file(&temporary); return Err(error); } - if let Err(error) = fs::rename(&temporary, target) { let _ = fs::remove_file(&temporary); return Err(error.into()); } - #[cfg(unix)] { + let temporary = parent.join(format!( + ".{}.tmp-{:016x}", + target.file_name().unwrap_or_default().to_string_lossy(), + rand::random::() + )); + if let Err(error) = copy_private(source, &temporary, max_bytes) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + if let Err(error) = fs::rename(&temporary, target) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + #[cfg(unix)] + { use std::os::unix::fs::OpenOptionsExt; - let directory = OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY).open(parent)?; + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY) + .open(parent)?; directory.sync_all()?; } Ok(()) diff --git a/src/geoip/update_http.rs b/src/geoip/update_http.rs index 95b7471..c836c49 100644 --- a/src/geoip/update_http.rs +++ b/src/geoip/update_http.rs @@ -9,12 +9,7 @@ use hyper_util::{ }; use rustls::ClientConfig; use sha2::{Digest, Sha256}; -use std::{ - fs::OpenOptions, - path::Path, - sync::Arc, - time::Duration, -}; +use std::{fs::OpenOptions, path::Path, sync::Arc, time::Duration}; use tokio::io::AsyncWriteExt; use url::Url; @@ -66,12 +61,11 @@ pub(super) async fn download( fn client() -> Result, Body>> { let roots = crate::update_check::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)); @@ -123,7 +117,10 @@ fn redirect( .to_str() .context("GeoIP redirect Location is not UTF-8")?; let target = crate::update_check::redirect_target(current, location)?; - ensure!(target.scheme() == "https", "GeoIP redirect must remain HTTPS"); + ensure!( + target.scheme() == "https", + "GeoIP redirect must remain HTTPS" + ); Ok(target) } diff --git a/src/geoip/update_tests.rs b/src/geoip/update_tests.rs index 4ac5013..c88e487 100644 --- a/src/geoip/update_tests.rs +++ b/src/geoip/update_tests.rs @@ -76,19 +76,36 @@ mod tests { std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,PL\n").unwrap(); std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,PL\n").unwrap(); let slot = arc_swap::ArcSwap::from_pointee( - crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(), + crate::geoip::Database::load_format( + directory.path(), + crate::geoip::DatabaseFormat::CsvPair, + ) + .unwrap(), ); let previous = slot.load_full(); - assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL")); + assert_eq!( + slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), + Some("PL") + ); std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,DE\n").unwrap(); std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,DE\n").unwrap(); publish_database( &slot, - crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(), + crate::geoip::Database::load_format( + directory.path(), + crate::geoip::DatabaseFormat::CsvPair, + ) + .unwrap(), ); - assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("DE")); - assert_eq!(previous.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL")); + assert_eq!( + slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), + Some("DE") + ); + assert_eq!( + previous.country("192.0.2.7".parse().unwrap()).as_deref(), + Some("PL") + ); } } diff --git a/src/headers/tests.rs b/src/headers/tests.rs index 291a622..90a2735 100644 --- a/src/headers/tests.rs +++ b/src/headers/tests.rs @@ -35,7 +35,10 @@ mod tests { response: vec![], }; assert!(rules.compile().is_err(), "{name}"); - let response_rules = HeaderRules { request: vec![], response: rules.request }; + let response_rules = HeaderRules { + request: vec![], + response: rules.request, + }; assert!(response_rules.compile().is_err(), "response {name}"); } let rules = HeaderRules { diff --git a/src/lib.rs b/src/lib.rs index a7acfae..a6bf4a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,8 @@ pub mod acme; pub mod admin; #[path = "auth/mod.rs"] pub mod auth; -pub mod basic_auth; pub mod backup; +pub mod basic_auth; pub mod config; pub mod db; pub mod demo; @@ -17,26 +17,26 @@ pub mod runtime; pub mod server; pub mod tls; pub mod traffic; -pub mod upstream; pub mod update_check; +pub mod upstream; pub mod user_agent; pub mod cache; pub mod compression; -pub mod transform; pub mod diagnostics; pub mod docker; pub mod pages; pub mod settings; pub mod stream; pub mod system_stats; +pub mod transform; pub mod balance; pub mod forwarding; pub mod geoip; pub mod http3; -pub mod security; pub mod secret_store; +pub mod security; pub mod tinyauth; pub mod vhost; pub mod web_server; diff --git a/src/prometheus/vhosts_backends.rs b/src/prometheus/vhosts_backends.rs index e41023b..4c419c7 100644 --- a/src/prometheus/vhosts_backends.rs +++ b/src/prometheus/vhosts_backends.rs @@ -119,7 +119,11 @@ fn render_backends( ), ("read_timeouts_total", "read_timeouts_total", "counter"), ("send_timeouts_total", "send_timeouts_total", "counter"), - ("upstream_client_generation", "upstream_client_generation", "gauge"), + ( + "upstream_client_generation", + "upstream_client_generation", + "gauge", + ), ( "upstream_client_generation_requests", "upstream_client_generation_requests", @@ -414,7 +418,11 @@ fn render_docker( ), ("read_timeouts_total", "read_timeouts_total", "counter"), ("send_timeouts_total", "send_timeouts_total", "counter"), - ("upstream_client_generation", "upstream_client_generation", "gauge"), + ( + "upstream_client_generation", + "upstream_client_generation", + "gauge", + ), ( "upstream_client_generation_requests", "upstream_client_generation_requests", diff --git a/src/proxy/body/tests.rs b/src/proxy/body/tests.rs index cb37155..2faadab 100644 --- a/src/proxy/body/tests.rs +++ b/src/proxy/body/tests.rs @@ -50,9 +50,8 @@ mod tests { async fn optimized_empty_request_keeps_exchange_until_response() { let (exchange, slots) = observation().await; let metrics = exchange.metrics.clone(); - let body = super::super::wrap_observed_request( - Body::empty(), &exchange, Duration::from_secs(1), - ); + let body = + super::super::wrap_observed_request(Body::empty(), &exchange, Duration::from_secs(1)); assert!(body.is_end_stream()); drop(body); assert_eq!(slots.available_permits(), 0); @@ -66,7 +65,9 @@ mod tests { let (exchange, slots) = observation().await; let metrics = exchange.metrics.clone(); let body = super::super::wrap_observed_request( - Body::from("payload"), &exchange, Duration::from_secs(1), + Body::from("payload"), + &exchange, + Duration::from_secs(1), ); assert!(!body.is_end_stream()); drop(exchange); @@ -272,7 +273,10 @@ mod tests { assert_eq!(entries[0].route.as_deref(), Some("route-1")); assert_eq!(entries[0].client_ip.as_deref(), Some("198.51.100.9")); assert_eq!(entries[0].country.as_deref(), Some("DE")); - assert_eq!(entries[0].upstream_error.as_deref(), Some("upstream closed")); + assert_eq!( + entries[0].upstream_error.as_deref(), + Some("upstream closed") + ); assert_eq!(entries[0].cache_reason, "cache_disabled"); assert_eq!(entries[0].websocket_error, Some("upstream_http_error")); assert_eq!(entries[0].h3_reset_code, Some("h3_general_protocol_error")); @@ -296,7 +300,10 @@ mod tests { assert_eq!(entries[0].host, "localhost"); assert_eq!(entries[0].path, "/echo"); assert_eq!(entries[0].request_id.len(), 64); - assert_eq!(ExchangeSource::name(ExchangeSource::ReverseProxy as u8), "reverse_proxy"); + assert_eq!( + ExchangeSource::name(ExchangeSource::ReverseProxy as u8), + "reverse_proxy" + ); assert_eq!(read_effective(0), None); assert_eq!(read_effective(1), Some(false)); assert_eq!(read_effective(2), Some(true)); diff --git a/src/proxy/cache_helpers.rs b/src/proxy/cache_helpers.rs index 7cd77ac..a3b354f 100644 --- a/src/proxy/cache_helpers.rs +++ b/src/proxy/cache_helpers.rs @@ -268,7 +268,10 @@ fn finish_headers( active: &[crate::vhost::Listener], ) { // Global security headers form a baseline; explicitly set vhost values take precedence. - snapshot.global_security.headers.apply(response.headers_mut(), tls); + snapshot + .global_security + .headers + .apply(response.headers_mut(), tls); if let Some(frontend) = frontend { frontend.security.apply(response.headers_mut(), tls); } diff --git a/src/proxy/cache_http_invalidation.rs b/src/proxy/cache_http_invalidation.rs index 2da14ed..ffb4005 100644 --- a/src/proxy/cache_http_invalidation.rs +++ b/src/proxy/cache_http_invalidation.rs @@ -9,19 +9,28 @@ fn cache_http_invalidation_request( scheme: &str, request: &Request, ) -> Result { - let mut lengths = request.headers().get_all(http::header::CONTENT_LENGTH).iter(); + 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" { return Err("BAN/PURGE must not contain a request body"); } } if request.headers().contains_key("upgrade") - || request.headers().contains_key(http::header::TRANSFER_ENCODING) + || request + .headers() + .contains_key(http::header::TRANSFER_ENCODING) || !request.body().is_end_stream() { return Err("BAN/PURGE must not contain a request body or upgrade"); } - let target = request.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); + 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"); } @@ -35,23 +44,40 @@ fn cache_http_invalidation_request( } else { crate::cache::InvalidationSelector::Exact }; - if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1 - || request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1 + if request + .headers() + .get_all("x-rustproxy-ban-ttl") + .iter() + .count() + > 1 + || request + .headers() + .get_all("x-rustproxy-confirm-wide") + .iter() + .count() + > 1 { return Err("duplicate BAN/PURGE control header"); } let mut ttl_seconds = None; if method.as_str() == "BAN" { if let Some(value) = request.headers().get("x-rustproxy-ban-ttl") { - let seconds = value.to_str().ok().and_then(|v| v.parse::().ok()) + let seconds = value + .to_str() + .ok() + .and_then(|v| v.parse::().ok()) .ok_or("invalid BAN TTL")?; - if !(1..=604_800).contains(&seconds) { return Err("BAN TTL must be 1..604800"); } + if !(1..=604_800).contains(&seconds) { + return Err("BAN TTL must be 1..604800"); + } ttl_seconds = Some(seconds); } } else if request.headers().contains_key("x-rustproxy-ban-ttl") { return Err("BAN TTL is only supported for BAN"); } - let confirm_wide = request.headers().get("x-rustproxy-confirm-wide") + let confirm_wide = request + .headers() + .get("x-rustproxy-confirm-wide") .is_some_and(|value| value == "1"); if target == "/" && method.as_str() == "BAN" && !confirm_wide { return Err("BAN / requires X-RustProxy-Confirm-Wide: 1"); @@ -71,9 +97,13 @@ fn cache_http_invalidation_request( host.to_owned() }; Ok(crate::cache::InvalidationRequest { - operation, selector, value: format!("{scheme}://{authority}{target}"), - host: Some(host.to_owned()), vhost: Some(vhost.to_owned()), - ttl_seconds, confirm_wide, + operation, + selector, + value: format!("{scheme}://{authority}{target}"), + host: Some(host.to_owned()), + vhost: Some(vhost.to_owned()), + ttl_seconds, + confirm_wide, }) } @@ -88,51 +118,95 @@ async fn cache_http_invalidation( scheme: &str, ) -> Response { let Some(frontend) = frontend else { - return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n"); + return error( + StatusCode::NOT_FOUND, + "RustProxy: no matching vhost for cache invalidation\n", + ); }; - if snapshot.forwarding.trusted(peer_ip) - && crate::vhost::canonical_ip(peer_ip) == client_ip + if snapshot.forwarding.trusted(peer_ip) && crate::vhost::canonical_ip(peer_ip) == client_ip { + return error( + StatusCode::FORBIDDEN, + "RustProxy: missing trusted proxy client identity\n", + ); + } + if !snapshot + .envelope + .config + .cache + .permits_http_invalidation(client_ip) { - return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity\n"); + return error( + StatusCode::FORBIDDEN, + "RustProxy: BAN/PURGE client IP is not allowed\n", + ); } - if !snapshot.envelope.config.cache.permits_http_invalidation(client_ip) { - return error(StatusCode::FORBIDDEN, "RustProxy: BAN/PURGE client IP is not allowed\n"); - } - let requested = match cache_http_invalidation_request(request.method(), host, &frontend.id, scheme, &request) { + let requested = match cache_http_invalidation_request( + request.method(), + host, + &frontend.id, + scheme, + &request, + ) { Ok(requested) => requested, Err(reason) => return error(StatusCode::BAD_REQUEST, reason), }; let method = request.method().clone(); drop(request); let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else { - return error(StatusCode::TOO_MANY_REQUESTS, "RustProxy: cache maintenance busy\n"); + return error( + StatusCode::TOO_MANY_REQUESTS, + "RustProxy: cache maintenance busy\n", + ); }; let detail = format!( "client_ip={client_ip}; method={}; host={host}; vhost={}; target={:?}", method, frontend.id, requested.value ); - if let Err(audit_error) = state.store.audit(&format!("http:{client_ip}"), "cache.http_invalidate", &detail).await { + if let Err(audit_error) = state + .store + .audit( + &format!("http:{client_ip}"), + "cache.http_invalidate", + &detail, + ) + .await + { tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed"); - return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n"); + return error( + StatusCode::SERVICE_UNAVAILABLE, + "RustProxy: cache maintenance unavailable\n", + ); } let cache = state.cache.clone(); let result = tokio::task::spawn_blocking(move || { let _permit = permit; cache.invalidate(requested) - }).await; + }) + .await; let result = match result { Ok(Ok(result)) => result, Ok(Err(invalid)) => { tracing::warn!(reason = %invalid, "cache HTTP invalidation rejected"); - return error(StatusCode::UNPROCESSABLE_ENTITY, "RustProxy: invalid cache maintenance request\n"); - }, - Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: cache maintenance failed\n"), + return error( + StatusCode::UNPROCESSABLE_ENTITY, + "RustProxy: invalid cache maintenance request\n", + ); + } + Err(_) => { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: cache maintenance failed\n", + ) + } }; let code = if result.operation == crate::cache::InvalidationOperation::Ban { StatusCode::ACCEPTED - } else { StatusCode::OK }; + } else { + StatusCode::OK + }; let mut response = (code, axum::Json(serde_json::json!({"result": result}))).into_response(); - response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store")); + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); response } - diff --git a/src/proxy/cache_http_invalidation_tests.rs b/src/proxy/cache_http_invalidation_tests.rs index bdb9602..d12a5b3 100644 --- a/src/proxy/cache_http_invalidation_tests.rs +++ b/src/proxy/cache_http_invalidation_tests.rs @@ -2,14 +2,25 @@ mod http_invalidation_request_tests { use super::*; fn request(method: &str, target: &str) -> Request { - Request::builder().method(method).uri(target).body(Body::empty()).unwrap() + Request::builder() + .method(method) + .uri(target) + .body(Body::empty()) + .unwrap() } - fn parse(method: &str, host: &str, scheme: &str, request: &Request) - -> Result - { + fn parse( + method: &str, + host: &str, + scheme: &str, + request: &Request, + ) -> Result { cache_http_invalidation_request( - &Method::from_bytes(method.as_bytes()).unwrap(), host, "frontend-1", scheme, request, + &Method::from_bytes(method.as_bytes()).unwrap(), + host, + "frontend-1", + scheme, + request, ) } @@ -34,36 +45,66 @@ mod http_invalidation_request_tests { fn wide_ban_must_be_confirmed_and_ttl_is_bounded() { assert!(parse("BAN", "a.example", "https", &request("BAN", "/")).is_err()); let mut wide = request("BAN", "/"); - wide.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap()); - assert!(parse("BAN", "a.example", "https", &wide).unwrap().confirm_wide); + wide.headers_mut() + .insert("x-rustproxy-confirm-wide", "1".parse().unwrap()); + assert!( + parse("BAN", "a.example", "https", &wide) + .unwrap() + .confirm_wide + ); let mut ttl = request("BAN", "/foo"); - ttl.headers_mut().insert("x-rustproxy-ban-ttl", "604801".parse().unwrap()); + ttl.headers_mut() + .insert("x-rustproxy-ban-ttl", "604801".parse().unwrap()); assert!(parse("BAN", "a.example", "https", &ttl).is_err()); - ttl.headers_mut().insert("x-rustproxy-ban-ttl", "30".parse().unwrap()); - assert_eq!(parse("BAN", "a.example", "https", &ttl).unwrap().ttl_seconds, Some(30)); - ttl.headers_mut().append("x-rustproxy-ban-ttl", "60".parse().unwrap()); + ttl.headers_mut() + .insert("x-rustproxy-ban-ttl", "30".parse().unwrap()); + assert_eq!( + parse("BAN", "a.example", "https", &ttl) + .unwrap() + .ttl_seconds, + Some(30) + ); + ttl.headers_mut() + .append("x-rustproxy-ban-ttl", "60".parse().unwrap()); assert!(parse("BAN", "a.example", "https", &ttl).is_err()); } #[test] fn maintenance_rejects_bodies_and_ambiguous_or_unsupported_controls() { let mut body = request("PURGE", "/foo"); - body.headers_mut().insert(http::header::CONTENT_LENGTH, "99".parse().unwrap()); + body.headers_mut() + .insert(http::header::CONTENT_LENGTH, "99".parse().unwrap()); assert!(parse("PURGE", "a.example", "https", &body).is_err()); - body.headers_mut().insert(http::header::CONTENT_LENGTH, "0".parse().unwrap()); + body.headers_mut() + .insert(http::header::CONTENT_LENGTH, "0".parse().unwrap()); assert!(parse("PURGE", "a.example", "https", &body).is_ok()); - body.headers_mut().append(http::header::CONTENT_LENGTH, "0".parse().unwrap()); + body.headers_mut() + .append(http::header::CONTENT_LENGTH, "0".parse().unwrap()); assert!(parse("PURGE", "a.example", "https", &body).is_err()); let mut transfer = request("PURGE", "/foo"); - transfer.headers_mut().insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap()); + transfer + .headers_mut() + .insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap()); assert!(parse("PURGE", "a.example", "https", &transfer).is_err()); let mut unexpected = request("PURGE", "/foo"); - unexpected.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap()); + unexpected + .headers_mut() + .insert("x-rustproxy-confirm-wide", "1".parse().unwrap()); assert!(parse("PURGE", "a.example", "https", &unexpected).is_err()); unexpected = request("BAN", "/foo"); - unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap()); - unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap()); + unexpected + .headers_mut() + .append("x-rustproxy-confirm-wide", "1".parse().unwrap()); + unexpected + .headers_mut() + .append("x-rustproxy-confirm-wide", "1".parse().unwrap()); assert!(parse("BAN", "a.example", "https", &unexpected).is_err()); - assert!(parse("PURGE", "a.example", "https", &request("PURGE", &format!("/{}", "a".repeat(4097)))).is_err()); + assert!(parse( + "PURGE", + "a.example", + "https", + &request("PURGE", &format!("/{}", "a".repeat(4097))) + ) + .is_err()); } } diff --git a/src/proxy/cache_plan/tests.rs b/src/proxy/cache_plan/tests.rs index 28fded6..ae37c77 100644 --- a/src/proxy/cache_plan/tests.rs +++ b/src/proxy/cache_plan/tests.rs @@ -16,19 +16,33 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade let settings = settings(); let mut headers = HeaderMap::new(); headers.insert("authorization", HeaderValue::from_static("private")); - headers.insert("cache-control", HeaderValue::from_static("max-age=not-a-number")); + headers.insert( + "cache-control", + HeaderValue::from_static("max-age=not-a-number"), + ); for reason in [ - "cache_disabled", "route_disabled", "admin_control_plane", "basic_auth", - "tinyauth", "sticky_cookie", "request_upgrade", + "cache_disabled", + "route_disabled", + "admin_control_plane", + "basic_auth", + "tinyauth", + "sticky_cookie", + "request_upgrade", ] { let plan = CachePlan::new(Some(reason), &settings, "/style.css", || { panic!("disabled cache must not match force-cache rules") }); assert!(!plan.force_cache()); assert!(!plan.ignore_cookie()); - let policy = plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings); + let policy = + plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings); assert_eq!(policy, RequestPolicy::Bypass(reason)); - assert_eq!(combine_policy(policy, || panic!("disabled cache must not parse post-rule policy")), policy); + assert_eq!( + combine_policy(policy, || panic!( + "disabled cache must not parse post-rule policy" + )), + policy + ); } } @@ -38,23 +52,49 @@ fn cache_on_keeps_all_existing_request_policy_outcomes() { for forced in [false, true] { let plan = CachePlan::new(None, &settings, "/style.css", || forced); for (name, value) in [ - ("x-irrelevant", "yes"), ("cache-control", "no-store"), - ("cache-control", "no-cache"), ("cache-control", "max-age=0"), - ("cache-control", "min-fresh=broken"), ("authorization", "private"), - ("cookie", "session=private"), ("range", "bytes=0-2"), - ("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""), - ("if-match", "\"etag\""), ("upgrade", "websocket"), + ("x-irrelevant", "yes"), + ("cache-control", "no-store"), + ("cache-control", "no-cache"), + ("cache-control", "max-age=0"), + ("cache-control", "min-fresh=broken"), + ("authorization", "private"), + ("cookie", "session=private"), + ("range", "bytes=0-2"), + ("range", "bytes=0-2,4-5"), + ("if-range", "\"etag\""), + ("if-match", "\"etag\""), + ("upgrade", "websocket"), ] { let mut headers = HeaderMap::new(); - headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap()); + headers.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + value.parse().unwrap(), + ); for method in [Method::GET, Method::HEAD, Method::POST] { for body_empty in [false, true] { let expected = if forced { crate::cache::forced_request_policy(&method, &headers, body_empty) } else { - crate::cache::request_policy(&method, &headers, body_empty, "/style.css", false, &settings) + crate::cache::request_policy( + &method, + &headers, + body_empty, + "/style.css", + false, + &settings, + ) }; - assert_eq!(plan.request_policy(&method, &headers, body_empty, "/style.css", false, &settings), expected); + assert_eq!( + plan.request_policy( + &method, + &headers, + body_empty, + "/style.css", + false, + &settings + ), + expected + ); } } } @@ -73,8 +113,12 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() { for original in cases { for rewritten in cases { let expected = match (original, rewritten) { - (RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason), - (RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason), + (RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => { + RequestPolicy::Bypass(reason) + } + (RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => { + RequestPolicy::Revalidate(reason) + } _ => RequestPolicy::Use, }; assert_eq!(combine_policy(original, || rewritten), expected); @@ -85,16 +129,36 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() { #[test] fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() { for method in [Method::GET, Method::HEAD, Method::POST] { - for range in [None, Some("bytes=0-2"), Some("bytes=0-2,4-5"), Some("broken"), Some("bytes=99-")] { + for range in [ + None, + Some("bytes=0-2"), + Some("bytes=0-2,4-5"), + Some("broken"), + Some("bytes=99-"), + ] { let mut headers = HeaderMap::new(); - if let Some(range) = range { headers.insert("range", range.parse().unwrap()); } + if let Some(range) = range { + headers.insert("range", range.parse().unwrap()); + } headers.insert("if-range", HeaderValue::from_static("\"same-validator\"")); - headers.insert("if-none-match", HeaderValue::from_static("\"client-validator\"")); + headers.insert( + "if-none-match", + HeaderValue::from_static("\"client-validator\""), + ); let original = headers.clone(); let baseline = crate::cache::request_range(&method, &headers); assert_eq!(range_selection(true, &method, &headers), baseline); - assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None)); - assert_eq!(headers, original, "origin must receive the original Range/conditional fields"); + assert_eq!( + matches!( + range_selection(false, &method, &headers), + RangeSelection::None + ), + matches!(baseline, RangeSelection::None) + ); + assert_eq!( + headers, original, + "origin must receive the original Range/conditional fields" + ); } } } diff --git a/src/proxy/error_fallback.rs b/src/proxy/error_fallback.rs index 5fa40cc..f5e22e0 100644 --- a/src/proxy/error_fallback.rs +++ b/src/proxy/error_fallback.rs @@ -35,10 +35,7 @@ impl ErrorFallbackRequest { transport: request.extensions().get::().copied(), snapshot: snapshot.clone(), exchange: request.extensions().get::>().cloned(), - connection_lease: request - .extensions() - .get::>() - .cloned(), + connection_lease: request.extensions().get::>().cloned(), stop: request.extensions().get::().cloned(), depth: request .extensions() @@ -48,7 +45,13 @@ impl ErrorFallbackRequest { } } - fn rebuild(&self, body: Bytes, peer: PeerAddr, local: LocalAddr, dispatch: InternalFallbackRoute) -> Request { + fn rebuild( + &self, + body: Bytes, + peer: PeerAddr, + local: LocalAddr, + dispatch: InternalFallbackRoute, + ) -> Request { let mut request = Request::new(Body::from(body)); *request.method_mut() = self.method.clone(); *request.uri_mut() = self.uri.clone(); @@ -83,7 +86,10 @@ enum ErrorFallbackBody { const MAX_ERROR_FALLBACK_DEPTH: u8 = 8; const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024; -async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Result { +async fn prepare_error_fallback_body( + request: &mut Request, + enabled: bool, +) -> Result { if !enabled { return Ok(ErrorFallbackBody::Disabled); } @@ -193,6 +199,8 @@ fn intercept_route_origin_error( } else { response }; - response.extensions_mut().insert(crate::pages::OriginProvidedResponse); + response + .extensions_mut() + .insert(crate::pages::OriginProvidedResponse); response } diff --git a/src/proxy/forward/cache_fast_path_tests.rs b/src/proxy/forward/cache_fast_path_tests.rs index fe271c0..aaec1d1 100644 --- a/src/proxy/forward/cache_fast_path_tests.rs +++ b/src/proxy/forward/cache_fast_path_tests.rs @@ -735,7 +735,10 @@ mod cache_fast_path_tests { let fixture = Fixture::new(config).await; let first = fixture.get("/vary", &[]).await; assert_eq!(first.headers["x-cache"], "MISS"); - assert!(cache_chain(&first.headers).last().unwrap().starts_with("RustProxy; fwd=miss")); + assert!(cache_chain(&first.headers) + .last() + .unwrap() + .starts_with("RustProxy; fwd=miss")); let revalidated = fixture.get("/vary", &[("cache-control", "no-cache")]).await; assert_eq!(revalidated.status, StatusCode::OK); assert_eq!(revalidated.headers["x-cache"], "HIT"); @@ -743,7 +746,9 @@ mod cache_fast_path_tests { cache_chain(&revalidated.headers).last().copied(), Some("RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\""), ); - let posted = fixture.send(Method::POST, "/echo", &[], Body::from("posted")).await; + let posted = fixture + .send(Method::POST, "/echo", &[], Body::from("posted")) + .await; assert_eq!(posted.status, StatusCode::OK); assert_eq!(posted.body, "posted"); assert_eq!( @@ -783,9 +788,10 @@ mod cache_fast_path_tests { let stale = fixture.get("/stale-no-validator", &[]).await; assert_eq!(stale.status, StatusCode::OK); assert_eq!(stale.headers["x-cache"], "MISS"); - assert!( - cache_chain(&stale.headers).last().unwrap().starts_with("RustProxy; fwd=stale"), - ); + assert!(cache_chain(&stale.headers) + .last() + .unwrap() + .starts_with("RustProxy; fwd=stale"),); assert_eq!(origin.requests.load(Ordering::Relaxed), 2); fixture.stop().await; } @@ -942,9 +948,18 @@ mod cache_fast_path_tests { ); let cache_status = cache_chain(&stale.headers); let last = cache_status.last().unwrap(); - let freshness = last.strip_prefix("RustProxy; hit; ttl=").unwrap() - .split(';').next().unwrap().parse::().unwrap(); - assert!(freshness <= 0, "expired response must not claim positive freshness"); + let freshness = last + .strip_prefix("RustProxy; hit; ttl=") + .unwrap() + .split(';') + .next() + .unwrap() + .parse::() + .unwrap(); + assert!( + freshness <= 0, + "expired response must not claim positive freshness" + ); assert!(last.ends_with("; detail=\"cache_stale_while_revalidate\"")); tokio::time::sleep(Duration::from_millis(100)).await; assert_eq!( diff --git a/src/proxy/forward_entry.rs b/src/proxy/forward_entry.rs index a8f2035..4afcb91 100644 --- a/src/proxy/forward_entry.rs +++ b/src/proxy/forward_entry.rs @@ -106,7 +106,11 @@ async fn forward_request( } else { snapshot.frontend(&host, local.0, tls) }; - if snapshot.global_security.content_type_policy.blocks(request.headers()) { + if snapshot + .global_security + .content_type_policy + .blocks(request.headers()) + { return error( StatusCode::FORBIDDEN, "RustProxy: request Content-Type denied by global policy\n", @@ -118,7 +122,10 @@ async fn forward_request( "RustProxy: request Content-Type denied by vhost policy\n", ); } - if snapshot.global_security.user_agent_blocking.blocks(request.headers()) + if snapshot + .global_security + .user_agent_blocking + .blocks(request.headers()) || frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers())) { return error( @@ -170,7 +177,9 @@ async fn forward_request( "RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n", ); } - if let Some(response) = method_policy_denial(&snapshot.global_security.method_policy, request.method()) { + if let Some(response) = + method_policy_denial(&snapshot.global_security.method_policy, request.method()) + { return response; } if !cache_http_invalidation_method(request.method()) { @@ -239,11 +248,24 @@ async fn forward_request( } } // Global rules are cumulative: an explicit IP allow cannot bypass a country deny/allowlist. - if !snapshot.global_security.country_access.allows(country.as_deref()) - || !snapshot.global_security.rate_limit.country_allowed(country.as_deref()) - || snapshot.global_security.acl.as_ref().is_some_and(|acl| !acl.allows(context.client_ip)) + if !snapshot + .global_security + .country_access + .allows(country.as_deref()) + || !snapshot + .global_security + .rate_limit + .country_allowed(country.as_deref()) + || snapshot + .global_security + .acl + .as_ref() + .is_some_and(|acl| !acl.allows(context.client_ip)) { - return error(StatusCode::FORBIDDEN, "RustProxy: denied by global access policy\n"); + return error( + StatusCode::FORBIDDEN, + "RustProxy: denied by global access policy\n", + ); } if internal_dispatch.is_none() { if let Err(limited) = state.rate_limiter.check_with_user_agent( @@ -251,7 +273,10 @@ async fn forward_request( "\u{1f}rustproxy-global-security", context.client_ip, country.as_deref(), - request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()), + request + .headers() + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()), &snapshot.global_security.rate_limit, ) { let mut response = error( @@ -265,12 +290,13 @@ async fn forward_request( } } if let Some(frontend) = frontend { - if !country_resolved && (capture_country - || frontend - .profile_policies - .iter() - .any(|profile| profile.countries.needs_geoip()) - || frontend.rate_limit.needs_geoip()) + if !country_resolved + && (capture_country + || frontend + .profile_policies + .iter() + .any(|profile| profile.countries.needs_geoip()) + || frontend.rate_limit.needs_geoip()) { country = cloudflare_country .clone() @@ -331,14 +357,20 @@ async fn forward_request( if let Some(mut response) = frontend.and_then(|frontend| { canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri()) }) { - snapshot.global_security.headers.apply(response.headers_mut(), tls); + snapshot + .global_security + .headers + .apply(response.headers_mut(), tls); return response; } if !tls { if let Some(mut response) = frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) { - snapshot.global_security.headers.apply(response.headers_mut(), tls); + snapshot + .global_security + .headers + .apply(response.headers_mut(), tls); return response; } } @@ -364,7 +396,10 @@ async fn forward_request( "RustProxy: redirect target could not be rendered\n", ); }; - snapshot.global_security.headers.apply(response.headers_mut(), tls); + snapshot + .global_security + .headers + .apply(response.headers_mut(), tls); response.extensions_mut().insert(RedirectResponse { http3_enabled: _redirect.http3_enabled, }); @@ -372,7 +407,10 @@ async fn forward_request( } if let Some(item) = local_host { let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND); - snapshot.global_security.headers.apply(response.headers_mut(), tls); + snapshot + .global_security + .headers + .apply(response.headers_mut(), tls); response.extensions_mut().insert(LocalHostResponse { http3_enabled: item.http3_enabled, }); diff --git a/src/proxy/headers/tests.rs b/src/proxy/headers/tests.rs index ce2975c..ad617d6 100644 --- a/src/proxy/headers/tests.rs +++ b/src/proxy/headers/tests.rs @@ -23,7 +23,9 @@ mod tests { fn common_connection_tokens_do_not_request_websocket_upgrade() { for connection in [None, Some("keep-alive"), Some("KEEP-ALIVE"), Some("close")] { let mut headers = HeaderMap::new(); - if let Some(value) = connection { headers.insert("connection", value.parse().unwrap()); } + if let Some(value) = connection { + headers.insert("connection", value.parse().unwrap()); + } assert!(!requests_upgrade(&headers)); } let mut headers = HeaderMap::new(); @@ -52,20 +54,29 @@ mod tests { headers.append("set-cookie", "rustproxy_session=secret".parse().unwrap()); headers.append("set-cookie", "b=2; HttpOnly".parse().unwrap()); strip_admin_set_cookie(&mut headers); - let cookies: Vec<_> = headers.get_all("set-cookie").iter() - .map(|value| value.to_str().unwrap()).collect(); + let cookies: Vec<_> = headers + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); assert_eq!(cookies, vec!["a=1; Secure", "b=2; HttpOnly"]); } #[test] fn admin_cookie_can_be_restored_only_from_captured_fragment() { let mut h = HeaderMap::new(); - h.insert("cookie", "a=1; rustproxy_session=secret; b=2".parse().unwrap()); + h.insert( + "cookie", + "a=1; rustproxy_session=secret; b=2".parse().unwrap(), + ); let admin = strip_admin_cookie(&mut h); assert_eq!(h.get("cookie").unwrap(), "a=1; b=2"); assert_eq!(admin, vec!["rustproxy_session=secret"]); restore_admin_cookie(&mut h, &admin); - assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret"); + assert_eq!( + h.get("cookie").unwrap(), + "a=1; b=2; rustproxy_session=secret" + ); } #[test] @@ -87,7 +98,9 @@ mod tests { fn absent_and_close_connection_preserve_non_hop_headers() { for value in [None, Some("close"), Some("CLOSE")] { let mut headers = HeaderMap::new(); - if let Some(value) = value { headers.insert("connection", value.parse().unwrap()); } + if let Some(value) = value { + headers.insert("connection", value.parse().unwrap()); + } headers.insert("x-request-policy", "allowed".parse().unwrap()); headers.insert("transfer-encoding", "chunked".parse().unwrap()); strip_hop_by_hop(&mut headers); diff --git a/src/proxy/identity.rs b/src/proxy/identity.rs index 60ca9eb..941cb82 100644 --- a/src/proxy/identity.rs +++ b/src/proxy/identity.rs @@ -13,9 +13,20 @@ struct SelectedRouteContext { fn take_routing_identity( request: &mut Request, tls: bool, -) -> Result<(Option, HeaderValue, String, Option, bool), Response> { +) -> Result< + ( + Option, + HeaderValue, + String, + Option, + bool, + ), + Response, +> { let mut selected_context = request.extensions_mut().remove::(); - let validated = selected_context.as_mut().and_then(|context| context.validated.take()); + let validated = selected_context + .as_mut() + .and_then(|context| context.validated.take()); let host_already_validated = validated.is_some(); let (original_host, host, decoded_path) = match validated { Some(validated) => ( @@ -24,16 +35,25 @@ fn take_routing_identity( validated.decoded_path, ), None => { - let original_host = effective_host(request, tls).map_err(|_| error( - StatusCode::BAD_REQUEST, - "RustProxy: invalid or conflicting request authority\n", - ))?; - let host = original_host.to_str().ok() + let original_host = effective_host(request, tls).map_err(|_| { + error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid or conflicting request authority\n", + ) + })?; + let host = original_host + .to_str() + .ok() .and_then(|v| request_host(v).ok()) - .ok_or_else(|| error(StatusCode::BAD_REQUEST, - "RustProxy: invalid hostname\n"))?; + .ok_or_else(|| error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n"))?; (original_host, host, None) } }; - Ok((selected_context, original_host, host, decoded_path, host_already_validated)) + Ok(( + selected_context, + original_host, + host, + decoded_path, + host_already_validated, + )) } diff --git a/src/proxy/redirect_helpers.rs b/src/proxy/redirect_helpers.rs index 799b691..c88b1de 100644 --- a/src/proxy/redirect_helpers.rs +++ b/src/proxy/redirect_helpers.rs @@ -29,7 +29,10 @@ fn canonical_host_redirect( } else { ("http", local_port) }; - let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/"); + let path = uri + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/"); let default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80); let location = if default_port { format!("{scheme}://{to}{path}") @@ -37,8 +40,13 @@ fn canonical_host_redirect( format!("{scheme}://{to}:{port}{path}") }; let mut response = StatusCode::PERMANENT_REDIRECT.into_response(); - response.headers_mut().insert(http::header::LOCATION, HeaderValue::from_str(&location).ok()?); - response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store")); + response.headers_mut().insert( + http::header::LOCATION, + HeaderValue::from_str(&location).ok()?, + ); + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); Some(response) } @@ -60,4 +68,3 @@ fn force_ssl_redirect( .insert("cache-control", HeaderValue::from_static("no-store")); Some(response) } - diff --git a/src/proxy/request_id/tests.rs b/src/proxy/request_id/tests.rs index c436707..99134e3 100644 --- a/src/proxy/request_id/tests.rs +++ b/src/proxy/request_id/tests.rs @@ -32,9 +32,7 @@ mod request_id_tests { #[test] fn independent_sequential_ids_keep_full_length_and_unique_values() { - let ids: std::collections::HashSet<_> = (0..4096u64) - .map(request_id_hash) - .collect(); + let ids: std::collections::HashSet<_> = (0..4096u64).map(request_id_hash).collect(); assert_eq!(ids.len(), 4096); assert!(ids.iter().all(|id| id.len() == 64)); } diff --git a/src/proxy/web_cache_revalidation/mod.rs b/src/proxy/web_cache_revalidation/mod.rs index 5780d2a..8a48813 100644 --- a/src/proxy/web_cache_revalidation/mod.rs +++ b/src/proxy/web_cache_revalidation/mod.rs @@ -129,12 +129,15 @@ pub(super) fn finish_web_revalidation( if *method == Method::HEAD { *response.body_mut() = Body::empty(); } - let request_forces_validation = crate::cache::request_policy( - method, client, true, "", false, settings, - ).revalidation_reason().is_some() - || cached.request_freshness_revalidation_reason( - &crate::cache::request_freshness_headers(client, settings), - ).is_some(); + let request_forces_validation = + crate::cache::request_policy(method, client, true, "", false, settings) + .revalidation_reason() + .is_some() + || cached + .request_freshness_revalidation_reason(&crate::cache::request_freshness_headers( + client, settings, + )) + .is_some(); settings.annotate_reason_transformed_forwarded( response.headers_mut(), "HIT", diff --git a/src/proxy/web_cache_revalidation/tests.rs b/src/proxy/web_cache_revalidation/tests.rs index 8fce3e1..4d60726 100644 --- a/src/proxy/web_cache_revalidation/tests.rs +++ b/src/proxy/web_cache_revalidation/tests.rs @@ -154,7 +154,9 @@ async fn strict_browser_refresh_uses_request_forward_reason() { let mut client = HeaderMap::new(); client.insert("cache-control", HeaderValue::from_static("no-cache")); let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) - .await.unwrap().response; + .await + .unwrap() + .response; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()["x-cache"], "HIT"); assert_eq!( diff --git a/src/rate_limit/tests.rs b/src/rate_limit/tests.rs index dcd05fd..6b1cd07 100644 --- a/src/rate_limit/tests.rs +++ b/src/rate_limit/tests.rs @@ -49,10 +49,13 @@ mod tests { assert!(limiter.check(7, "site", first, None, &settings).is_ok()); { let mut state = lock(&limiter.state); - let bucket = state.buckets.get_mut("site").unwrap().get_mut(&first).unwrap(); - bucket.started = Instant::now() - .checked_sub(Duration::from_secs(2)) + let bucket = state + .buckets + .get_mut("site") + .unwrap() + .get_mut(&first) .unwrap(); + bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap(); state.last_sweep = None; } assert!(limiter.check(7, "site", second, None, &settings).is_ok()); @@ -82,9 +85,7 @@ mod tests { .unwrap() .get_mut(&ip) .unwrap(); - bucket.started = Instant::now() - .checked_sub(Duration::from_secs(2)) - .unwrap(); + bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap(); } limiter.maintenance(); let state = lock(&limiter.state); @@ -116,44 +117,76 @@ mod tests { requests: 3, burst: 0, user_agent_limits: vec![UserAgentLimit { - pattern: "*BadBot*".into(), requests: 1, window_seconds: 60, burst: 0, + pattern: "*BadBot*".into(), + requests: 1, + window_seconds: 60, + burst: 0, }], ..Default::default() }; settings.validate().unwrap(); let limiter = Limiter::new(); let ip = "198.51.100.12".parse().unwrap(); - assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("badbot/1.0"), &settings).is_ok()); - assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings).is_err()); + assert!(limiter + .check_with_user_agent(7, "a", ip, None, Some("badbot/1.0"), &settings) + .is_ok()); + assert!(limiter + .check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings) + .is_err()); for _ in 0..3 { - 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_ok()); } - assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_err()); - assert!(limiter.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings).is_ok()); + assert!(limiter + .check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings) + .is_err()); + assert!(limiter + .check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings) + .is_ok()); } #[test] fn user_agent_overrides_work_without_base_rate_or_geoip() { let mut settings = Settings { enabled: false, - user_agent_limits: vec![UserAgentLimit { pattern: "scanner".into(), requests: 1, window_seconds: 60, burst: 0 }], + user_agent_limits: vec![UserAgentLimit { + pattern: "scanner".into(), + requests: 1, + window_seconds: 60, + burst: 0, + }], ..Default::default() }; settings.validate().unwrap(); let limiter = Limiter::new(); let ip = "192.0.2.100".parse().unwrap(); - assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("ScanNER"),&settings).is_ok()); - assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("scanner"),&settings).is_err()); - assert!(limiter.check_with_user_agent(1,"site",ip,None,None,&settings).is_ok()); + assert!(limiter + .check_with_user_agent(1, "site", ip, None, Some("ScanNER"), &settings) + .is_ok()); + assert!(limiter + .check_with_user_agent(1, "site", ip, None, Some("scanner"), &settings) + .is_err()); + assert!(limiter + .check_with_user_agent(1, "site", ip, None, None, &settings) + .is_ok()); } #[test] fn user_agent_override_validation_is_bounded_and_unique() { - let mut settings = Settings { user_agent_limits: vec![ - UserAgentLimit { pattern: "TEST*".into(), ..Default::default() }, - UserAgentLimit { pattern: "test*".into(), ..Default::default() }, - ], ..Default::default() }; + let mut settings = Settings { + user_agent_limits: vec![ + UserAgentLimit { + pattern: "TEST*".into(), + ..Default::default() + }, + UserAgentLimit { + pattern: "test*".into(), + ..Default::default() + }, + ], + ..Default::default() + }; assert!(settings.validate().is_err()); settings.user_agent_limits[1].pattern = "other".into(); assert!(settings.validate().is_ok()); } - } diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 81b448c..c77425e 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -8,15 +8,21 @@ mod cache_revision_tests { let identity = cache_revision(&base); let mut file_transport = base.clone(); - file_transport.web_services.push(crate::web_server::Service { - id: "static-test".into(), name: "Static Test".into(), - document_root: "/var/www/static-test".into(), - ..Default::default() - }); + file_transport + .web_services + .push(crate::web_server::Service { + id: "static-test".into(), + name: "Static Test".into(), + document_root: "/var/www/static-test".into(), + ..Default::default() + }); let mut zero_copy = file_transport.clone(); zero_copy.web_services[0].sendfile = crate::web_server::SendfileMode::Auto; assert_eq!(cache_revision(&file_transport), cache_revision(&zero_copy)); - assert!(matches!(cache_invalidation(&file_transport, &zero_copy), CacheInvalidation::None)); + assert!(matches!( + cache_invalidation(&file_transport, &zero_copy), + CacheInvalidation::None + )); let mut logging = base.clone(); logging.logging.file.path = "/tmp/rustproxy-test-access.jsonl".into(); @@ -88,24 +94,43 @@ mod cache_revision_tests { protocol_only.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled; } assert_ne!( - (protocol_only.vhosts[0].http2_enabled, protocol_only.vhosts[0].http3_enabled), + ( + protocol_only.vhosts[0].http2_enabled, + protocol_only.vhosts[0].http3_enabled + ), (base.vhosts[0].http2_enabled, base.vhosts[0].http3_enabled), "{label} toggle did not change a transport switch" ); - assert_eq!(cache_revision(&protocol_only), identity, "{label} rotated cache"); - assert!(matches!(cache_invalidation(&base, &protocol_only), CacheInvalidation::None)); - assert!(matches!(cache_invalidation(&protocol_only, &base), CacheInvalidation::None)); + assert_eq!( + cache_revision(&protocol_only), + identity, + "{label} rotated cache" + ); + assert!(matches!( + cache_invalidation(&base, &protocol_only), + CacheInvalidation::None + )); + assert!(matches!( + cache_invalidation(&protocol_only, &base), + CacheInvalidation::None + )); } let mut both_protocols = base.clone(); both_protocols.vhosts[0].http2_enabled = !base.vhosts[0].http2_enabled; both_protocols.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled; assert_eq!(cache_revision(&both_protocols), identity); - assert!(matches!(cache_invalidation(&base, &both_protocols), CacheInvalidation::None)); + assert!(matches!( + cache_invalidation(&base, &both_protocols), + CacheInvalidation::None + )); let mut vhost_diagnostics = base.clone(); vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true); assert_eq!(cache_revision(&vhost_diagnostics), identity); - assert!(matches!(cache_invalidation(&base, &vhost_diagnostics), CacheInvalidation::None)); + assert!(matches!( + cache_invalidation(&base, &vhost_diagnostics), + CacheInvalidation::None + )); let mut cache = base.clone(); cache.cache.max_entries += 1; @@ -118,12 +143,30 @@ mod cache_revision_tests { let mut operational = base.clone(); operational.vhosts[0].load_balancing.keepalive.enabled = false; - operational.vhosts[0].load_balancing.keepalive.idle_timeout_seconds = 90; - operational.vhosts[0].load_balancing.keepalive.max_idle_per_host = 32; - operational.vhosts[0].load_balancing.keepalive.tcp_keepalive_seconds = 90; - operational.vhosts[0].load_balancing.keepalive.pool_recycle_requests = 1_000; - operational.vhosts[0].load_balancing.keepalive.pool_recycle_seconds = 1_800; - operational.vhosts[0].load_balancing.keepalive.http1_max_buffer_bytes = 65_536; + operational.vhosts[0] + .load_balancing + .keepalive + .idle_timeout_seconds = 90; + operational.vhosts[0] + .load_balancing + .keepalive + .max_idle_per_host = 32; + operational.vhosts[0] + .load_balancing + .keepalive + .tcp_keepalive_seconds = 90; + operational.vhosts[0] + .load_balancing + .keepalive + .pool_recycle_requests = 1_000; + operational.vhosts[0] + .load_balancing + .keepalive + .pool_recycle_seconds = 1_800; + operational.vhosts[0] + .load_balancing + .keepalive + .http1_max_buffer_bytes = 65_536; operational.vhosts[0].backends[0].weight += 1; operational.vhosts[0].backends[0].max_concurrent_requests = 16; operational.vhosts[0].backends[0].queue_timeout_ms = 100; @@ -132,15 +175,26 @@ mod cache_revision_tests { operational.vhosts[0].user_agent_blocking = Default::default(); operational.vhosts[0].proxy_timeouts.read_timeout_seconds = Some(180); assert_eq!(cache_revision(&operational), identity); - assert_eq!(vhost_cache_revision(&operational.vhosts[0]), vhost_cache_revision(&base.vhosts[0])); - assert!(matches!(cache_invalidation(&base, &operational), CacheInvalidation::None)); - assert!(matches!(cache_invalidation(&operational, &base), CacheInvalidation::None)); + assert_eq!( + vhost_cache_revision(&operational.vhosts[0]), + vhost_cache_revision(&base.vhosts[0]) + ); + assert!(matches!( + cache_invalidation(&base, &operational), + CacheInvalidation::None + )); + assert!(matches!( + cache_invalidation(&operational, &base), + CacheInvalidation::None + )); let mut vhost = base.clone(); vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into(); assert_eq!(cache_revision(&vhost), identity); let affected_id = base.vhosts[0].id.clone(); - assert!(matches!(cache_invalidation(&base, &vhost), CacheInvalidation::Scopes(ids) if ids == vec![affected_id])); + assert!( + matches!(cache_invalidation(&base, &vhost), CacheInvalidation::Scopes(ids) if ids == vec![affected_id]) + ); let mut multiple = base.clone(); let mut another = multiple.vhosts[0].clone(); @@ -149,10 +203,15 @@ mod cache_revision_tests { multiple.vhosts.push(another); let mut changed_one = multiple.clone(); changed_one.vhosts[0].backends[0].url = "http://127.0.0.1:9002".into(); - assert!(matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids) - if ids == vec![base.vhosts[0].id.clone()])); + assert!( + matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids) + if ids == vec![base.vhosts[0].id.clone()]) + ); let mut changed_global = multiple.clone(); changed_global.transform.max_body_bytes += 1; - assert!(matches!(cache_invalidation(&multiple, &changed_global), CacheInvalidation::All(_))); + assert!(matches!( + cache_invalidation(&multiple, &changed_global), + CacheInvalidation::All(_) + )); } } diff --git a/src/server/http.rs b/src/server/http.rs index f1ed03b..5c6f57b 100644 --- a/src/server/http.rs +++ b/src/server/http.rs @@ -153,7 +153,16 @@ fn log_protocol_error( tls_sni: Option, ) { log_protocol_error_with_identity( - state, kind, detail, peer, local, transport, http_version, status, None, None, + state, + kind, + detail, + peer, + local, + transport, + http_version, + status, + None, + None, tls_sni, ); } diff --git a/src/server/http1_connection.rs b/src/server/http1_connection.rs index c79acc1..327ec71 100644 --- a/src/server/http1_connection.rs +++ b/src/server/http1_connection.rs @@ -205,4 +205,3 @@ async fn connection( }, } } - diff --git a/src/server/listeners.rs b/src/server/listeners.rs index 68dca63..65296e9 100644 --- a/src/server/listeners.rs +++ b/src/server/listeners.rs @@ -201,12 +201,12 @@ fn h2_headers_frame(request: &Request) -> std::result::Result> 16) & 0xff) as u8, ((length >> 8) & 0xff) as u8, (length & 0xff) as u8, - 0x1, - 0x5, + 0x1, + 0x5, 0, 0, 0, - 1, + 1, ]); frame.extend_from_slice(&block); Ok(Bytes::from(frame)) diff --git a/src/server/sendfile.rs b/src/server/sendfile.rs index 814fde5..8dac1f0 100644 --- a/src/server/sendfile.rs +++ b/src/server/sendfile.rs @@ -5,11 +5,14 @@ use std::{ io, os::fd::{AsRawFd, RawFd}, pin::Pin, - sync::{Arc, Mutex}, sync::atomic::Ordering, + sync::{Arc, Mutex}, task::{Context, Poll}, }; -use tokio::{io::{AsyncRead, AsyncWrite, Interest, ReadBuf}, net::TcpStream}; +use tokio::{ + io::{AsyncRead, AsyncWrite, Interest, ReadBuf}, + net::TcpStream, +}; const TAG_LEN: usize = 32; const FRAME_LEN: usize = 64 * 1024; @@ -54,24 +57,37 @@ impl SendfileSlots { return None; } let mut registry = self.0.lock().unwrap_or_else(|e| e.into_inner()); - if registry.slots.len() >= MAX_PENDING { return None; } + if registry.slots.len() >= MAX_PENDING { + return None; + } let mut tag = [0u8; TAG_LEN]; - tag[0] = 0; + 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; } + if registry.slots.iter().any(|slot| slot.tag == tag) { + return None; + } let mut data = vec![0u8; FRAME_LEN]; - for chunk in data.chunks_exact_mut(TAG_LEN) { chunk.copy_from_slice(&tag); } + for chunk in data.chunks_exact_mut(TAG_LEN) { + chunk.copy_from_slice(&tag); + } registry.slots.push(Slot { - tag, file: candidate.file, length: candidate.length, written: 0, + tag, + file: candidate.file, + length: candidate.length, + written: 0, }); Some(SendfileBody { - bytes: Bytes::from(data), remaining: candidate.length, web_metrics, + bytes: Bytes::from(data), + remaining: candidate.length, + web_metrics, }) } fn plan(&self, buf: &[u8]) -> WritePlan { let registry = self.0.lock().unwrap_or_else(|e| e.into_inner()); - if registry.slots.is_empty() { return WritePlan::Plain(buf.len()); } + 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) { return plan_file(slot, buf); @@ -79,12 +95,16 @@ impl SendfileSlots { return WritePlan::Invalid; } for start in 0..buf.len() { - if buf[start] != 0 { continue; } + if buf[start] != 0 { + continue; + } for slot in ®istry.slots { let available = buf.len() - start; let compare = available.min(TAG_LEN); if buf[start..start + compare] == slot.tag[..compare] { - if start > 0 { return WritePlan::Plain(start); } + if start > 0 { + return WritePlan::Plain(start); + } return plan_file(slot, buf); } } @@ -107,36 +127,58 @@ impl SendfileSlots { enum WritePlan { Plain(usize), - File { tag: [u8; TAG_LEN], fd: RawFd, file: Arc, offset: u64, count: usize }, + File { + tag: [u8; TAG_LEN], + fd: RawFd, + file: Arc, + offset: u64, + count: usize, + }, Invalid, } fn plan_file(slot: &Slot, buf: &[u8]) -> WritePlan { let expected = (slot.length - slot.written).min(buf.len() as u64) as usize; - let matched = buf[..expected].iter().enumerate() + let matched = buf[..expected] + .iter() + .enumerate() .take_while(|(n, byte)| **byte == slot.tag[(slot.written as usize + n) % TAG_LEN]) .count(); - if matched == 0 { return WritePlan::Invalid; } + if matched == 0 { + return WritePlan::Invalid; + } WritePlan::File { - tag: slot.tag, fd: slot.file.as_raw_fd(), file: slot.file.clone(), - offset: slot.written, count: matched, + tag: slot.tag, + fd: slot.file.as_raw_fd(), + file: slot.file.clone(), + offset: slot.written, + count: matched, } } impl HttpBody for SendfileBody { type Data = Bytes; type Error = io::Error; - fn poll_frame(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) - -> Poll, io::Error>>> - { - if self.remaining == 0 { return Poll::Ready(None); } + fn poll_frame( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, io::Error>>> { + if self.remaining == 0 { + return Poll::Ready(None); + } let len = self.remaining.min(FRAME_LEN as u64) as usize; self.remaining -= len as u64; - self.web_metrics.response_bytes.fetch_add(len as u64, Ordering::Relaxed); + self.web_metrics + .response_bytes + .fetch_add(len as u64, Ordering::Relaxed); Poll::Ready(Some(Ok(Frame::data(self.bytes.slice(0..len))))) } - fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) } - fn is_end_stream(&self) -> bool { self.remaining == 0 } + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.remaining) + } + fn is_end_stream(&self) -> bool { + self.remaining == 0 + } } pub(crate) struct SendfileIo { @@ -146,22 +188,46 @@ pub(crate) struct SendfileIo { impl SendfileIo { pub fn new(stream: TcpStream) -> (Self, SendfileSlots) { let slots = SendfileSlots::default(); - (Self { stream, slots: slots.clone() }, slots) + ( + Self { + stream, + slots: slots.clone(), + }, + slots, + ) } } impl AsyncRead for SendfileIo { - fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { Pin::new(&mut self.get_mut().stream).poll_read(cx, buf) } } impl AsyncWrite for SendfileIo { - fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - if buf.is_empty() { return Poll::Ready(Ok(0)); } + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } match self.slots.plan(buf) { WritePlan::Plain(count) => Pin::new(&mut self.stream).poll_write(cx, &buf[..count]), - WritePlan::Invalid => Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, - "sendfile placeholder did not match its registered descriptor"))), - WritePlan::File { tag, fd, file, offset, count } => { + WritePlan::Invalid => Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "sendfile placeholder did not match its registered descriptor", + ))), + WritePlan::File { + tag, + fd, + file, + offset, + count, + } => { let _keep_open = file; let socket: RawFd = self.stream.as_raw_fd(); let mut at: libc::off_t = offset as libc::off_t; @@ -171,13 +237,22 @@ impl AsyncWrite for SendfileIo { Poll::Ready(Ok(())) => { let result = self.stream.try_io(Interest::WRITABLE, || { let n = unsafe { libc::sendfile(socket, fd, &mut at, count) }; - if n < 0 { Err(io::Error::last_os_error()) } - else if n == 0 { Err(io::Error::new(io::ErrorKind::UnexpectedEof, - "static source truncated during sendfile")) } - else { Ok(n as usize) } + if n < 0 { + Err(io::Error::last_os_error()) + } else if n == 0 { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "static source truncated during sendfile", + )) + } else { + Ok(n as usize) + } }); match result { - Ok(n) => { self.slots.commit(tag, n); Poll::Ready(Ok(n)) }, + Ok(n) => { + self.slots.commit(tag, n); + Poll::Ready(Ok(n)) + } Err(e) if e.kind() == io::ErrorKind::WouldBlock => Poll::Pending, Err(e) => Poll::Ready(Err(e)), } @@ -192,9 +267,22 @@ impl AsyncWrite for SendfileIo { fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { Pin::new(&mut self.stream).poll_shutdown(cx) } - 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> { - if self.slots.0.lock().unwrap_or_else(|e| e.into_inner()).slots.is_empty() { + 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> { + 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); } match bufs.iter().find(|part| !part.is_empty()) { @@ -208,8 +296,8 @@ impl AsyncWrite for SendfileIo { mod tests { use super::*; use http_body::Body; - use tempfile::tempfile; use std::io::Write; + use tempfile::tempfile; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[test] @@ -218,19 +306,42 @@ mod tests { file.write_all(&vec![42u8; 100_000]).unwrap(); let slots = SendfileSlots::default(); let counter = Arc::new(crate::web_server::metrics::PhpMetrics::default()); - let mut body = slots.create_body(SendfileCandidate { file: Arc::new(file), length: 100_000 }, counter.clone()).unwrap(); - let first = match Pin::new(&mut body).poll_frame(&mut Context::from_waker(std::task::Waker::noop())) { - Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), _ => panic!("frame missing"), + let mut body = slots + .create_body( + SendfileCandidate { + file: Arc::new(file), + length: 100_000, + }, + counter.clone(), + ) + .unwrap(); + let first = match Pin::new(&mut body) + .poll_frame(&mut Context::from_waker(std::task::Waker::noop())) + { + Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), + _ => panic!("frame missing"), }; assert_eq!(first.len(), FRAME_LEN); - match slots.plan(&first) { WritePlan::File { count, .. } => assert_eq!(count, FRAME_LEN), _ => panic!("missing kernel file plan") } + match slots.plan(&first) { + WritePlan::File { count, .. } => assert_eq!(count, FRAME_LEN), + _ => panic!("missing kernel file plan"), + } let tag = slots.0.lock().unwrap().slots[0].tag; slots.commit(tag, FRAME_LEN); - let second = match Pin::new(&mut body).poll_frame(&mut Context::from_waker(std::task::Waker::noop())) { - Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), _ => panic!("frame missing"), + let second = match Pin::new(&mut body) + .poll_frame(&mut Context::from_waker(std::task::Waker::noop())) + { + Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), + _ => panic!("frame missing"), }; assert_eq!(second.len(), 34_464); - match slots.plan(&second) { WritePlan::File { count, offset, .. } => { assert_eq!(offset, 65_536); assert_eq!(count, 34_464); }, _ => panic!("second plan missing") } + match slots.plan(&second) { + WritePlan::File { count, offset, .. } => { + assert_eq!(offset, 65_536); + assert_eq!(count, 34_464); + } + _ => panic!("second plan missing"), + } slots.commit(tag, second.len()); assert!(slots.0.lock().unwrap().slots.is_empty()); assert!(body.is_end_stream()); @@ -245,36 +356,74 @@ mod tests { let (tcp, _) = listener.accept().await.unwrap(); let (mut socket, slots) = SendfileIo::new(tcp); for round in 0..2 { - let expected = if round == 0 { vec![0, 1, 2, 3].repeat(25_000) } - else { vec![9u8; 100_003] }; + let expected = if round == 0 { + vec![0, 1, 2, 3].repeat(25_000) + } else { + vec![9u8; 100_003] + }; let mut file = tempfile().unwrap(); file.write_all(&expected).unwrap(); let web = Arc::new(crate::web_server::metrics::PhpMetrics::default()); - let body = slots.create_body(SendfileCandidate { - 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()); + let body = slots + .create_body( + SendfileCandidate { + 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() + ); let mut body = body; - let first_frame = http_body_util::BodyExt::frame(&mut body).await.unwrap().unwrap().into_data().unwrap(); + let first_frame = http_body_util::BodyExt::frame(&mut body) + .await + .unwrap() + .unwrap() + .into_data() + .unwrap(); let mut joined = first.into_bytes(); joined.extend_from_slice(&first_frame); socket.write_all(&joined).await.unwrap(); while let Some(frame) = http_body_util::BodyExt::frame(&mut body).await { - socket.write_all(&frame.unwrap().into_data().unwrap()).await.unwrap(); + socket + .write_all(&frame.unwrap().into_data().unwrap()) + .await + .unwrap(); } - assert_eq!(web.response_bytes.load(Ordering::Relaxed), expected.len() as u64); + assert_eq!( + web.response_bytes.load(Ordering::Relaxed), + expected.len() as u64 + ); } socket.shutdown().await.unwrap(); }); let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); let mut actual = Vec::new(); - tokio::time::timeout(std::time::Duration::from_secs(10), client.read_to_end(&mut actual)).await.unwrap().unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(10), + client.read_to_end(&mut actual), + ) + .await + .unwrap() + .unwrap(); server.await.unwrap(); let mut wanted = Vec::new(); for expected in [vec![0, 1, 2, 3].repeat(25_000), vec![9u8; 100_003]] { - wanted.extend_from_slice(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", expected.len()).as_bytes()); + wanted.extend_from_slice( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", + expected.len() + ) + .as_bytes(), + ); wanted.extend_from_slice(&expected); } - assert_eq!(actual, wanted, "kernel sendfile must never leak placeholder markers"); + assert_eq!( + actual, wanted, + "kernel sendfile must never leak placeholder markers" + ); } } diff --git a/src/settings.rs b/src/settings.rs index 676fd77..f640a58 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -67,9 +67,12 @@ fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAdd return; } 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 - }) { + if let Some(endpoint) = config + .listeners + .endpoints + .iter_mut() + .find(|endpoint| endpoint.id == canonical_id && endpoint.binding.tls == tls) + { endpoint.binding.address = address; endpoint.additional_addresses.clear(); endpoint.enabled = true; @@ -166,7 +169,13 @@ pub struct PanelCompression { pub gzip: bool, } impl Default for PanelCompression { - fn default() -> Self { Self { zstd: true, br: true, gzip: true } } + fn default() -> Self { + Self { + zstd: true, + br: true, + gzip: true, + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -248,7 +257,8 @@ impl Administration { *public_origin = normalize_public_admin_origin(public_origin)?; } if let Some(host) = self.public_origin_host().map(str::to_owned) { - self.allowed_hosts.retain(|allowed| !allowed.eq_ignore_ascii_case(&host)); + self.allowed_hosts + .retain(|allowed| !allowed.eq_ignore_ascii_case(&host)); } Ok(()) } diff --git a/src/settings/public_origin.rs b/src/settings/public_origin.rs index 4e22cae..b673713 100644 --- a/src/settings/public_origin.rs +++ b/src/settings/public_origin.rs @@ -3,7 +3,8 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result { public_origin.len() <= 255 && !public_origin.chars().any(|c| matches!(c, '@' | '#' | '?' | ',' | '\\')), "administration public_origin must be a single HTTPS origin without userinfo, query or fragment" ); - let uri = public_origin.parse::() + let uri = public_origin + .parse::() .context("invalid administration public_origin")?; ensure!( uri.scheme_str() == Some("https") @@ -11,7 +12,8 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result { && matches!(uri.path(), "" | "/"), "administration public_origin must be HTTPS without a path or query" ); - let authority = uri.authority() + let authority = uri + .authority() .context("administration public_origin requires an authority")?; let host = crate::config::normalize_host(authority.host())?; ensure!( @@ -19,9 +21,13 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result { "administration public_origin must name a DNS hostname" ); let port = if authority.as_str().contains(':') { - let port = authority.port_u16() + let port = authority + .port_u16() .context("invalid administration public_origin port")?; - ensure!(port > 0, "administration public_origin port must be nonzero"); + ensure!( + port > 0, + "administration public_origin port must be nonzero" + ); format!(":{port}") } else { String::new() @@ -31,17 +37,26 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result { impl Administration { pub fn public_origin_host(&self) -> Option<&str> { - self.public_origin.as_deref() + self.public_origin + .as_deref() .and_then(|url| url.strip_prefix("https://")) .and_then(|authority| authority.split(':').next()) } 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)) + 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)) } 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)) { + if !self + .allowed_hosts + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(&host)) + { self.allowed_hosts.push(host); } } diff --git a/src/settings/tests.rs b/src/settings/tests.rs index added5c..d52dcc5 100644 --- a/src/settings/tests.rs +++ b/src/settings/tests.rs @@ -15,11 +15,29 @@ mod tests { }; configured.validate().unwrap(); assert_eq!(configured.idle_timeout(), Duration::from_secs(45)); - assert_eq!(configured.max_connection_lifetime(), Some(Duration::from_secs(3_600))); + assert_eq!( + configured.max_connection_lifetime(), + Some(Duration::from_secs(3_600)) + ); assert_eq!(configured.drain_timeout(), Duration::from_secs(20)); - assert!(Http2Connections { idle_timeout_seconds: 0, ..defaults }.validate().is_err()); - assert!(Http2Connections { max_connection_lifetime_seconds: 2_592_001, ..defaults }.validate().is_err()); - assert!(Http2Connections { drain_timeout_seconds: 0, ..defaults }.validate().is_err()); + assert!(Http2Connections { + idle_timeout_seconds: 0, + ..defaults + } + .validate() + .is_err()); + assert!(Http2Connections { + max_connection_lifetime_seconds: 2_592_001, + ..defaults + } + .validate() + .is_err()); + assert!(Http2Connections { + drain_timeout_seconds: 0, + ..defaults + } + .validate() + .is_err()); } #[test] fn new_install_server_header_defaults_to_rustproxy_without_changing_legacy_missing_field() { @@ -120,13 +138,29 @@ mod tests { locks.ensure_config(&mut config); - let http = config.listeners.endpoints.iter().find(|item| item.id == "http").unwrap(); - let https = config.listeners.endpoints.iter().find(|item| item.id == "https").unwrap(); + let http = config + .listeners + .endpoints + .iter() + .find(|item| item.id == "http") + .unwrap(); + let https = config + .listeners + .endpoints + .iter() + .find(|item| item.id == "https") + .unwrap(); assert_eq!(http.binding.address, "0.0.0.0:8181".parse().unwrap()); assert!(http.additional_addresses.is_empty()); assert_eq!(https.binding.address, "0.0.0.0:8443".parse().unwrap()); assert!(https.additional_addresses.is_empty()); - assert!(!config.listeners.endpoints.iter().filter(|item| item.enabled).flat_map(|item| item.bindings()).any(|binding| binding.address.port() == 80 || binding.address.port() == 443)); + assert!(!config + .listeners + .endpoints + .iter() + .filter(|item| item.enabled) + .flat_map(|item| item.bindings()) + .any(|binding| binding.address.port() == 80 || binding.address.port() == 443)); } #[test] @@ -264,19 +298,32 @@ mod tests { ..Administration::default() }; admin.validate(&tls).unwrap(); - assert_eq!(admin.public_origin.as_deref(), Some("https://panel.example.test:8443")); + assert_eq!( + admin.public_origin.as_deref(), + Some("https://panel.example.test:8443") + ); assert_eq!(admin.public_origin_host(), Some("panel.example.test")); - assert!(admin.allowed_hosts.is_empty(), "pin must not be stored twice"); + assert!( + admin.allowed_hosts.is_empty(), + "pin must not be stored twice" + ); assert!(admin.explicitly_allowed_panel_host("PANEL.EXAMPLE.TEST")); assert!(!admin.explicitly_allowed_panel_host("other.example.test")); let mut runtime = admin.clone(); runtime.include_public_origin_host(); runtime.include_public_origin_host(); assert_eq!(runtime.allowed_hosts, vec!["panel.example.test".to_owned()]); - assert!(admin.allowed_hosts.is_empty(), "saved config remains unchanged"); - let active = Administration { public_origin: None, ..admin.clone() }; + assert!( + admin.allowed_hosts.is_empty(), + "saved config remains unchanged" + ); + let active = Administration { + public_origin: None, + ..admin.clone() + }; assert!(admin.restart_differs(&active)); - let mut legacy: Administration = serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap(); + let mut legacy: Administration = + serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap(); legacy.validate(&tls).unwrap(); assert_eq!(legacy.public_origin, None); assert!(legacy.explicitly_allowed_panel_host("panel.example.test")); diff --git a/src/tls/certificates.rs b/src/tls/certificates.rs index 18495ae..e886449 100644 --- a/src/tls/certificates.rs +++ b/src/tls/certificates.rs @@ -132,7 +132,9 @@ pub fn import(directory: &Path, name: &str, cert: PathBuf, key: PathBuf) -> Resu #[derive(Debug, PartialEq, Eq)] pub enum ExternalPairDeployment { - Unchanged { covered_hosts: Vec }, + Unchanged { + covered_hosts: Vec, + }, Staged { cert_file: String, key_file: String, diff --git a/src/tls/server_config.rs b/src/tls/server_config.rs index 593f72d..adcac2a 100644 --- a/src/tls/server_config.rs +++ b/src/tls/server_config.rs @@ -358,7 +358,10 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result = counters.view().statuses.into_iter() - .map(|entry| entry.value).collect(); + let labels: std::collections::HashSet<_> = counters + .view() + .statuses + .into_iter() + .map(|entry| entry.value) + .collect(); for label in ["1xx", "2xx", "3xx", "4xx", "5xx", "7xx"] { assert!(labels.contains(label)); } @@ -113,12 +133,23 @@ mod tests { let analytics = Analytics::start(shutdown.clone()); for id in 0..130 { analytics.push(Sample { - vhost: if id % 2 == 0 { "left".into() } else { "right".into() }, - route: None, method: "GET".into(), path: "/small".into(), - client_ip: None, status: 200, duration_ms: 1, - request_bytes: 0, response_bytes: 1040, - cache: "BYPASS", source: "reverse_proxy", - user_agent: None, country: None, + vhost: if id % 2 == 0 { + "left".into() + } else { + "right".into() + }, + route: None, + method: "GET".into(), + path: "/small".into(), + client_ip: None, + status: 200, + duration_ms: 1, + request_bytes: 0, + response_bytes: 1040, + cache: "BYPASS", + source: "reverse_proxy", + user_agent: None, + country: None, }); } tokio::time::timeout(std::time::Duration::from_secs(2), async { @@ -127,7 +158,9 @@ mod tests { { tokio::task::yield_now().await; } - }).await.expect("analytics should drain two batches"); + }) + .await + .expect("analytics should drain two batches"); let all = analytics.view(None).data; assert_eq!(all.requests, 130); assert_eq!(all.response_bytes, 130 * 1040); @@ -136,5 +169,4 @@ mod tests { assert_eq!(analytics.view(None).data.requests, 65); shutdown.cancel(); } - } diff --git a/src/transform/tests.rs b/src/transform/tests.rs index 5854251..c597b08 100644 --- a/src/transform/tests.rs +++ b/src/transform/tests.rs @@ -427,7 +427,13 @@ mod tests { let result = css::minify(input.as_bytes()).expect("minify panel stylesheet"); let output = std::str::from_utf8(&result).expect("UTF-8 CSS"); assert!(output.contains("html:is([data-corner-style=square],[data-corner-style=subtle],[data-corner-style=rounded],[data-corner-style=extra]) :is("), "lost descendant pseudo-class combinator"); - assert!(output.contains(".card,.stat,dialog,.login-card,"), "lost card selector list"); - assert!(output.contains("border-radius:var(--rp-radius-surface) !important"), "lost radius declaration"); + assert!( + output.contains(".card,.stat,dialog,.login-card,"), + "lost card selector list" + ); + assert!( + output.contains("border-radius:var(--rp-radius-surface) !important"), + "lost radius declaration" + ); } } diff --git a/src/upstream/client_tests.rs b/src/upstream/client_tests.rs index 3d19dbe..d922779 100644 --- a/src/upstream/client_tests.rs +++ b/src/upstream/client_tests.rs @@ -49,7 +49,6 @@ mod tests { assert_eq!(stats.generation, 1); } - #[test] fn generation_recycles_after_age_without_request() { let backend = backend("rotation-age-test", 18083); @@ -101,14 +100,18 @@ mod tests { keepalive.pool_recycle_requests = 1_000; keepalive.pool_recycle_seconds = 0; let client = client_with_keepalive( - &backend, AddressPolicy::new(&backend).unwrap(), false, &keepalive, - ).unwrap(); - for _ in 0..1_000 { client.inner.client_for_request(); } + &backend, + AddressPolicy::new(&backend).unwrap(), + false, + &keepalive, + ) + .unwrap(); + for _ in 0..1_000 { + client.inner.client_for_request(); + } assert_eq!(client.rotation_stats().rotations, 0); client.inner.client_for_request(); assert_eq!(client.rotation_stats().rotations, 1); assert_eq!(client.rotation_stats().generation_requests, 1); } - } - diff --git a/src/upstream/protocol_tests.rs b/src/upstream/protocol_tests.rs index c50c175..7bc2376 100644 --- a/src/upstream/protocol_tests.rs +++ b/src/upstream/protocol_tests.rs @@ -232,9 +232,11 @@ async fn tls_authority_origin( let key = rustls::pki_types::PrivatePkcs8KeyDer::from(pair.signing_key.serialize_der()); let provider = Arc::new(rustls::crypto::ring::default_provider()); let mut config = rustls::ServerConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions().unwrap() + .with_safe_default_protocol_versions() + .unwrap() .with_no_client_auth() - .with_single_cert(vec![pair.cert.der().clone()], key.into()).unwrap(); + .with_single_cert(vec![pair.cert.der().clone()], key.into()) + .unwrap(); config.alpn_protocols = if offer_h2 { vec![b"h2".to_vec(), b"http/1.1".to_vec()] } else { @@ -253,7 +255,9 @@ async fn tls_authority_origin( async move { if let Some(tx) = observed.lock().unwrap().take() { let authority = request.uri().authority().map(|v| v.as_str().to_owned()); - let host = request.headers().get("host") + let host = request + .headers() + .get("host") .map(|v| v.to_str().unwrap().to_owned()); let _ = tx.send((request.version(), authority, host, sni)); } @@ -262,10 +266,12 @@ async fn tls_authority_origin( }); if offer_h2 { let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) - .serve_connection(TokioIo::new(tls), service).await; + .serve_connection(TokioIo::new(tls), service) + .await; } else { let _ = hyper::server::conn::http1::Builder::new() - .serve_connection(TokioIo::new(tls), service).await; + .serve_connection(TokioIo::new(tls), service) + .await; } }); (address, observed_rx, server) @@ -278,16 +284,23 @@ async fn strict_h2_preserves_public_authority_while_tcp_and_sni_use_private_orig backend.tls_server_name = Some("localhost".into()); backend.preserve_host = true; let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); - let request = Request::builder().version(Version::HTTP_2) + let request = Request::builder() + .version(Version::HTTP_2) .uri("https://unitraklub.pl/block_refresh/block/10") - .body(Body::empty()).unwrap(); + .body(Body::empty()) + .unwrap(); let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) - .await.unwrap().unwrap(); + .await + .unwrap() + .unwrap(); assert_eq!(response.version(), Version::HTTP_2); let (version, authority, host, sni) = seen.await.unwrap(); assert_eq!(version, Version::HTTP_2); assert_eq!(authority.as_deref(), Some("unitraklub.pl")); - assert!(host.is_none(), "Strict H2 must not duplicate :authority with Host"); + assert!( + host.is_none(), + "Strict H2 must not duplicate :authority with Host" + ); assert_eq!(sni.as_deref(), Some("localhost")); server.abort(); } @@ -299,12 +312,16 @@ async fn auto_h1_fallback_preserves_public_host_without_resolving_it() { backend.tls_server_name = Some("localhost".into()); backend.preserve_host = true; let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); - let request = Request::builder().version(Version::HTTP_11) + let request = Request::builder() + .version(Version::HTTP_11) .uri("https://unitraklub.pl/block_refresh/block/10") .header("host", "unitraklub.pl") - .body(Body::empty()).unwrap(); + .body(Body::empty()) + .unwrap(); let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) - .await.unwrap().unwrap(); + .await + .unwrap() + .unwrap(); assert_eq!(response.version(), Version::HTTP_11); let (version, _authority, host, sni) = seen.await.unwrap(); assert_eq!(version, Version::HTTP_11); @@ -320,12 +337,16 @@ async fn auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin() { backend.tls_server_name = Some("localhost".into()); backend.preserve_host = true; let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); - let request = Request::builder().version(Version::HTTP_11) + let request = Request::builder() + .version(Version::HTTP_11) .uri("https://unitraklub.pl/block_refresh/block/10") .header("host", "unitraklub.pl") - .body(Body::empty()).unwrap(); + .body(Body::empty()) + .unwrap(); let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) - .await.unwrap().unwrap(); + .await + .unwrap() + .unwrap(); assert_eq!(response.version(), Version::HTTP_2); let (version, authority, host, sni) = seen.await.unwrap(); assert_eq!(version, Version::HTTP_2); @@ -352,15 +373,20 @@ async fn cleartext_h2c_preserves_public_authority_and_pins_socket() { } }); let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) - .serve_connection(TokioIo::new(stream), service).await; + .serve_connection(TokioIo::new(stream), service) + .await; }); let backend = protocol_backend(address, "http", "h2"); let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); - let request = Request::builder().version(Version::HTTP_2) + let request = Request::builder() + .version(Version::HTTP_2) .uri("http://unitraklub.pl/test") - .body(Body::empty()).unwrap(); + .body(Body::empty()) + .unwrap(); let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) - .await.unwrap().unwrap(); + .await + .unwrap() + .unwrap(); assert_eq!(response.version(), Version::HTTP_2); assert_eq!(observed.await.unwrap().as_deref(), Some("unitraklub.pl")); server.abort(); diff --git a/src/user_agent/tests.rs b/src/user_agent/tests.rs index 89840ed..e71f4f0 100644 --- a/src/user_agent/tests.rs +++ b/src/user_agent/tests.rs @@ -1,53 +1,53 @@ - use super::*; - use http::HeaderValue; +use super::*; +use http::HeaderValue; - fn headers(value: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - if let Some(value) = value { - headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap()); - } - headers +fn headers(value: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(value) = value { + headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap()); } + headers +} - #[test] - fn plain_patterns_match_case_insensitive_substrings() { - let policy = Policy { - enabled: true, - patterns: vec!["BadBot".into()], - } - .compile() - .unwrap(); - assert!(policy.blocks(&headers(Some("Mozilla/5.0 badbot/2.1")))); - assert!(!policy.blocks(&headers(Some("Mozilla/5.0")))); +#[test] +fn plain_patterns_match_case_insensitive_substrings() { + let policy = Policy { + enabled: true, + patterns: vec!["BadBot".into()], } + .compile() + .unwrap(); + assert!(policy.blocks(&headers(Some("Mozilla/5.0 badbot/2.1")))); + assert!(!policy.blocks(&headers(Some("Mozilla/5.0")))); +} - #[test] - fn wildcard_patterns_can_anchor_or_span() { - let policy = Policy { - enabled: true, - patterns: vec!["curl/*".into(), "*scanner*".into()], - } - .compile() - .unwrap(); - assert!(policy.blocks(&headers(Some("curl/8.4.0")))); - assert!(policy.blocks(&headers(Some("AcmeScanner/1")))); - assert!(!policy.blocks(&headers(Some("prefix curl/8.4.0")))); +#[test] +fn wildcard_patterns_can_anchor_or_span() { + let policy = Policy { + enabled: true, + patterns: vec!["curl/*".into(), "*scanner*".into()], } + .compile() + .unwrap(); + assert!(policy.blocks(&headers(Some("curl/8.4.0")))); + assert!(policy.blocks(&headers(Some("AcmeScanner/1")))); + assert!(!policy.blocks(&headers(Some("prefix curl/8.4.0")))); +} - #[test] - fn disabled_or_missing_user_agent_is_allowed() { - let disabled = Policy { - enabled: false, - patterns: vec!["*".into()], - } - .compile() - .unwrap(); - assert!(!disabled.blocks(&headers(Some("anything")))); - let enabled = Policy { - enabled: true, - patterns: vec!["*".into()], - } - .compile() - .unwrap(); - assert!(!enabled.blocks(&headers(None))); +#[test] +fn disabled_or_missing_user_agent_is_allowed() { + let disabled = Policy { + enabled: false, + patterns: vec!["*".into()], } + .compile() + .unwrap(); + assert!(!disabled.blocks(&headers(Some("anything")))); + let enabled = Policy { + enabled: true, + patterns: vec!["*".into()], + } + .compile() + .unwrap(); + assert!(!enabled.blocks(&headers(None))); +} diff --git a/src/vhost/error_fallback.rs b/src/vhost/error_fallback.rs index 36d7754..0ec1dd9 100644 --- a/src/vhost/error_fallback.rs +++ b/src/vhost/error_fallback.rs @@ -24,7 +24,10 @@ impl Default for ErrorFallback { impl ErrorFallback { pub fn validate(&mut self) -> Result<()> { self.target = self.target.trim().to_owned(); - ensure!(crate::config::valid_id(&self.target), "invalid error fallback target"); + ensure!( + crate::config::valid_id(&self.target), + "invalid error fallback target" + ); ensure!( !self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(), "error fallback requires 1..={} status codes", @@ -45,4 +48,3 @@ impl ErrorFallback { self.statuses.contains(&status.as_u16()) } } - diff --git a/src/vhost/redirect.rs b/src/vhost/redirect.rs index 96f5e32..3cb4bdd 100644 --- a/src/vhost/redirect.rs +++ b/src/vhost/redirect.rs @@ -26,5 +26,3 @@ impl Default for LocationRedirect { } } } - - diff --git a/src/vhost/validation.rs b/src/vhost/validation.rs index 1db8606..4fe5244 100644 --- a/src/vhost/validation.rs +++ b/src/vhost/validation.rs @@ -67,7 +67,10 @@ impl Vhost { !self.websocket_rfc9220 || self.http3_enabled, "RFC 9220 requires HTTP/3 to be enabled for the vhost" ); - ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname"); + ensure!( + !self.frontend.hosts.is_empty(), + "vhost requires at least one hostname" + ); let mut hosts = std::collections::HashSet::new(); for host in &mut self.frontend.hosts { *host = crate::config::normalize_host_pattern(host)?; @@ -155,7 +158,10 @@ impl Vhost { "location cannot define both a local response and a redirect" ); if let Some(id) = &route.acl_profile_id { - ensure!(crate::config::valid_id(id), "invalid location ACL profile id"); + ensure!( + crate::config::valid_id(id), + "invalid location ACL profile id" + ); } route.country_access.validate()?; route.method_policy.validate()?; @@ -191,13 +197,21 @@ impl Vhost { } } let mut starts = vec![None]; - starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str()))); + starts.extend( + self.routes + .iter() + .filter(|route| route.enabled) + .map(|route| Some(route.id.as_str())), + ); for start in starts { let mut seen = std::collections::HashSet::new(); let mut current = start; loop { let key = current.unwrap_or("
"); - ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected"); + ensure!( + seen.insert(key.to_owned()), + "recursive error fallback loop detected" + ); let fallback = match current { None => self.error_fallback.as_ref(), Some(id) => self @@ -215,7 +229,9 @@ impl Vhost { if let Some(custom) = &member.health_check { let mut settings = self.load_balancing.clone(); settings.health_check = custom.clone(); - settings.validate().with_context(|| format!("backend {} health check", member.id))?; + settings + .validate() + .with_context(|| format!("backend {} health check", member.id))?; } } self.proxy_timeouts.validate()?; @@ -233,7 +249,8 @@ impl Vhost { "choose either Tinyauth or Basic authentication" ); self.pages.validate()?; - self.transform.resolve(&crate::transform::Settings::default())?; + self.transform + .resolve(&crate::transform::Settings::default())?; self.security_headers.compile()?; self.user_agent_blocking.compile()?; self.headers.compile()?; @@ -306,8 +323,10 @@ pub fn validate_listener(listener: Listener) -> Result<()> { } fn www_partner(host: &str) -> Result { if let Some(bare) = host.strip_prefix("www.") { - ensure!(!bare.is_empty() && bare.parse::().is_err(), - "www redirect requires a DNS hostname"); + ensure!( + !bare.is_empty() && bare.parse::().is_err(), + "www redirect requires a DNS hostname" + ); crate::config::normalize_host(bare) } else { crate::config::normalize_host(&format!("www.{host}")) @@ -315,14 +334,23 @@ fn www_partner(host: &str) -> Result { } impl Vhost { fn www_canonical_host(&self) -> Result<&str> { - let canonical = self.www_redirect_target.as_deref() + let canonical = self + .www_redirect_target + .as_deref() .or_else(|| self.frontend.hosts.first().map(String::as_str)) .ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?; - ensure!(!canonical.starts_with("*.") && canonical.parse::().is_err(), - "www redirect requires an exact DNS hostname"); + ensure!( + !canonical.starts_with("*.") && canonical.parse::().is_err(), + "www redirect requires an exact DNS hostname" + ); let partner = www_partner(canonical)?; - ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner), - "www redirect target or its www/non-www partner must belong to this vhost"); + ensure!( + self.frontend + .hosts + .iter() + .any(|host| host == canonical || host == &partner), + "www redirect target or its www/non-www partner must belong to this vhost" + ); Ok(canonical) } diff --git a/src/web_server/config/model.rs b/src/web_server/config/model.rs index bcb5ddb..d6cc137 100644 --- a/src/web_server/config/model.rs +++ b/src/web_server/config/model.rs @@ -1,7 +1,7 @@ -use super::htaccess; -use super::htaccess::Config as HtaccessConfig; #[cfg(unix)] use super::filesystem::open_root_directory; +use super::htaccess; +use super::htaccess::Config as HtaccessConfig; use anyhow::{ensure, Context, Result}; use http::{HeaderName, HeaderValue, StatusCode}; use regex::Regex; diff --git a/src/web_server/fastcgi/response.rs b/src/web_server/fastcgi/response.rs index 6c956b4..996fb34 100644 --- a/src/web_server/fastcgi/response.rs +++ b/src/web_server/fastcgi/response.rs @@ -11,9 +11,26 @@ pub(super) async fn php_response( context: RequestContext, ) -> Response { let start = Instant::now(); - let mut response = php_response_inner(service, php, method, headers, original_uri, script_path, path_info, query, body, context).await; - php.metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64); - response.extensions_mut().insert(crate::web_server::metrics::ResponseKind::Php); + let mut response = php_response_inner( + service, + php, + method, + headers, + original_uri, + script_path, + path_info, + query, + body, + context, + ) + .await; + php.metrics.record( + response.status(), + start.elapsed().as_micros().min(u64::MAX as u128) as u64, + ); + response + .extensions_mut() + .insert(crate::web_server::metrics::ResponseKind::Php); response } @@ -51,15 +68,19 @@ async fn php_response_inner( return response; } }; - let mut io = match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await { - Ok(Ok(io)) => io, - Ok(Err(error)) => { - php.metrics.failure(2); - tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed"); - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - Err(_) => { php.metrics.failure(1); return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); }, - }; + let mut io = + match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await { + Ok(Ok(io)) => io, + Ok(Err(error)) => { + php.metrics.failure(2); + tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed"); + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + Err(_) => { + php.metrics.failure(1); + return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); + } + }; let params = match fastcgi_params( service, php, @@ -91,8 +112,9 @@ async fn php_response_inner( let mut body = body; let mut body_bytes = 0u64; while let Some(frame) = body.frame().await { - let frame = frame - .map_err(|error| FastCgiRequestWriteError::Io(io::Error::other(error.to_string())))?; + let frame = frame.map_err(|error| { + FastCgiRequestWriteError::Io(io::Error::other(error.to_string())) + })?; if let Ok(data) = frame.into_data() { body_bytes = body_bytes.saturating_add(data.len() as u64); if php.max_request_body_bytes > 0 && body_bytes > php.max_request_body_bytes { @@ -117,7 +139,10 @@ async fn php_response_inner( tracing::warn!(%error, service = %service.id, "PHP-FPM request write failed"); return builtin_error_response(StatusCode::BAD_GATEWAY, &method); } - Err(_) => { php.metrics.failure(3); return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); }, + Err(_) => { + php.metrics.failure(3); + return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); + } } let mut stdout = Vec::new(); @@ -128,7 +153,7 @@ async fn php_response_inner( Ok(value) => value, Err(error) if error.kind() == io::ErrorKind::TimedOut => { php.metrics.failure(5); - return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method) + return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); } Err(error) => { tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed"); @@ -218,17 +243,24 @@ pub(super) fn method_not_allowed_response() -> Response { response } -pub(super) fn php_for_path<'a>(service: &'a CompiledService, path: &Path) -> Option<&'a CompiledPhp> { +pub(super) fn php_for_path<'a>( + service: &'a CompiledService, + path: &Path, +) -> Option<&'a CompiledPhp> { let php = service.php.as_ref()?; let extension = path .extension() .and_then(|value| value.to_str())? .to_ascii_lowercase(); - (php.extensions.contains(&extension) || htaccess::php_extension(service.htaccess.as_ref(), path)).then_some(php) + (php.extensions.contains(&extension) + || htaccess::php_extension(service.htaccess.as_ref(), path)) + .then_some(php) } pub(super) fn protected_script_without_handler(service: &CompiledService, path: &Path) -> bool { - if service.php.is_some() { return false; } + if service.php.is_some() { + return false; + } path.extension() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase()) diff --git a/src/web_server/filesystem.rs b/src/web_server/filesystem.rs index 58c1059..e55a7c6 100644 --- a/src/web_server/filesystem.rs +++ b/src/web_server/filesystem.rs @@ -1,11 +1,11 @@ use super::{CompiledService, SymlinkPolicy}; +#[cfg(unix)] +use std::{ffi::CString, sync::Arc}; use std::{ fs::File, io, path::{Path, PathBuf}, }; -#[cfg(unix)] -use std::{ffi::CString, sync::Arc}; pub(super) struct FilesystemRoot { root: PathBuf, @@ -63,12 +63,18 @@ pub(super) fn open_root_directory(path: &Path) -> io::Result { pub(super) fn path_components(path: &str) -> io::Result> { if !path.starts_with('/') { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not absolute")); + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path is not absolute", + )); } let mut result = Vec::new(); for part in path.split('/').filter(|part| !part.is_empty()) { if part == "." || part == ".." || part.contains('\\') || part.contains('\0') { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "ambiguous path")); + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ambiguous path", + )); } result.push(part.to_owned()); } @@ -93,7 +99,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> } let current = unsafe { File::from_raw_fd(fd) }; let metadata = current.metadata()?; - return Ok(Opened { file: current, metadata }); + return Ok(Opened { + file: current, + metadata, + }); } let mut current: Option = None; for (index, component) in components.iter().enumerate() { @@ -135,7 +144,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> "Web Server serves only regular files and directories", )); } - Ok(Opened { file: current, metadata }) + Ok(Opened { + file: current, + metadata, + }) } #[cfg(target_os = "linux")] @@ -192,7 +204,10 @@ pub(super) fn list_directory_sync( let opened = secure_open_sync(root, components)?; if !opened.metadata.is_dir() { - return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "not a directory", + )); } let raw = opened.file.try_clone()?.into_raw_fd(); let dir = unsafe { libc::fdopendir(raw) }; @@ -241,7 +256,10 @@ pub(super) fn list_directory_sync( ) -> io::Result> { let opened = secure_open_sync(root, components)?; if !opened.metadata.is_dir() { - return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "not a directory", + )); } let mut path = root.root.clone(); for component in components { @@ -249,7 +267,10 @@ pub(super) fn list_directory_sync( } let canonical = std::fs::canonicalize(path)?; if !canonical.starts_with(&root.root) { - return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escaped document root")); + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path escaped document root", + )); } let mut entries = Vec::new(); for entry in std::fs::read_dir(canonical)? { @@ -275,4 +296,3 @@ pub(super) fn list_directory_sync( }); Ok(entries) } - diff --git a/src/web_server/htaccess/model.rs b/src/web_server/htaccess/model.rs index 8706351..1230f8a 100644 --- a/src/web_server/htaccess/model.rs +++ b/src/web_server/htaccess/model.rs @@ -1,7 +1,5 @@ use super::{ - config::CompiledService, - filesystem::path_components, - request::RequestContext, + config::CompiledService, filesystem::path_components, request::RequestContext, routing::open_components, }; use anyhow::{bail, ensure, Context, Result}; @@ -119,9 +117,19 @@ struct RewriteCondition { #[derive(Debug, Clone)] enum ConditionMatcher { - Regex { regex: Regex, negate: bool }, - Exact { value: String, negate: bool, insensitive: bool }, - File { kind: FileTest, negate: bool }, + Regex { + regex: Regex, + negate: bool, + }, + Exact { + value: String, + negate: bool, + insensitive: bool, + }, + File { + kind: FileTest, + negate: bool, + }, } #[derive(Debug, Clone, Copy)] @@ -198,7 +206,13 @@ fn tokens(value: &str) -> Result> { fn flags(value: Option<&str>) -> Vec { value .and_then(|value| value.strip_prefix('[').and_then(|v| v.strip_suffix(']'))) - .map(|value| value.split(',').map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()).collect()) + .map(|value| { + value + .split(',') + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + .collect() + }) .unwrap_or_default() } @@ -227,16 +241,26 @@ fn supported_response_header(name: &HeaderName) -> bool { fn parse_duration(value: &str) -> Result { let value = value.trim().trim_matches('"').trim(); - if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('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())) { let seconds: u64 = seconds.parse().context("invalid Expires duration")?; return Ok(Duration::from_secs(seconds)); } let parts = value.split_whitespace().collect::>(); - ensure!(parts.len() >= 4, "Expires value must look like 'access plus 1 day' or A3600"); - ensure!(parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"), "only access-based Expires values are supported"); - let count: u64 = parts[2].parse().context("invalid Expires duration number")?; + ensure!( + parts.len() >= 4, + "Expires value must look like 'access plus 1 day' or A3600" + ); + ensure!( + parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"), + "only access-based Expires values are supported" + ); + let count: u64 = parts[2] + .parse() + .context("invalid Expires duration number")?; let unit = parts[3].to_ascii_lowercase(); let seconds = match unit.trim_end_matches('s') { "second" => count, @@ -252,32 +276,73 @@ fn parse_duration(value: &str) -> Result { } fn parse_condition(parts: &[String]) -> Result { - ensure!(parts.len() >= 3, "RewriteCond needs a test string and pattern"); + ensure!( + parts.len() >= 3, + "RewriteCond needs a test string and pattern" + ); let condition_flags = flags(parts.get(3).map(String::as_str)); - let insensitive = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("NC")); - let or_next = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("OR")); + let insensitive = condition_flags + .iter() + .any(|flag| flag.eq_ignore_ascii_case("NC")); + let or_next = condition_flags + .iter() + .any(|flag| flag.eq_ignore_ascii_case("OR")); for flag in &condition_flags { - ensure!(flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"), "unsupported RewriteCond flag [{flag}]"); + ensure!( + flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"), + "unsupported RewriteCond flag [{flag}]" + ); } let raw = parts[2].as_str(); let matcher = if let Some(value) = raw.strip_prefix("!=") { - ConditionMatcher::Exact { value: value.to_owned(), negate: true, insensitive } + ConditionMatcher::Exact { + value: value.to_owned(), + negate: true, + insensitive, + } } else if let Some(value) = raw.strip_prefix('=') { - ConditionMatcher::Exact { value: value.to_owned(), negate: false, insensitive } + ConditionMatcher::Exact { + value: value.to_owned(), + negate: false, + insensitive, + } } else { let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v)); match raw { - "-f" => ConditionMatcher::File { kind: FileTest::File, negate }, - "-d" => ConditionMatcher::File { kind: FileTest::Directory, negate }, - "-s" => ConditionMatcher::File { kind: FileTest::NonEmptyFile, negate }, - _ => ConditionMatcher::Regex { regex: ci_regex(raw, insensitive)?, negate }, + "-f" => ConditionMatcher::File { + kind: FileTest::File, + negate, + }, + "-d" => ConditionMatcher::File { + kind: FileTest::Directory, + negate, + }, + "-s" => ConditionMatcher::File { + kind: FileTest::NonEmptyFile, + negate, + }, + _ => ConditionMatcher::Regex { + regex: ci_regex(raw, insensitive)?, + negate, + }, } }; - Ok(RewriteCondition { input: parts[1].clone(), matcher, or_next }) + Ok(RewriteCondition { + input: parts[1].clone(), + matcher, + or_next, + }) } -fn parse_rewrite_rule(parts: &[String], conditions: Vec, state: &mut ParseState) -> Result { - ensure!(parts.len() >= 3, "RewriteRule needs a pattern and substitution"); +fn parse_rewrite_rule( + parts: &[String], + conditions: Vec, + state: &mut ParseState, +) -> Result { + ensure!( + parts.len() >= 3, + "RewriteRule needs a pattern and substitution" + ); let rule_flags = flags(parts.get(3).map(String::as_str)); let mut redirect = None; let mut forbidden = false; @@ -319,15 +384,30 @@ fn parse_rewrite_rule(parts: &[String], conditions: Vec, state fn normalize_extension(value: &str) -> Result { let value = value.trim().trim_start_matches('.').to_ascii_lowercase(); - ensure!(!value.is_empty() && value.len() <= 32 && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), "invalid extension in .htaccess"); + ensure!( + !value.is_empty() + && value.len() <= 32 + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), + "invalid extension in .htaccess" + ); Ok(value) } -fn unsupported(state: &mut ParseState, config: &Config, file: &Path, line: usize, message: String) -> Result<()> { +fn unsupported( + state: &mut ParseState, + config: &Config, + file: &Path, + line: usize, + message: String, +) -> Result<()> { if config.strict { bail!("{}:{line}: {message}", file.display()); } - state.unsupported.push(format!("{}:{line}: {message}", file.display())); + state + .unsupported + .push(format!("{}:{line}: {message}", file.display())); Ok(()) } @@ -344,17 +424,33 @@ fn read_htaccess_nofollow(path: &Path) -> Result { #[cfg(not(unix))] let mut file = { let metadata = fs::symlink_metadata(path)?; - ensure!(!metadata.file_type().is_symlink(), "{} must not be a symlink", path.display()); + ensure!( + !metadata.file_type().is_symlink(), + "{} must not be a symlink", + path.display() + ); fs::File::open(path)? }; let metadata = file.metadata()?; - ensure!(metadata.is_file(), "{} is not a regular file", path.display()); - ensure!(metadata.len() <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display()); + ensure!( + metadata.is_file(), + "{} is not a regular file", + path.display() + ); + ensure!( + metadata.len() <= MAX_HTACCESS_FILE_BYTES, + "{} exceeds the 128 KiB .htaccess limit", + path.display() + ); let mut content = String::new(); (&mut file) .take(MAX_HTACCESS_FILE_BYTES + 1) .read_to_string(&mut content) .with_context(|| format!("{} is not valid UTF-8", path.display()))?; - ensure!(content.len() as u64 <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display()); + ensure!( + content.len() as u64 <= MAX_HTACCESS_FILE_BYTES, + "{} exceeds the 128 KiB .htaccess limit", + path.display() + ); Ok(content) } diff --git a/src/web_server/htaccess/rewrite.rs b/src/web_server/htaccess/rewrite.rs index 48f73e8..9e6bd4c 100644 --- a/src/web_server/htaccess/rewrite.rs +++ b/src/web_server/htaccess/rewrite.rs @@ -1,5 +1,19 @@ -pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledService, method: &Method, headers: &HeaderMap, uri: &Uri, context: &RequestContext, initial_path: &str, initial_query: Option<&str>) -> Result { - let Some(compiled) = compiled else { return Ok(RewriteOutcome::Internal { path: initial_path.to_owned(), query: initial_query.map(str::to_owned) }); }; +pub async fn apply_rewrites( + compiled: Option<&Compiled>, + service: &CompiledService, + method: &Method, + headers: &HeaderMap, + uri: &Uri, + context: &RequestContext, + initial_path: &str, + initial_query: Option<&str>, +) -> Result { + let Some(compiled) = compiled else { + return Ok(RewriteOutcome::Internal { + path: initial_path.to_owned(), + query: initial_query.map(str::to_owned), + }); + }; let mut path = initial_path.to_owned(); let mut query = initial_query.map(str::to_owned); let mut exhausted = false; @@ -10,37 +24,95 @@ pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledServi 'scope: for scope in scopes { for rule in &scope.rewrites { let relative = scope_relative_path(scope, &path); - if !rule.pattern.is_match(&relative) { continue; } - let (conditions_ok, condition_captures) = conditions_match(rule, service, method, headers, &path, query.as_deref(), uri, context).await; - if !conditions_ok { continue; } - let Some(captures) = rule.pattern.captures(&relative) else { continue; }; - if rule.forbidden { return Ok(RewriteOutcome::Forbidden); } + if !rule.pattern.is_match(&relative) { + continue; + } + let (conditions_ok, condition_captures) = conditions_match( + rule, + service, + method, + headers, + &path, + query.as_deref(), + uri, + context, + ) + .await; + if !conditions_ok { + continue; + } + let Some(captures) = rule.pattern.captures(&relative) else { + continue; + }; + if rule.forbidden { + return Ok(RewriteOutcome::Forbidden); + } let replacement = replacement_with_captures(rule, &captures, &condition_captures); if replacement != "-" { - let (replacement_path, replacement_query) = if let Some((target, query_part)) = replacement.split_once('?') { (target, Some(query_part.to_owned())) } else { (replacement.as_str(), None) }; - let target = if rule.redirect.is_some() && (replacement_path.starts_with("http://") || replacement_path.starts_with("https://")) { replacement_path.to_owned() } else { scoped_target(scope, replacement_path) }; + let (replacement_path, replacement_query) = + if let Some((target, query_part)) = replacement.split_once('?') { + (target, Some(query_part.to_owned())) + } else { + (replacement.as_str(), None) + }; + let target = if rule.redirect.is_some() + && (replacement_path.starts_with("http://") + || replacement_path.starts_with("https://")) + { + replacement_path.to_owned() + } else { + scoped_target(scope, replacement_path) + }; let next_query = if rule.qsa { match (replacement_query, query.clone()) { - (Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => Some(format!("{new}&{old}")), + (Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => { + Some(format!("{new}&{old}")) + } (Some(new), _) => Some(new), (None, old) => old, } - } else { replacement_query.or_else(|| query.clone()) }; + } else { + replacement_query.or_else(|| query.clone()) + }; if let Some(status) = rule.redirect { let mut location = target; - if let Some(query) = next_query.filter(|q| !q.is_empty()) { location.push('?'); location.push_str(&query); } - return Ok(RewriteOutcome::Redirect(redirect_response(status, &location)?)); + if let Some(query) = next_query.filter(|q| !q.is_empty()) { + location.push('?'); + location.push_str(&query); + } + return Ok(RewriteOutcome::Redirect(redirect_response( + status, &location, + )?)); + } + ensure!( + target.starts_with('/'), + ".htaccess internal rewrite must stay on an absolute local path" + ); + if target != path || next_query != query { + changed = true; + path = target; + query = next_query; } - ensure!(target.starts_with('/'), ".htaccess internal rewrite must stay on an absolute local path"); - if target != path || next_query != query { changed = true; path = target; query = next_query; } } - if rule.end { end = true; break 'scope; } - if rule.last { break 'scope; } + if rule.end { + end = true; + break 'scope; + } + if rule.last { + break 'scope; + } } } - if end || !changed { break; } - if pass == 7 { exhausted = true; } + if end || !changed { + break; + } + if pass == 7 { + exhausted = true; + } } - ensure!(!exhausted, ".htaccess rewrite exceeded the 8-pass safety limit"); + ensure!( + !exhausted, + ".htaccess rewrite exceeded the 8-pass safety limit" + ); Ok(RewriteOutcome::Internal { path, query }) } diff --git a/src/web_server/metrics.rs b/src/web_server/metrics.rs index 3fd7600..1d3f293 100644 --- a/src/web_server/metrics.rs +++ b/src/web_server/metrics.rs @@ -28,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, + 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], - pub(crate) selected_status: [AtomicU64; 8], + pub(crate) methods: [AtomicU64; 5], + pub(crate) selected_status: [AtomicU64; 8], pub(crate) failures: [AtomicU64; 9], - pub(crate) compression: [AtomicU64; 4], + pub(crate) compression: [AtomicU64; 4], } pub(crate) struct HeadersInFlight<'a>(&'a AtomicU64); diff --git a/src/web_server/request.rs b/src/web_server/request.rs index fd443f1..1fe4101 100644 --- a/src/web_server/request.rs +++ b/src/web_server/request.rs @@ -10,7 +10,6 @@ pub struct RequestContext { pub version: Version, } - pub(super) fn server_protocol(version: http::Version) -> &'static str { match version { http::Version::HTTP_09 => "HTTP/0.9", diff --git a/src/web_server/response/file.rs b/src/web_server/response/file.rs index 6a1268e..3b0aa37 100644 --- a/src/web_server/response/file.rs +++ b/src/web_server/response/file.rs @@ -161,13 +161,22 @@ async fn file_response_inner( #[cfg(target_os = "linux")] let sendfile_candidate = if service.sendfile == super::SendfileMode::Auto - && !multipart && single_range.is_none() && content_encoding.is_none() + && !multipart + && single_range.is_none() + && content_encoding.is_none() && body_length >= 64 * 1024 { - opened.file.try_clone().ok().map(|file| crate::server::sendfile::SendfileCandidate { - file: std::sync::Arc::new(file), length: body_length, - }) - } else { None }; + opened + .file + .try_clone() + .ok() + .map(|file| crate::server::sendfile::SendfileCandidate { + file: std::sync::Arc::new(file), + length: body_length, + }) + } else { + None + }; let mut file = tokio::fs::File::from_std(opened.file); if let Some(boundary) = boundary { let ranges = ranges.expect("multipart ranges"); @@ -203,7 +212,8 @@ async fn file_response_inner( .await; }); *response.body_mut() = Body::new(file_body::FileBody::new( - ReaderStream::with_capacity(reader, stream_capacity), response_length, + ReaderStream::with_capacity(reader, stream_capacity), + response_length, )); return response; } diff --git a/src/web_server/response/file_body/mod.rs b/src/web_server/response/file_body/mod.rs index db31839..efec805 100644 --- a/src/web_server/response/file_body/mod.rs +++ b/src/web_server/response/file_body/mod.rs @@ -1,7 +1,11 @@ use bytes::Bytes; use futures_util::Stream; use http_body::{Body, Frame, SizeHint}; -use std::{io, pin::Pin, task::{Context, Poll}}; +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; pub(super) struct FileBody { stream: Option, @@ -10,7 +14,10 @@ pub(super) struct FileBody { impl FileBody { pub(super) fn new(stream: S, length: u64) -> Self { - Self { stream: (length != 0).then_some(stream), remaining: length } + Self { + stream: (length != 0).then_some(stream), + remaining: length, + } } } @@ -26,14 +33,17 @@ where cx: &mut Context<'_>, ) -> Poll, io::Error>>> { let this = self.get_mut(); - let Some(stream) = this.stream.as_mut() else { return Poll::Ready(None) }; + let Some(stream) = this.stream.as_mut() else { + return Poll::Ready(None); + }; match Pin::new(stream).poll_next(cx) { Poll::Ready(Some(Ok(data))) => { if data.len() as u64 > this.remaining { this.stream = None; this.remaining = 0; return Poll::Ready(Some(Err(io::Error::new( - io::ErrorKind::InvalidData, "local file body exceeded its declared length", + io::ErrorKind::InvalidData, + "local file body exceeded its declared length", )))); } this.remaining -= data.len() as u64; @@ -46,7 +56,8 @@ where this.stream = None; this.remaining = 0; Poll::Ready(Some(Err(io::Error::new( - io::ErrorKind::UnexpectedEof, "local file body ended before its declared length", + io::ErrorKind::UnexpectedEof, + "local file body ended before its declared length", )))) } Poll::Ready(Some(Err(error))) => { @@ -58,9 +69,13 @@ where } } - fn is_end_stream(&self) -> bool { self.stream.is_none() } + fn is_end_stream(&self) -> bool { + self.stream.is_none() + } - fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) } + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.remaining) + } } #[cfg(test)] diff --git a/src/web_server/response/file_body/tests.rs b/src/web_server/response/file_body/tests.rs index e4c5cef..555ea18 100644 --- a/src/web_server/response/file_body/tests.rs +++ b/src/web_server/response/file_body/tests.rs @@ -17,30 +17,54 @@ async fn final_file_frame_is_terminal_without_an_extra_eof_poll() { let mut body = FileBody::new(ReaderStream::with_capacity(file.take(6), 3), 6); assert_eq!(body.size_hint().exact(), Some(6)); assert!(!body.is_end_stream()); - assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc"); + assert_eq!( + body.frame().await.unwrap().unwrap().into_data().unwrap(), + "abc" + ); assert_eq!(body.size_hint().exact(), Some(3)); assert!(!body.is_end_stream()); - assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "def"); - assert!(body.is_end_stream(), "the final data frame must end the body before another poll"); + assert_eq!( + body.frame().await.unwrap().unwrap().into_data().unwrap(), + "def" + ); + assert!( + body.is_end_stream(), + "the final data frame must end the body before another poll" + ); assert_eq!(body.size_hint().exact(), Some(0)); - assert!(body.stream.is_none(), "the reader/file is released at the length boundary"); + assert!( + body.stream.is_none(), + "the reader/file is released at the length boundary" + ); assert!(body.frame().await.is_none()); } #[tokio::test] async fn short_file_and_local_stream_errors_never_become_successful_eof() { let mut short = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abc"))]), 6); - assert_eq!(short.frame().await.unwrap().unwrap().into_data().unwrap(), "abc"); - assert_eq!(short.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof); + assert_eq!( + short.frame().await.unwrap().unwrap().into_data().unwrap(), + "abc" + ); + assert_eq!( + short.frame().await.unwrap().unwrap_err().kind(), + io::ErrorKind::UnexpectedEof + ); assert!(short.is_end_stream()); assert!(short.frame().await.is_none()); let mut failed = FileBody::new(stream::iter([Err(io::Error::other("read failed"))]), 6); - assert_eq!(failed.frame().await.unwrap().unwrap_err().to_string(), "read failed"); + assert_eq!( + failed.frame().await.unwrap().unwrap_err().to_string(), + "read failed" + ); assert!(failed.is_end_stream()); let mut oversized = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abcdef"))]), 3); - assert_eq!(oversized.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::InvalidData); + assert_eq!( + oversized.frame().await.unwrap().unwrap_err().kind(), + io::ErrorKind::InvalidData + ); assert!(oversized.is_end_stream()); } @@ -51,10 +75,24 @@ async fn file_shrunk_after_open_returns_unexpected_eof() { tokio::fs::write(&path, b"abcdef").await.unwrap(); let file = tokio::fs::File::open(&path).await.unwrap(); let expected = file.metadata().await.unwrap().len(); - std::fs::OpenOptions::new().write(true).open(path).unwrap().set_len(3).unwrap(); - let mut body = FileBody::new(ReaderStream::with_capacity(file.take(expected), 3), expected); - assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc"); - assert_eq!(body.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap() + .set_len(3) + .unwrap(); + let mut body = FileBody::new( + ReaderStream::with_capacity(file.take(expected), 3), + expected, + ); + assert_eq!( + body.frame().await.unwrap().unwrap().into_data().unwrap(), + "abc" + ); + assert_eq!( + body.frame().await.unwrap().unwrap_err().kind(), + io::ErrorKind::UnexpectedEof + ); assert!(body.is_end_stream()); assert!(body.stream.is_none()); } @@ -67,12 +105,22 @@ async fn zero_length_file_is_terminal_without_polling_the_reader() { assert!(body.frame().await.is_none()); } -fn settings() -> Settings { Settings { enabled: true, ..Default::default() } } +fn settings() -> Settings { + Settings { + enabled: true, + ..Default::default() + } +} fn cacheable(body: AxumBody, length: u64) -> Response { let mut response = Response::new(body); - response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=600")); - response.headers_mut().insert("content-length", length.to_string().parse().unwrap()); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=600"), + ); + response + .headers_mut() + .insert("content-length", length.to_string().parse().unwrap()); response } @@ -85,17 +133,33 @@ async fn fully_delivered_file_fills_cache_before_consumer_drops_without_polling_ let body = FileBody::new(ReaderStream::with_capacity(file.take(6), 6), 6); let cache = Cache::new(1); let settings = settings(); - let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap(); + let ticket = cache + .ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()) + .unwrap(); let response = cacheable(AxumBody::new(body), 6); - 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"); + 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" + ); drop(body); assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().rejected_fills, 0); assert_eq!(cache.view().fills_active, 0); let hit = cache.get(&ticket).unwrap(); - assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "abcdef"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + "abcdef" + ); } #[tokio::test] @@ -103,14 +167,35 @@ async fn incomplete_or_cancelled_file_cannot_fill_cache() { for short in [false, true] { let cache = Cache::new(1); let settings = settings(); - let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap(); - let chunks = if short { vec![Ok(Bytes::from_static(b"abc"))] } - else { vec![Ok(Bytes::from_static(b"abc")), Ok(Bytes::from_static(b"def"))] }; + let ticket = cache + .ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()) + .unwrap(); + let chunks = if short { + vec![Ok(Bytes::from_static(b"abc"))] + } else { + vec![ + Ok(Bytes::from_static(b"abc")), + Ok(Bytes::from_static(b"def")), + ] + }; let response = cacheable(AxumBody::new(FileBody::new(stream::iter(chunks), 6)), 6); - 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(), "abc"); - if short { assert!(body.frame().await.unwrap().is_err()); } + 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(), + "abc" + ); + if short { + assert!(body.frame().await.unwrap().is_err()); + } drop(body); assert!(cache.get(&ticket).is_none()); assert_eq!(cache.view().rejected_fills, 1); diff --git a/src/web_server/routing.rs b/src/web_server/routing.rs index 9c9ebdf..1650b5a 100644 --- a/src/web_server/routing.rs +++ b/src/web_server/routing.rs @@ -8,7 +8,10 @@ use axum::{body::Body, response::Response}; use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode}; use std::io; -pub(super) async fn open_components(service: &CompiledService, components: Vec) -> io::Result { +pub(super) async fn open_components( + service: &CompiledService, + components: Vec, +) -> io::Result { let root = FilesystemRoot::for_service(service); tokio::task::spawn_blocking(move || secure_open_sync(&root, &components)) .await @@ -28,7 +31,10 @@ fn target_path_and_query( Some((path, query)) => (path.to_owned(), Some(query.to_owned())), None => (expanded, query.map(str::to_owned)), }; - ensure!(path.starts_with('/'), "Web Server internal target must start with /"); + ensure!( + path.starts_with('/'), + "Web Server internal target must start with /" + ); crate::acl::validate_decoded_routing_path(&path) .map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?; Ok((path, query)) @@ -54,11 +60,7 @@ pub(super) fn apply_rewrites( .pattern .replace(&path, rule.replacement.as_str()) .to_string(); - let (next_path, next_query) = target_path_and_query( - &replacement, - &path, - query.as_deref(), - )?; + let (next_path, next_query) = target_path_and_query(&replacement, &path, query.as_deref())?; if let Some(status) = rule.status { let mut target = next_path; if let Some(query) = next_query.filter(|value| !value.is_empty()) { @@ -142,7 +144,9 @@ pub(super) async fn select_precompressed( continue; } let mut sidecar = components.to_vec(); - let Some(last) = sidecar.last_mut() else { continue }; + let Some(last) = sidecar.last_mut() else { + continue; + }; last.push_str(suffix); if let Ok(opened) = open_components(service, sidecar).await { if opened.metadata.is_file() { @@ -152,4 +156,3 @@ pub(super) async fn select_precompressed( } None } - diff --git a/src/web_server/security.rs b/src/web_server/security.rs index 160f2c1..cc703ff 100644 --- a/src/web_server/security.rs +++ b/src/web_server/security.rs @@ -35,8 +35,8 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo "id_ed25519", ]; let backup_suffixes = [ - ".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", - ".sqlite", ".sqlite3", ".db", + ".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", ".sqlite", ".sqlite3", + ".db", ]; path_components(path).ok().is_some_and(|components| { components.iter().any(|component| { @@ -46,14 +46,18 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo || component.starts_with(".env.") || sensitive_names.contains(&component.as_str()) || component.ends_with('~') - || backup_suffixes.iter().any(|suffix| component.ends_with(*suffix)) + || backup_suffixes + .iter() + .any(|suffix| component.ends_with(*suffix)) }) }) } pub(super) fn hardening_denied_method(service: &CompiledService, method: &Method) -> bool { service.hardening.block_common_attacks - && (method == Method::CONNECT || method == Method::TRACE || method.as_str().eq_ignore_ascii_case("TRACK")) + && (method == Method::CONNECT + || method == Method::TRACE + || method.as_str().eq_ignore_ascii_case("TRACK")) } pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &CompiledService) { @@ -73,4 +77,3 @@ pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &Compi response.headers_mut().remove(name); } } - diff --git a/src/web_server/tests.rs b/src/web_server/tests.rs index 8f7a278..d018f65 100644 --- a/src/web_server/tests.rs +++ b/src/web_server/tests.rs @@ -122,7 +122,13 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() { assert_eq!(missing.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(missing.headers()["x-content-type-options"], "nosniff"); assert_eq!( - missing.into_body().collect().await.unwrap().to_bytes().as_ref(), + missing + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .as_ref(), crate::pages::builtin_gateway_error(StatusCode::NOT_FOUND) .unwrap() .as_bytes() @@ -210,7 +216,9 @@ async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses( .unwrap() .as_bytes() ); - assert!(!body.windows(b"RustProxy:".len()).any(|w| w == b"RustProxy:")); + assert!(!body + .windows(b"RustProxy:".len()) + .any(|w| w == b"RustProxy:")); } } @@ -222,7 +230,13 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() { assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); assert_eq!(invalid.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!( - invalid.into_body().collect().await.unwrap().to_bytes().as_ref(), + invalid + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .as_ref(), crate::pages::builtin_gateway_error(StatusCode::BAD_REQUEST) .unwrap() .as_bytes() @@ -255,7 +269,10 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() { assert_eq!(fallback.status(), StatusCode::IM_A_TEAPOT); assert_eq!(fallback.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(fallback.headers()[CACHE_CONTROL], "no-store"); - assert!(fallback.extensions().get::().is_none()); + assert!(fallback + .extensions() + .get::() + .is_none()); let fallback_html = fallback.into_body().collect().await.unwrap().to_bytes(); let fallback_html = std::str::from_utf8(&fallback_html).unwrap(); assert!(fallback_html.contains("

418 -")); @@ -272,7 +289,13 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() { head.headers()[CONTENT_LENGTH], HeaderValue::from_str(&fallback_html.len().to_string()).unwrap() ); - assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty()); + assert!(head + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty()); } #[tokio::test] @@ -1831,10 +1854,10 @@ async fn web_metrics_count_handler_status_method_and_consumed_body_bytes() { assert_eq!(view["status_4xx"], 1); assert_eq!(view["static_requests"], 2); assert_eq!(view["other_requests"], 1); - 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["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); }