From b2b5b45d8acb71534aa87e03e62c626933904a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 19 Oct 2026 15:40:37 +0200 Subject: [PATCH] v0.34.2 --- CHANGELOG.md | 3 +- README.md | 5 +- SHA256SUMS | 54 +- docs/API.md | 1 + docs/MONITORING.md | 111 +++++ docs/README.md | 4 +- docs/WEB_SERVER.md | 4 +- src/admin/api_token_access.rs | 2 + src/admin/logging_api.rs | 14 +- src/admin/openapi.json | 693 +++++++++++++++++++++++++- src/admin/router.rs | 14 +- src/admin/tests.rs | 79 +++ src/metrics.rs | 1 + src/metrics/global.rs | 1 + src/metrics/rate_sampling.rs | 100 ++++ src/pages.rs | 36 +- src/prometheus.rs | 2 + src/prometheus/json.rs | 758 +++++++++++++++++++++++++++++ src/prometheus/model.rs | 15 +- src/prometheus/tests.rs | 164 +++++++ src/web_server/response/headers.rs | 67 +-- src/web_server/tests.rs | 125 ++++- 22 files changed, 2146 insertions(+), 107 deletions(-) create mode 100644 src/metrics/rate_sampling.rs create mode 100644 src/prometheus/json.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 589839c..6936d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ Release history focused on **features, settings, default behavior, compatibility ## 0.34.2 — configuration storage visibility and public HTTP error responses +- Added poll-based `GET /api/metrics/json` to the authenticated admin API and a `.json` sibling route on the optional standalone Prometheus exporter. JSON contains Overview-style monitoring snapshots and every numeric sample from the Prometheus collector (including labelled histograms). Added `summary` projection in the full payload and compact `GET /api/metrics/summary` / exporter `.summary.json` routes: calculated static and Docker backend/vhost readiness, request/error rates, overall byte totals, rolling transfer rates, memory/cache percentages and Web Server/Stream totals. Reuses the respective API or exporter Bearer/CIDR security policies, without SSE/WebSockets. Rolling rates are `null` until a usable second pull and after sampling gaps/resets. - **Settings → Manual config** now reports whether SQLite or an explicit JSON file is the authoritative configuration source, shows its resolved path and distinguishes the SQLite history/audit database from the file-backed active configuration. The authenticated `GET /api/config` response supplies this read-only `storage` metadata without changing the editable JSON schema or write responses; no extra endpoint is needed. - Added an optional `systemctl edit rustproxy` example using `--config /etc/rustproxy/config.json` with the required ACLs, owner/mode setup, systemd filesystem allowance, validation and safe reload instructions. The original SQLite configuration mode remains the default. -- Normalized built-in Web Server errors and proxy-generated non-template errors to ordinary HTTP status bodies (such as `404 Not Found`), without exposing RustProxy diagnostic strings. Preserved status codes, headers such as `Allow`/`Retry-After`, custom error pages, backend-origin responses and authentication behavior. +- Web Server-generated errors now reuse the bundled application-style HTML pages (400/403/404/500 and other available templates), with the same styled fallback for statuses such as 405. Proxy-generated non-template errors continue to use ordinary HTTP status text; no implementation diagnostics are exposed publicly. Preserved HTTP statuses, `HEAD` behavior, `Allow`/`Retry-After`, existing custom pages, backend-origin responses and authentication behavior. - Removed an unused forwarding accessor (`peer_header`) to avoid a Rust `dead_code` warning without changing forwarded header behavior. diff --git a/README.md b/README.md index 99fdac9..1a41b20 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ Features and default settings vary by component; configure only the capabilities ## 0.34.2 +- Added read-only pull-based JSON monitoring at `/api/metrics/json` (admin session or General API token) and at the enabled Prometheus exporter path with `.json` suffix (same exporter bearer/CIDR policy). Includes Overview-style metrics and all existing Prometheus sample families without SSE. Compact precomputed dashboard metrics are available at `/api/metrics/summary`, under `summary` in the full JSON, and at the exporter path with `.summary.json` suffix. This includes readiness counts, RPS, transfer/second and system/cache percentages. - **Settings → Manual config** displays the active configuration storage mode, its path and the SQLite history location for file-backed mode. - Documented optional systemd `--config /etc/rustproxy/config.json` deployment with file/directory ACLs, write access for atomic panel saves and reload instructions. -- Public Web Server and proxy-generated errors use standard HTTP status pages/text instead of internal `RustProxy:` descriptions. Origin error bodies and custom error pages remain configurable. +- Public Web Server errors reuse embedded application-style HTML templates (or a matching fallback for statuses without a dedicated page). Proxy-generated non-template errors use standard HTTP status text; neither leaks internal `RustProxy:` diagnostics. Origin error bodies and custom error pages remain configurable. ## 0.34.1 @@ -60,4 +61,4 @@ The authenticated **`/docs`** page includes the guides and a separate interactiv ## Status of this source archive -Version **0.34.2** is delivered as a **source package**. This archive includes regression tests for the configuration and public HTTP changes, but native compilation, full Rust tests and target-system acceptance are required before production use. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md). +Version **0.34.2** is delivered as a **source package**. This archive includes regression tests for configuration, public HTTP error handling and the JSON monitoring API, but native compilation, full Rust tests and target-system acceptance are required before production use. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md). diff --git a/SHA256SUMS b/SHA256SUMS index ec2257d..d83c148 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -2,12 +2,12 @@ 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 65441f53fe87a21b1b960ddd7b76a6346a7ab97d6c75ba71c04a1d28504a7ee0 AGENTS.md -9141d37b0acd820916bb0157f2c206d4c20ac56e72454cecf3baea34ab1051b5 CHANGELOG.md +cf8531560d308e9acb81c5155b644b5ee0821b8f8ad57d96955e7668030f883c CHANGELOG.md b8d1eceeb2e8aa506da7d5675307a3ca7f101bc7a78a8cc00bb3b441a4dd85a9 Cargo.lock 55788c48310d81442f3a937de1a42e67cae7482f88ec4f1699b50e37f6f56acb Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -25c622f1c49edea413432091b4100ff6542e76d0145480feba91bc16172e05c3 README.md -447fe8a026b819f326d27b941aa35caea2bcfd493c4de46e3fde77f8f13997c8 ROADMAP.md +c5cd6441c38a73f4abfe10e353e84bd93ad54f6fa4b86a1c83a8524ca312fec7 README.md +ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs 99ac985e49fc0bf7ddfb0b157c241261ecb69f24f92e5dcc99e4f72d58ce6cea deploy/README.md @@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go b0eaa2cfe4f6e86582f67891e457591ae650d15145e025d85cab43e37c883ac8 docs/ACCESS.md 64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md -adb6062ae518c64ba4eb3a358cb1bb147e41ef46f0e42cdc80e27833eddcbf39 docs/API.md +9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md 7e6a7b9421541c94181a712d17f6166a328e7e28c5a1d629e7d335c0ae5aa1de docs/CACHE.md @@ -40,16 +40,16 @@ d73d0ff31f20f8ca96377bde5cb05753c5c29e417d25b908baac4e51a628ea48 docs/INSTALL.m 37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md 8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md 888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md -1e2796dcf723b74035bc8f31c38bc8ed5e00de2150e6e73379d7b52c26142c46 docs/MONITORING.md +e4ffd659be1e6c445b582a76643a46408699ef1e929d3a4c6e741d8146ded031 docs/MONITORING.md d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md -5c0cd602645b500d9c52130b81911b2fc34d464566366fb7e9caa2e56e9f064a docs/README.md +3061263d5ac19ea579dd3f00ca9d9583296a9e761b84180595a33833daf7b9e0 docs/README.md 7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md 701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md 519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md f2f8916e5782bc9d4deef3405c36eeddcc1c1dde5198f9b415407c0351bf364e docs/TRANSPORT.md f4c23ee07723a78e1e104991c99bac6be61d72b7b912141e69cffffddf7232fe docs/TROUBLESHOOTING.md 23765b0d3e32766966e922aa79951b8bc94fc93c0ffa38824e9dfc7e6cd4a1aa docs/VHOSTS.md -a0e1a93a3bbd0f38b6f06e6f7d7768946c0751146879e79f1115b8542ddcb0bb docs/WEB_SERVER.md +f80fe921f0e93f1833ade5740b102c548210519b3b792d386762a57b92ea54cc docs/WEB_SERVER.md 5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json 2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json @@ -125,7 +125,7 @@ d1dcb7bdfb7cfdc9c3b88cfb6908201c58532bc888744a13f45853079f214659 src/acme/provi f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs -3aa582c79f67952a0c9267c52a01447b13af5813193d2f6ab7ff7b80362ae142 src/admin/api_token_access.rs +12883df8e5a88d9da6026959b4736838ba9dd276f900ca14e33622dceeedbb25 src/admin/api_token_access.rs 0a8a6fb22b90b11ab8ed329779aa3813f6a63a39edac7fe8d821402821073635 src/admin/api_tokens.rs b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs 51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs @@ -138,9 +138,9 @@ ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/docu ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs -74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs +ab453d459cd8de4e71421353edefa574085eceb5d39c66ca796cd6aaf4035e9f src/admin/logging_api.rs 8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs -508ed1890154ed109297b35cae652c24c4c7dc46633dcd9ebf170a5ea2dab53d src/admin/openapi.json +4ef9b39fdde727e44c1ab1b7907cf5dc4d477c26d4d98da54cd482a2dfa6d436 src/admin/openapi.json d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs @@ -150,13 +150,13 @@ d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/oper 4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs -8c56885198c27cf83c2464b41db32689cf0ba95d180e82c96669a1ae6e18ff8a src/admin/router.rs +d3e35d7c5b066990fd6cea07464970e6ddced8989e6c92ca327709b5d1c87017 src/admin/router.rs 0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs -71fabd06da516df1680cf323336f81c782a87aaf860b86aa2caf437fdeabe0a5 src/admin/status.rs +66111616ccb59a561003c723554c25ba35925afbef7f9b73e5479ca8f0b35b54 src/admin/status.rs 4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -1a8048ae2f8aa84545d2a42eece2bc7c0bcbbd631641028cc8d770778f537081 src/admin/tests.rs +5e52252a8e886d8e28eed1d456cdfb7387ea6a193007043662889ff366f6d6f5 src/admin/tests.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js @@ -216,7 +216,7 @@ c1b831617bf3cd03dfa416b43791777c8e71f0711c78f9d467fd0f1930ca2a1e src/admin/web/ 17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js -5ee0fa4b19f14a3e49283f1ec1e0dbde6b780da205bba35817bc6774f00e532b src/admin/web/style.css +d5147f3ff46a9e102b8c250eef0e59c241f2cb320518d945616148c5b8fb04ef src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js @@ -312,7 +312,7 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs 61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs -f3598f4e71dfa0171a3b3fc74f0229f34a3d2a5a668fc2daa724e557243754ef src/forwarding/tests.rs +01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs 80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs 06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs @@ -342,25 +342,27 @@ b9bef61d332df0dec47f15d864a7aaea240935c432bf935e40144916e37553bc src/logging/ru 33935951388699d3a78bcd4314fbde8226bc535e9c05932e7edef65e894bd7b9 src/main/shutdown.rs 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs -49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs -201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs +1def53b16ae1a498e6d31e4ecd7337af240121abe36ca505fed087bd4e426ab8 src/metrics.rs +6eb36a1a8a2bd04833aba17cca9645a8c55fcf93d512c9ac44c5368b83fe4059 src/metrics/global.rs +2715e2aa3068d9a4dd8f9cbf44e31917c4166111e6fd770219fc2e26cbbec3e0 src/metrics/rate_sampling.rs 78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs -d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs +0393d094c02b5ca76c887b4e4e24ebd7cbc59277eb2eceee65683be34acd9416 src/pages.rs da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs -0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs +63a84af77b6c3dd390aa9dfde71c527eec6e1a3c0a38c1f02c08055e525f250c src/prometheus.rs cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs 74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs -fefc761639392f7be68e6ccc161f47a7a7cb608374977f27ad4d5a9f5e46d8b4 src/prometheus/model.rs -eb92fc3c9606788e58e1f4015be1d3fb2287b20c8dd7007655101106944f54e5 src/prometheus/tests.rs +175e4b8566eaddddc51370f971bef5548b9f87636c703433c7cd542fbf4bd949 src/prometheus/json.rs +c34c3204bfb07a0de95250dfc1208979a5e701ccf5b4093cfbfd9b996ff3ba15 src/prometheus/model.rs +59fd9e547516999dd54ad07d25066abbd39e58c60bc1c313280b5fb0bdcc982d src/prometheus/tests.rs 028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs 2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs 66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs -6bbf2b1f71c7845f9c077cef5af1e2ff1dfe01836e928a039ca7abb0dbfc2e65 src/proxy/cache_helpers.rs +a1a78f580bef47e03f3bad826640183e3a68e511634157d8eba95e8b50548ac8 src/proxy/cache_helpers.rs aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs 676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs @@ -381,7 +383,7 @@ cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/head 55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs 2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs -d0e2b1f4dd3691c8293b8135a5a44a988ff502ffa4694bd2d416194f38b2b106 src/proxy/tests/authority_tests.rs +6a46cc0245175db56384ad793065141dece2213657ca73620fcc66fbe53a4b1b src/proxy/tests/authority_tests.rs 1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs 0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs @@ -492,11 +494,11 @@ d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server 833c9fea744b9649046e1a64d6fc6972fbf6cea640e1943b0db52b7ccfa9faad src/web_server/response/file.rs ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs c2e54fd803a10f74490f183e8c030fd0e3e72f5de4e79c55bc393ab57d90ab9a src/web_server/response/file_body/tests.rs -5e574d1e369e80f7164d8aaeadf3b3780df4ef988dca73928e92fed0357a09d7 src/web_server/response/headers.rs +b35e00e4084b4de2125e9b6fff9699358b881277427bed8706fb2e8286e3cf28 src/web_server/response/headers.rs 305c0109b4f869a99381a142853f9e2205f5c7c4f904b2dfa4643274bfc542af src/web_server/routing.rs 400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs -161fb2c88dd1d90f3c6d8f1f1cf9f3a060f8e06846f34d1f4995491eac6fcf49 src/web_server/serve.rs -8550770a40db5db72f068399b241b6c29c299acbb02f8ae7ada245722b40dbc6 src/web_server/tests.rs +c645740e5175e32a9ee743700e99a2f16b6fd894de5f734b84021b774dd747eb src/web_server/serve.rs +dc3a18c14c3cc28519103e860b0f42a59d163db15df3f68b76778cdfa6786e46 src/web_server/tests.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json 1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs 347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs diff --git a/docs/API.md b/docs/API.md index 25e8e41..94c5eb4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -23,6 +23,7 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot | `GET /api/logs` · `GET /api/live-logs` | Recent and streaming request logs. | | `GET /api/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. | | `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. | +| `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). | | `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. | | `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. | | `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. | diff --git a/docs/MONITORING.md b/docs/MONITORING.md index d44c43f..f53124e 100644 --- a/docs/MONITORING.md +++ b/docs/MONITORING.md @@ -42,6 +42,117 @@ scrape_configs: When using a bearer token, configure Prometheus `authorization` or `bearer_token_file` securely rather than embedding a clear token in an exported dashboard. Review `grafana/README.md` and `grafana/rustproxy-overview.json` for chart examples; `nagios/README.md` contains external check scripts. +## JSON metrics API for external dashboards + +RustProxy also offers a **plain, read-only JSON snapshot**. It does not use SSE, +WebSockets or persistent subscriptions: clients poll using ordinary `GET` (for +example, every 2–5 seconds). + +| Endpoint | Where | Authentication | +|---|---|---| +| `GET /api/metrics/json` | Administration listener (default `127.0.0.1:9090`) | Admin session or a **General API** `ro`/`rw` Bearer token from **Settings → Security**, restricted by allowed CIDRs | +| `GET /api/metrics/summary` | Administration listener, compact calculated metrics only | **Same** admin session/General API Bearer credentials and CIDR policy | +| `GET /metrics.json` | Dedicated exporter (default `127.0.0.1:9191`, **only when Prometheus is enabled**) | Same allowed CIDRs and exporter Bearer token as `/metrics`; not an admin/API token | +| `GET /metrics.summary.json` | Dedicated exporter, compact calculated metrics only | **Same** exporter Bearer token/CIDR policy, if enabled | + +When the exporter `path` differs from `/metrics`, the JSON routes append +`.json` and `.summary.json` to that path (e.g. `/private/stats` → +`/private/stats.json` and `/private/stats.summary.json`). Exporter routes +reject query strings. Every JSON response uses `application/json` and +`Cache-Control: no-store`; do not expose these endpoints publicly. + +For a dedicated integration, issue a General API token with **read-only** +permission and a narrow `allowed_cidrs` set. Example from a permitted client: + +```sh +curl -fsS -H "Authorization: Bearer $RUSTPROXY_API_TOKEN" \ + http://127.0.0.1:9090/api/metrics/json | jq '{vhosts: .vhosts | {total,enabled,disabled}, requests: .requests.total, cpu: .system.cpu_percent, ram_bytes: .system.process_rss_bytes}' +``` + +### Summary: ready for dashboard cards + +For the shortest response, poll **`/api/metrics/summary`** (or the standalone +exporter's `.summary.json` sibling) instead of loading every Prometheus sample. +The full `/api/metrics/json` includes the **same calculated object** at `summary`. +The response groups all values into `vhosts`, `backends`, `traffic`, `system`, +`cache`, `web_server`, `streams` and `capacity`, plus a collection timestamp. + +```sh +curl -fsS -H "Authorization: Bearer $RUSTPROXY_API_TOKEN" \ + http://127.0.0.1:9090/api/metrics/summary | \ + jq '{working_vhosts: .vhosts.serving_estimate, backends: .backends.total, \ + backend_up: .backends.up, rps: .traffic.requests_per_second, \ + traffic_mb: .traffic.megabytes_total, cpu: .system.cpu_percent, \ + ram_percent: .system.memory_percent}' +``` + +- `vhosts.enabled` is the configured on/off flag. `vhosts.serving_estimate` + counts enabled vhosts using a ready backend pool or an embedded Web Server. + It is **not** an HTTP probe or guarantee of reachability; redirect hosts, + not-found hosts and Docker assignments are counted separately. Only enabled + vhosts with a compiled pool are included in `backend_ready` / `backend_not_ready`. +- `backends.total` counts **live compiled pool members including Docker**. + `configured_static` counts saved static members. `up`, `down`, `checking`, + `draining`, `disabled`, `unknown`, `pools_ready` and `active_exchanges` + are independently calculated; `traffic_bytes_per_second` sums moving + backend rates and **does not** represent all public HTTP traffic. +- `traffic.requests_total`, `bytes_in_total`, `bytes_out_total` and `bytes_total` + are cumulative process counters; `megabytes_total` uses decimal MB (10⁶ B). + `requests_per_second`, `errors_per_second`, `bytes_in_per_second`, + `bytes_out_per_second` and `bytes_per_second` are **rolling poll interval + rates** computed on the server from a shared bounded sample (minimum 1 s, + maximum 30 s between usable samples). An initial poll, restart, counter reset + or gap longer than 30 s yields `null` until a second valid poll. Very rapid + simultaneous polls reuse the latest completed interval. These are not + guaranteed exact one-second rolling averages; poll every **2–5 seconds**. + `requests_per_second_average` is a separate **uptime average**. +- `traffic.error_percent` is lifetime errors divided by completed requests; + `cache.hit_percent` is lifetime HOT/COLD hit ratio from cache metrics. + Zero-denominator percentages are `null` unless a cache ratio has a defined + default. The `traffic.network_rx_bytes_per_second` and + `network_tx_bytes_per_second` fields are **host** interface rates, not + RustProxy-exclusive transfer; backend rate is separate. +- `system.cpu_percent`, `process_cpu_percent`, `memory_percent`, RAM and RSS + reflect the last OS sample (`system.sampled_at_ms`). Unsupported values can + be `null`. `web_server.requests_total` adds observed Web Server responses, + while `streams.active_sessions` adds observed Layer-4 sessions. + +The JSON is organized for simple chart consumption: + +- `summary` contains the small, calculated metrics object described above. +- `vhosts` includes `total`, `enabled`, `disabled`, backend readiness and an array of per-Vhost counters. +- `requests` includes process-lifetime totals, active exchanges, errors, status + classes, byte counters, latency, active connections and H1/H2/H3/WebSockets. +- `system` includes sampled host/process CPU, memory, swap, process I/O/FDs, network interfaces and throughput. +- `backends`, `web_server`, `streams`, `docker`, `cache`, `traffic`, `transform`, + `logging` and `capacity` contain their corresponding operational summaries. +- `prometheus.types` and `prometheus.samples` include **every numeric sample** + exported by the existing Prometheus text renderer, with label pairs as JSON + objects. Histogram buckets/counts/sums are included. The normal Prometheus + collector switches still control this detailed section. + +Example field access: + +```js +const snapshot = await (await fetch('/api/metrics/json')).json(); +const vhostsEnabled = snapshot.vhosts.enabled; +const requestTotal = snapshot.requests.total; +const cpuPercent = snapshot.system.cpu_percent; // can be null before sampling +const samples = snapshot.prometheus.samples.rust_proxy_vhost_requests_total ?? []; +// samples[0] -> { labels: { vhost: 'site1' }, value: 123 } +``` + +`collected_at_ms` is the JSON snapshot time; `system.sampled_at_ms` is the +last OS collector sample time (the OS collector runs in the background). +Raw counters represent cumulative totals, **not instantaneous RPS**. Use the +server-computed interval rates from `summary` or calculate +`(new_total - old_total) / elapsed_seconds` from raw counters in the client; +reset the baseline after counter decreases, process restart or config reset. +Absent/unsupported OS values are `null`; non-finite Prometheus values are also +represented as `null`. Traffic breakdowns are approximate and can lose samples +under load. The JSON excludes raw request paths, client IP addresses and raw +User-Agent values, matching the bounded-label privacy model of Prometheus. + ## Metric privacy and limits Prometheus series use bounded configured labels; arbitrary request URLs, raw client IPs, credentials, raw User-Agents and query strings are intentionally not exposed as unbounded labels. Dashboard traffic top lists can still contain sensitive operational metadata, so keep the panel authenticated. diff --git a/docs/README.md b/docs/README.md index f5cd27f..051cf0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Practical guides for **RustProxy 0.34.2**. This index and the listed pages are a - [Vhosts and backends](VHOSTS.md) — domains, listener bindings, balancing, health and redirects. - [Custom locations](CUSTOM_LOCATIONS.md) — path routing, rewrites, local responses and error fallbacks. - [Access and authentication](ACCESS.md) — ACL, country/DDNS, Basic Auth, Tinyauth and rate limits. -- [Static Web Server](WEB_SERVER.md) — document roots, downloads, rules and file delivery. +- [Static Web Server](WEB_SERVER.md) — document roots, downloads, rules, built-in error pages and file delivery. - [PHP-FPM](PHP_FPM.md) — PHP service profiles, security and application examples. - [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL and invalidation. - [Response delivery](RESPONSE_DELIVERY.md) — compression, static transformations and response headers. @@ -27,7 +27,7 @@ Practical guides for **RustProxy 0.34.2**. This index and the listed pages are a - [Docker discovery](DOCKER_DISCOVERY.md) — agent installation, networking and generated Vhosts. - [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools. - [Dashboard](DASHBOARD.md) — metrics panels, presets and configuration history. -- [Monitoring](MONITORING.md) — Prometheus, traffic, backend and cache observations. +- [Monitoring](MONITORING.md) — Prometheus, full JSON and compact `summary` API, traffic, backend and cache observations. - [Logs](LOGS.md) — live logs, stored logs, error logs and searches. - [Backups and recovery](BACKUP.md) — encrypted backups, restore and Secret Store. - [Migration](MIGRATION.md) — managed upgrades and Nginx Proxy Manager import. diff --git a/docs/WEB_SERVER.md b/docs/WEB_SERVER.md index 0a63e7d..cb720ec 100644 --- a/docs/WEB_SERVER.md +++ b/docs/WEB_SERVER.md @@ -44,7 +44,9 @@ An optional bounded Apache-style `.htaccess` compatibility layer supports a **su A Web Service may enable PHP via a shared PHP-FPM profile or its own configuration. PHP is executed by the FPM service, not RustProxy, and must not be exposed as a raw Internet port. Use dedicated pools for different applications. See [PHP-FPM](PHP_FPM.md). -Missing static files return normal **HTTP 404 Not Found**, unreadable/blocked paths **403 Forbidden**, unsupported static methods **405 Method Not Allowed** (with `Allow: GET, HEAD`), and internal failures **500 Internal Server Error**. Generated response bodies contain only the standard status, not a `RustProxy: Web Server ...` diagnostic. `HEAD` errors have no response body. Internal server errors can be diagnosed through service logs; generated responses are not cacheable by default. +Missing static files return **HTTP 404 Not Found**, unreadable/blocked paths **403 Forbidden**, unsupported static methods **405 Method Not Allowed** (with `Allow: GET, HEAD`), and internal failures **500 Internal Server Error**. The built-in Web Server now uses the existing **application-style HTML error pages** for statuses with a bundled template (including 400, 403, 404, 500 and PHP-FPM gateway errors). Other generated error statuses, such as 405, use the shared styled HTML fallback. Neither internal filesystem paths nor `RustProxy: Web Server ...` diagnostics are included in the public body. Details for 5xx filesystem failures remain in server logs. + +These generated HTML responses retain the actual HTTP status, `Content-Type: text/html; charset=utf-8`, `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; `HEAD` preserves the matching `Content-Length` but sends no body. Explicit application/origin responses are still passed through without forced replacement, subject to the Vhost error interception options below. Vhosts can intercept selected application error bodies in **Pages** and return a configured HTML error page while preserving the status. Internal location `error_fallback` is different: it routes the error to a named internal handler. Error-page replacements are marked uncacheable. Origin-supplied and explicitly configured custom error bodies are preserved unless interception is enabled. diff --git a/src/admin/api_token_access.rs b/src/admin/api_token_access.rs index 2284c35..2766c14 100644 --- a/src/admin/api_token_access.rs +++ b/src/admin/api_token_access.rs @@ -20,6 +20,8 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b | "/api/file-logs" | "/api/file-logs/query" | "/api/metrics" + | "/api/metrics/json" + | "/api/metrics/summary" | "/api/cache/bans" ); let write = permission == "rw" diff --git a/src/admin/logging_api.rs b/src/admin/logging_api.rs index 99636ef..fa4c1ce 100644 --- a/src/admin/logging_api.rs +++ b/src/admin/logging_api.rs @@ -298,6 +298,12 @@ async fn audit(State(state): State>) -> Result, ApiError> { async fn metrics(State(state): State>) -> Response { crate::prometheus::response(&state) } +async fn metrics_json(State(state): State>) -> Response { + crate::prometheus::json_response(&state) +} +async fn metrics_summary(State(state): State>) -> Response { + crate::prometheus::summary_response(&state) +} #[derive(Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct EventsQuery { @@ -311,7 +317,10 @@ async fn events( ) -> Result { let interval = query.interval.unwrap_or(3); if ![1, 2, 3, 5, 10].contains(&interval) { - return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "invalid SSE interval".into())); + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid SSE interval".into(), + )); } let secret = auth::cookie_secret(&headers) .ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?; @@ -349,7 +358,8 @@ async fn events( _ => return None, } let payload = - json!({"status":live_status_payload(&state), "logs":logs_payload(&state)}).to_string(); + json!({"status":live_status_payload(&state), "logs":logs_payload(&state)}) + .to_string(); let event = Event::default().event("snapshot").data(payload); Some(( Ok::(event), diff --git a/src/admin/openapi.json b/src/admin/openapi.json index c676a53..4e59d98 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -6801,6 +6801,557 @@ "database_path" ], "additionalProperties": false + }, + "MonitoringMetricSample": { + "type": "object", + "description": "Single metric sample. Values are JSON numbers; non-finite Prometheus values are null. Unlabelled samples use an empty labels object.", + "properties": { + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "value": { + "type": "number", + "nullable": true + } + }, + "required": [ + "labels", + "value" + ], + "additionalProperties": false + }, + "MonitoringMetricFamilies": { + "type": "object", + "description": "All numeric samples returned by the same collectors as the Prometheus exporter, including histogram buckets, counts and sums.", + "properties": { + "types": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Metric-family name to Prometheus TYPE." + }, + "samples": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MonitoringMetricSample" + } + } + } + }, + "required": [ + "types", + "samples" + ], + "additionalProperties": false + }, + "MonitoringSnapshot": { + "type": "object", + "description": "Pull-only operational snapshot with a precomputed summary and all Prometheus samples. Summary per-second rates are computed over bounded polling intervals; the first sample has null rates. OS gauges may be null until the background collector samples.", + "properties": { + "version": { + "type": "string" + }, + "collected_at_ms": { + "type": "integer", + "format": "int64" + }, + "uptime_seconds": { + "type": "integer", + "format": "int64" + }, + "revision": { + "type": "integer", + "format": "int64" + }, + "vhosts": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "enabled": { + "type": "integer" + }, + "disabled": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "total", + "enabled", + "disabled", + "items" + ] + }, + "redirect_hosts": { + "type": "object" + }, + "not_found_hosts": { + "type": "object" + }, + "requests": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "active": { + "type": "integer", + "format": "int64" + }, + "errors_total": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + }, + "required": [ + "total", + "active", + "errors_total", + "status" + ] + }, + "system": { + "type": "object" + }, + "cache": { + "type": "object" + }, + "backends": { + "type": "object" + }, + "web_server": { + "type": "object" + }, + "streams": { + "type": "object" + }, + "docker": { + "type": "object" + }, + "traffic": { + "type": "object" + }, + "transform": { + "type": "object" + }, + "logging": { + "type": "object" + }, + "capacity": { + "type": "object" + }, + "prometheus": { + "$ref": "#/components/schemas/MonitoringMetricFamilies" + }, + "summary": { + "$ref": "#/components/schemas/MonitoringSummary" + } + }, + "required": [ + "summary", + "version", + "collected_at_ms", + "uptime_seconds", + "revision", + "vhosts", + "requests", + "system", + "cache", + "backends", + "web_server", + "streams", + "docker", + "traffic", + "transform", + "logging", + "capacity", + "prometheus" + ] + }, + "MonitoringSummary": { + "type": "object", + "description": "Compact read-only pull snapshot with computed counts, health states, bytes, percentages and rates. No Prometheus samples, paths, client addresses or secrets. Counters reset on process restart.", + "properties": { + "collected_at_ms": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + }, + "vhosts": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "enabled": { + "type": "integer" + }, + "disabled": { + "type": "integer" + }, + "serving_estimate": { + "type": "integer", + "description": "Enabled primary Web Server or ready backend pool; not an HTTP end-to-end health check." + }, + "backend_ready": { + "type": "integer" + }, + "backend_not_ready": { + "type": "integer" + }, + "docker_enabled": { + "type": "integer" + } + }, + "required": [ + "total", + "enabled", + "disabled", + "serving_estimate", + "backend_ready", + "backend_not_ready", + "docker_enabled" + ], + "additionalProperties": false + }, + "backends": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "All currently compiled backend pool members, including Docker-generated pools." + }, + "configured_static": { + "type": "integer" + }, + "up": { + "type": "integer" + }, + "down": { + "type": "integer" + }, + "checking": { + "type": "integer" + }, + "draining": { + "type": "integer" + }, + "disabled": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "pools_total": { + "type": "integer" + }, + "pools_ready": { + "type": "integer" + }, + "active_exchanges": { + "type": "integer" + }, + "traffic_bytes_per_second": { + "type": "number", + "nullable": true, + "description": "Sum of backend moving rates; excludes static/Web Server and local responses." + } + }, + "required": [ + "total", + "configured_static", + "up", + "down", + "checking", + "draining", + "disabled", + "unknown", + "pools_total", + "pools_ready", + "active_exchanges", + "traffic_bytes_per_second" + ], + "additionalProperties": false + }, + "traffic": { + "type": "object", + "properties": { + "requests_total": { + "type": "integer" + }, + "requests_completed_total": { + "type": "integer" + }, + "requests_active": { + "type": "integer" + }, + "requests_per_second": { + "type": "number", + "nullable": true, + "description": "Rate between server-side polling snapshots; null until at least 1 second has elapsed or after a gap/reset." + }, + "requests_per_second_average": { + "type": "number", + "nullable": true + }, + "errors_total": { + "type": "integer" + }, + "errors_per_second": { + "type": "number", + "nullable": true + }, + "error_percent": { + "type": "number", + "nullable": true, + "description": "errors_total / completed_total * 100; lifetime ratio." + }, + "status_2xx_total": { + "type": "integer" + }, + "status_4xx_total": { + "type": "integer" + }, + "status_5xx_total": { + "type": "integer" + }, + "bytes_in_total": { + "type": "integer" + }, + "bytes_out_total": { + "type": "integer" + }, + "bytes_total": { + "type": "integer" + }, + "megabytes_total": { + "type": "number", + "description": "Decimal MB = (bytes_in + bytes_out)/1,000,000." + }, + "bytes_in_per_second": { + "type": "number", + "nullable": true + }, + "bytes_out_per_second": { + "type": "number", + "nullable": true + }, + "bytes_per_second": { + "type": "number", + "nullable": true + }, + "sample_interval_seconds": { + "type": "number", + "nullable": true + }, + "network_rx_bytes_per_second": { + "type": "number", + "nullable": true, + "description": "Host network rate, not RustProxy-only." + }, + "network_tx_bytes_per_second": { + "type": "number", + "nullable": true, + "description": "Host network rate, not RustProxy-only." + } + }, + "required": [ + "requests_total", + "requests_completed_total", + "requests_active", + "requests_per_second", + "requests_per_second_average", + "errors_total", + "errors_per_second", + "error_percent", + "status_2xx_total", + "status_4xx_total", + "status_5xx_total", + "bytes_in_total", + "bytes_out_total", + "bytes_total", + "megabytes_total", + "bytes_in_per_second", + "bytes_out_per_second", + "bytes_per_second", + "sample_interval_seconds", + "network_rx_bytes_per_second", + "network_tx_bytes_per_second" + ], + "additionalProperties": false + }, + "system": { + "type": "object", + "properties": { + "cpu_percent": { + "type": "number", + "nullable": true + }, + "process_cpu_percent": { + "type": "number", + "nullable": true + }, + "memory_percent": { + "type": "number", + "nullable": true + }, + "memory_used_bytes": { + "type": "integer", + "nullable": true + }, + "memory_total_bytes": { + "type": "integer", + "nullable": true + }, + "process_rss_bytes": { + "type": "integer", + "nullable": true + }, + "sampled_at_ms": { + "type": "integer" + } + }, + "required": [ + "cpu_percent", + "process_cpu_percent", + "memory_percent", + "memory_used_bytes", + "memory_total_bytes", + "process_rss_bytes", + "sampled_at_ms" + ], + "additionalProperties": false + }, + "cache": { + "type": "object", + "properties": { + "hits_total": { + "type": "integer" + }, + "misses_total": { + "type": "integer" + }, + "hit_percent": { + "type": "number", + "nullable": true + }, + "hot_entries": { + "type": "integer" + }, + "cold_entries": { + "type": "integer" + }, + "hot_bytes": { + "type": "integer" + }, + "cold_bytes": { + "type": "integer" + } + }, + "required": [ + "hits_total", + "misses_total", + "hit_percent", + "hot_entries", + "cold_entries", + "hot_bytes", + "cold_bytes" + ], + "additionalProperties": false + }, + "web_server": { + "type": "object", + "properties": { + "services_total": { + "type": "integer" + }, + "requests_total": { + "type": "integer" + } + }, + "required": [ + "services_total", + "requests_total" + ], + "additionalProperties": false + }, + "streams": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "enabled": { + "type": "integer" + }, + "active_sessions": { + "type": "integer" + } + }, + "required": [ + "total", + "enabled", + "active_sessions" + ], + "additionalProperties": false + }, + "capacity": { + "type": "object", + "properties": { + "requests_limit": { + "type": "integer" + }, + "requests_used": { + "type": "integer" + }, + "requests_used_percent": { + "type": "number", + "nullable": true + } + }, + "required": [ + "requests_limit", + "requests_used", + "requests_used_percent" + ], + "additionalProperties": false + } + }, + "required": [ + "collected_at_ms", + "uptime_seconds", + "vhosts", + "backends", + "traffic", + "system", + "cache", + "web_server", + "streams", + "capacity" + ], + "additionalProperties": false } } }, @@ -9492,6 +10043,76 @@ ] } }, + "/api/metrics/json": { + "get": { + "summary": "Read-only JSON metrics snapshot for dashboards and integrations", + "tags": [ + "Monitoring" + ], + "operationId": "get_metrics_json", + "responses": { + "200": { + "description": "Bounded operational JSON snapshot, not a streaming feed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MonitoringSnapshot" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Single GET JSON snapshot with summary (aggregated counts, percentages and bounded rates), Overview counters/gauges and every sample from the Prometheus exporter (under prometheus.types/samples). Same admin session or CIDR-scoped general RO/RW API token as /api/metrics. No SSE, request paths, IPs or raw User-Agent values. Prometheus exporter sibling: {configured metrics path}.json; the same exporter credentials/CIDRs apply.", + "security": [ + { + "AdminSession": [] + }, + { + "GeneralApiBearer": [] + } + ] + } + }, "/docs/openapi.json": { "get": { "summary": "This protected OpenAPI document", @@ -12841,7 +13462,77 @@ } } } + }, + "/api/metrics/summary": { + "get": { + "summary": "Compact computed live metrics summary for external dashboards", + "tags": [ + "Monitoring" + ], + "operationId": "get_metrics_summary", + "responses": { + "200": { + "description": "Small JSON-only computed metrics summary", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MonitoringSummary" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Read-only compact JSON summary of vhosts, backend pools, total HTTP traffic, rolling per-second request and byte rates, CPU, RAM, cache, Web Server and streams. A first read or gap/reset produces null rolling rates; repeat GET at 2-5 second intervals. No SSE, no detailed Prometheus samples. Auth: admin session or CIDR-scoped General API Bearer (ro/rw). The separate enabled exporter offers {configured metrics path}.summary.json, protected by its own bearer/CIDR policy.", + "security": [ + { + "AdminSession": [] + }, + { + "GeneralApiBearer": [] + } + ] + } } }, "tags": [] -} \ No newline at end of file +} diff --git a/src/admin/router.rs b/src/admin/router.rs index da544ab..51541e9 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -200,9 +200,17 @@ pub fn router(state: Arc) -> Router { "/api/certificates/{certificate_id}/reload", post(certificate_reload_one).layer(DefaultBodyLimit::max(4096)), ) - .route("/api/api-tokens", get(list_api_tokens).post(create_api_token)) + .route( + "/api/api-tokens", + get(list_api_tokens).post(create_api_token), + ) .route("/api/api-tokens/{id}", delete(revoke_api_token)) - .route("/api/prometheus-token", get(prometheus_token_status).post(rotate_prometheus_token).delete(revoke_prometheus_token)) + .route( + "/api/prometheus-token", + get(prometheus_token_status) + .post(rotate_prometheus_token) + .delete(revoke_prometheus_token), + ) .route( "/api/certificate-api", get(certificate_api_status) @@ -267,6 +275,8 @@ pub fn router(state: Arc) -> Router { .route("/api/events", get(events)) .route("/metrics", get(metrics)) .route("/api/metrics", get(metrics)) + .route("/api/metrics/json", get(metrics_json)) + .route("/api/metrics/summary", get(metrics_summary)) .fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") }) .layer(DefaultBodyLimit::max( crate::config::MAX_CONFIG_BYTES + 1024, diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 438ee40..12c7674 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -2250,6 +2250,7 @@ mod tests { "/api/acme", "/api/auth/session", "/api/metrics", + "/api/metrics/json", "/metrics", "/docs/openapi.json", "/docs/content.json", @@ -3664,6 +3665,84 @@ mod tests { store.close().await; } + #[tokio::test] + async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() { + let (router, store, _dir, secret, _) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let snapshot = router.clone().oneshot( + request("/api/metrics/json").header("cookie", &cookie) + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(snapshot.status(), StatusCode::OK); + assert!(snapshot.headers()["content-type"].to_str().unwrap().starts_with("application/json")); + let snapshot = json_body(snapshot).await; + for field in ["summary", "vhosts", "requests", "system", "cache", "backends", "web_server", + "streams", "docker", "traffic", "transform", "logging", "capacity", "prometheus"] { + assert!(snapshot.get(field).is_some(), "missing: {field}"); + } + assert_eq!(snapshot["vhosts"]["total"], 0); + assert_eq!(snapshot["vhosts"]["enabled"], 0); + assert_eq!(snapshot["vhosts"]["disabled"], 0); + assert_eq!(snapshot["summary"]["vhosts"]["enabled"], 0); + assert_eq!(snapshot["summary"]["backends"]["total"], 0); + assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number()); + assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array()); + assert_eq!(snapshot["prometheus"]["types"]["rust_proxy_requests_total"], "counter"); + assert!(general_api_token_allowed(&Method::GET, "/api/metrics/json", "ro")); + assert!(general_api_token_allowed(&Method::GET, "/api/metrics/summary", "ro")); + assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/json", "rw")); + assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/summary", "rw")); + let summary = router.clone().oneshot( + request("/api/metrics/summary").header("cookie", &cookie) + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(summary.status(), StatusCode::OK); + assert_eq!(summary.headers()["cache-control"], "no-store"); + let summary = json_body(summary).await; + assert!(summary.get("prometheus").is_none()); + assert_eq!(summary["vhosts"]["enabled"], snapshot["summary"]["vhosts"]["enabled"]); + assert_eq!(summary["backends"]["total"], snapshot["summary"]["backends"]["total"]); + store.close().await; + } + + #[tokio::test] + async fn json_metrics_accepts_ro_bearer_only_from_allowed_peer() { + let (router, store, _dir, _, _) = fixture().await; + let token = "T".repeat(64); + let cidrs = vec!["127.0.0.1/32".to_string()]; + assert!(store.create_api_token( + "api-monitor", &auth::secret_hash(&token), "ro", "monitor test", &cidrs, "owner" + ).await.unwrap()); + let build = |peer: Option<&str>| { + let mut request = request("/api/metrics/json") + .header("authorization", format!("Bearer {token}")); + if let Some(peer) = peer { + request = request.extension(PeerAddr(peer.parse().unwrap())); + } + request.body(Body::empty()).unwrap() + }; + let response = router.clone().oneshot(build(None)).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = router.clone().oneshot(build(Some("127.0.0.1:12345"))).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = json_body(response).await; + assert!(response["prometheus"]["samples"].is_object()); + assert!(response.get("config").is_none()); + let summarize = |peer: Option<&str>| { + let mut req = request("/api/metrics/summary") + .header("authorization", format!("Bearer {token}")); + if let Some(peer) = peer { req = req.extension(PeerAddr(peer.parse().unwrap())); } + req.body(Body::empty()).unwrap() + }; + assert_eq!(router.clone().oneshot(summarize(None)).await.unwrap().status(), StatusCode::FORBIDDEN); + let summary = router.oneshot(summarize(Some("127.0.0.1:12345"))).await.unwrap(); + assert_eq!(summary.status(), StatusCode::OK); + let summary = json_body(summary).await; + assert!(summary["traffic"]["requests_total"].is_number()); + assert!(summary.get("prometheus").is_none()); + store.close().await; + } + #[test] fn storage_metadata_is_exposed_only_through_configuration_get_to_api_tokens() { assert!(general_api_token_allowed(&Method::GET, "/api/config", "ro")); diff --git a/src/metrics.rs b/src/metrics.rs index 3d8aa4c..76c11cb 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,4 +1,5 @@ //! Aggregate metrics: no attacker-controlled labels or unbounded series. include!("metrics/global.rs"); +include!("metrics/rate_sampling.rs"); include!("metrics/vhost.rs"); diff --git a/src/metrics/global.rs b/src/metrics/global.rs index b855e32..7c81ae2 100644 --- a/src/metrics/global.rs +++ b/src/metrics/global.rs @@ -2,6 +2,7 @@ use serde::Serialize; use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Default)] pub struct Metrics { + pub summary_rate_sampler: RateSampler, pub admin_requests: AtomicU64, pub admin_response_headers: AtomicU64, pub admin_header_duration_us: AtomicU64, diff --git a/src/metrics/rate_sampling.rs b/src/metrics/rate_sampling.rs new file mode 100644 index 0000000..7e2a004 --- /dev/null +++ b/src/metrics/rate_sampling.rs @@ -0,0 +1,100 @@ +// Bounded pull-driven rate sampling for the JSON summary. No per-client state, +// background task, or unbounded history. A cached sample is shared by readers. +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct SummaryRates { + pub requests_per_second: Option, + pub errors_per_second: Option, + pub bytes_in_per_second: Option, + pub bytes_out_per_second: Option, + pub sample_interval_seconds: Option, +} + +#[derive(Debug, Clone, Copy)] +struct SummaryCounters { + requests: u64, + errors: u64, + bytes_in: u64, + bytes_out: u64, +} + +#[derive(Default)] +struct RateState { + baseline: Option<(Instant, SummaryCounters)>, + latest: SummaryRates, +} + +#[derive(Default)] +pub struct RateSampler(Mutex); + +impl RateSampler { + fn sample(&self, counters: SummaryCounters, now: Instant) -> SummaryRates { + let mut state = self.0.lock().unwrap_or_else(|error| error.into_inner()); + if let Some((previous_at, previous)) = state.baseline { + let elapsed = now.saturating_duration_since(previous_at); + // Do not turn rapid parallel polls into zero/spiky rates. Reuse + // the last measured rate until a useful sampling interval passes. + if elapsed < Duration::from_secs(1) { + return state.latest; + } + let continuous = elapsed <= Duration::from_secs(30) + && counters.requests >= previous.requests + && counters.errors >= previous.errors + && counters.bytes_in >= previous.bytes_in + && counters.bytes_out >= previous.bytes_out; + if continuous { + let seconds = elapsed.as_secs_f64(); + state.latest = SummaryRates { + requests_per_second: Some((counters.requests - previous.requests) as f64 / seconds), + errors_per_second: Some((counters.errors - previous.errors) as f64 / seconds), + bytes_in_per_second: Some((counters.bytes_in - previous.bytes_in) as f64 / seconds), + bytes_out_per_second: Some((counters.bytes_out - previous.bytes_out) as f64 / seconds), + sample_interval_seconds: Some(seconds), + }; + } else { + // Long polling gaps or counter resets invalidate the baseline. + state.latest = SummaryRates::default(); + } + } + state.baseline = Some((now, counters)); + state.latest + } +} + +impl Metrics { + pub fn summary_rates(&self) -> SummaryRates { + self.summary_rate_sampler.sample(SummaryCounters { + requests: self.requests.load(Ordering::Relaxed), + errors: self.errors.load(Ordering::Relaxed), + bytes_in: self.bytes_in.load(Ordering::Relaxed), + bytes_out: self.bytes_out.load(Ordering::Relaxed), + }, Instant::now()) + } +} + +#[cfg(test)] +mod summary_rate_tests { + use super::*; + + #[test] + fn sampled_rates_handle_first_read_short_intervals_long_gaps_and_resets() { + let sampler = RateSampler::default(); + let at = Instant::now(); + let counts = |requests, errors, bytes_in, bytes_out| SummaryCounters { + requests, errors, bytes_in, bytes_out + }; + assert!(sampler.sample(counts(10, 2, 100, 200), at).requests_per_second.is_none()); + let rate = sampler.sample(counts(20, 4, 300, 500), at + Duration::from_secs(2)); + assert_eq!(rate.requests_per_second, Some(5.0)); + assert_eq!(rate.errors_per_second, Some(1.0)); + assert_eq!(rate.bytes_in_per_second, Some(100.0)); + assert_eq!(rate.bytes_out_per_second, Some(150.0)); + assert_eq!(rate.sample_interval_seconds, Some(2.0)); + assert_eq!(sampler.sample(counts(21, 4, 310, 520), at + Duration::from_millis(2300)).requests_per_second, Some(5.0)); + assert!(sampler.sample(counts(22, 4, 330, 550), at + Duration::from_secs(40)).requests_per_second.is_none()); + assert!(sampler.sample(counts(1, 0, 0, 0), at + Duration::from_secs(42)).requests_per_second.is_none()); + assert_eq!(sampler.sample(counts(5, 0, 200, 300), at + Duration::from_secs(44)).requests_per_second, Some(2.0)); + } +} diff --git a/src/pages.rs b/src/pages.rs index 50fa50f..1d80965 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -8,7 +8,8 @@ use std::collections::BTreeMap; pub const MAX_HTML_BYTES: usize = 32 * 1024; pub const ERROR_CODES: [u16; 11] = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]; -pub const BUILTIN_ERROR_CODES: [u16; 12] = [400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]; +pub const BUILTIN_ERROR_CODES: [u16; 12] = + [400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]; /// Built-in operational gateway pages. They are deliberately not marked as /// `PageServed`, so an administrator-configured vhost/global error page can @@ -30,7 +31,7 @@ pub fn builtin_gateway_error(status: StatusCode) -> Option<&'static str> { _ => None, } } -fn builtin_application_page(status: StatusCode) -> String { +pub(crate) fn builtin_application_page(status: StatusCode) -> String { if status == StatusCode::OK { return include_str!("admin/web/status_200.html").to_owned(); } @@ -41,7 +42,8 @@ fn builtin_application_page(status: StatusCode) -> String { // as the packaged pages, rather than the former bright bare 200/4xx page. // Code and canonical reason originate from http::StatusCode, never a request. let reason = status.canonical_reason().unwrap_or("Response"); - format!(r#" + format!( + r#" @@ -59,7 +61,10 @@ fn builtin_application_page(status: StatusCode) -> String {

RustProxy generated this response.

-"#, code = status.as_u16(), reason = reason) +"#, + code = status.as_u16(), + reason = reason + ) } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] @@ -184,8 +189,15 @@ impl Page { .status .and_then(|v| StatusCode::from_u16(v).ok()) .unwrap_or(default_status); - let bodyless = matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED); - let body = if bodyless { String::new() } else { self.body(status) }; + let bodyless = matches!( + status, + StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED + ); + let body = if bodyless { + String::new() + } else { + self.body(status) + }; let length = body.len(); let mut response = Response::new(Body::from(body)); *response.status_mut() = status; @@ -259,9 +271,13 @@ impl VhostPages { validate_errors(&self.errors)?; ensure!( self.intercept_statuses.len() <= ERROR_CODES.len() - && self.intercept_statuses.iter().all(|status| ERROR_CODES.contains(status)) + && self + .intercept_statuses + .iter() + .all(|status| ERROR_CODES.contains(status)) && { - let distinct: std::collections::HashSet<_> = self.intercept_statuses.iter().collect(); + let distinct: std::collections::HashSet<_> = + self.intercept_statuses.iter().collect(); distinct.len() == self.intercept_statuses.len() }, "intercepted application status codes must be unique supported HTTP error codes" @@ -312,7 +328,9 @@ pub fn intercept_application_error( status: None, html: String::new(), }; - let selected = vhost_pages.errors.get(&code) + let selected = vhost_pages + .errors + .get(&code) .or_else(|| global_pages.errors.get(&code)) .unwrap_or(&default_page); let mut body_only = selected.clone(); diff --git a/src/prometheus.rs b/src/prometheus.rs index cf5f213..9535263 100644 --- a/src/prometheus.rs +++ b/src/prometheus.rs @@ -6,6 +6,8 @@ include!("prometheus/traffic.rs"); include!("prometheus/web_php.rs"); include!("prometheus/vhosts_backends.rs"); include!("prometheus/docker_misc.rs"); +mod json; +pub use json::{response as json_response, summary_response}; #[cfg(test)] include!("prometheus/tests.rs"); diff --git a/src/prometheus/json.rs b/src/prometheus/json.rs new file mode 100644 index 0000000..ab015d2 --- /dev/null +++ b/src/prometheus/json.rs @@ -0,0 +1,758 @@ +// Pull-friendly JSON telemetry: a compact chart snapshot plus every numeric +// sample that the Prometheus text exporter currently emits. Both formats use +// the same collector switches and security boundaries. +use super::App; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + sync::atomic::Ordering, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Default, Serialize)] +struct MetricSamples { + // Metadata is stored separately because histogram/summary samples have + // suffixes (_bucket/_sum/_count) that share one Prometheus TYPE family. + types: BTreeMap, + samples: BTreeMap>, +} + +#[derive(Serialize)] +struct Sample { + labels: BTreeMap, + value: Value, +} + +// Read only our own Prometheus exposition, never an untrusted remote payload. +// Preserve large u64 counters exactly; JSON cannot represent NaN or infinity, +// so these values become null rather than breaking the whole API response. +fn sample_value(raw: &str) -> Value { + if let Ok(value) = raw.parse::() { + return json!(value); + } + if let Ok(value) = raw.parse::() { + return json!(value); + } + raw.parse::() + .ok() + .filter(|v| v.is_finite()) + .map_or(Value::Null, |value| json!(value)) +} + +// Prometheus label escaping: \\, \", \n. Scan the quoted value rather than +// splitting on commas (a configured label may contain commas or quotes). +fn parse_labels(raw: &str) -> Option> { + let bytes = raw.as_bytes(); + let mut pos = 0; + let mut labels = BTreeMap::new(); + if raw.is_empty() { + return Some(labels); + } + while pos < bytes.len() { + let equal = raw[pos..].find("=\"")? + pos; + let key = &raw[pos..equal]; + if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + return None; + } + pos = equal + 2; + let mut start = pos; + let mut value = String::new(); + let mut closed = false; + while pos < bytes.len() { + match bytes[pos] { + b'\\' => { + value.push_str(&raw[start..pos]); + pos += 1; + let escaped = *bytes.get(pos)?; + value.push(match escaped { + b'n' => '\n', + b'\\' => '\\', + b'"' => '"', + _ => return None, + }); + pos += 1; + start = pos; + } + b'"' => { + value.push_str(&raw[start..pos]); + pos += 1; + closed = true; + break; + } + _ => pos += 1, + } + } + if !closed || labels.insert(key.to_owned(), value).is_some() { + return None; + } + if pos == bytes.len() { + break; + } + if bytes[pos] != b',' { + return None; + } + pos += 1; + if pos == bytes.len() { + return None; + } + } + Some(labels) +} + +fn parse_exposition(exposition: &str) -> MetricSamples { + let mut result = MetricSamples::default(); + for line in exposition.lines() { + if let Some(spec) = line.strip_prefix("# TYPE ") { + if let Some((name, kind)) = spec.split_once(' ') { + result.types.insert(name.to_owned(), kind.to_owned()); + } + continue; + } + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((selector, raw_value)) = line.rsplit_once(' ') else { + continue; + }; + let (name, labels) = match selector.find('{') { + Some(open) if selector.ends_with('}') => { + let Some(labels) = parse_labels(&selector[open + 1..selector.len() - 1]) else { + continue; + }; + (&selector[..open], labels) + } + Some(_) => continue, + None => (selector, BTreeMap::new()), + }; + if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + continue; + } + result + .samples + .entry(name.to_owned()) + .or_default() + .push(Sample { + labels, + value: sample_value(raw_value), + }); + } + result +} + +fn counts(total: usize, enabled: usize) -> Value { + json!({"total": total, "enabled": enabled, "disabled": total - enabled}) +} + +// A small, field-stable projection that an external dashboard can consume +// without parsing metric families or knowing RustProxy's widget layout. +fn percentage(part: u64, total: u64) -> Option { + (total > 0).then(|| part as f64 * 100.0 / total as f64) +} + +fn summary_from_snapshot(detail: &Value, rates: crate::metrics::SummaryRates) -> Value { + let number = |value: &Value| value.as_u64().unwrap_or(0); + let requests = &detail["requests"]; + let vhosts = &detail["vhosts"]; + let system = &detail["system"]; + let cache = &detail["cache"]; + let backends = &detail["backends"]; + let mut up = 0usize; + let mut down = 0usize; + let mut checking = 0usize; + let mut draining = 0usize; + let mut disabled = 0usize; + let mut unknown = 0usize; + let mut backend_bytes_per_second = 0.0; + let mut backend_members = 0usize; + let mut ready_pools = 0usize; + if let Some(pools) = backends["pools"].as_array() { + for pool in pools { + if pool["ready"].as_bool() == Some(true) { + ready_pools += 1; + } + if let Some(members) = pool["members"].as_array() { + backend_members += members.len(); + for member in members { + match member["status"].as_str().unwrap_or("unknown") { + "up" => up += 1, + "down" => down += 1, + "checking" => checking += 1, + "draining" => draining += 1, + "disabled" => disabled += 1, + _ => unknown += 1, + } + if let Some(value) = member["traffic_bytes_per_second"] + .as_f64() + .filter(|value| value.is_finite() && *value >= 0.0) + { + backend_bytes_per_second += value; + } + } + } + } + } + let enabled = number(&vhosts["enabled"]); + let serving_estimate = vhosts["items"].as_array().map_or(0, |items| { + items + .iter() + .filter(|item| item["serving_estimate"].as_bool() == Some(true)) + .count() + }); + let backend_vhosts_ready = vhosts["items"].as_array().map_or(0, |items| { + items + .iter() + .filter(|item| { + item["enabled"].as_bool() == Some(true) + && item["backend_ready"].as_bool() == Some(true) + }) + .count() + }); + let backend_vhosts_unready = vhosts["items"].as_array().map_or(0, |items| { + items + .iter() + .filter(|item| { + item["enabled"].as_bool() == Some(true) + && item["backend_ready"].as_bool() == Some(false) + }) + .count() + }); + let bytes_in = number(&requests["bytes_in_total"]); + let bytes_out = number(&requests["bytes_out_total"]); + let bytes_total = bytes_in.saturating_add(bytes_out); + let uptime = number(&detail["uptime_seconds"]); + let memory_percent = system["memory_used_bytes"] + .as_u64() + .zip(system["memory_total_bytes"].as_u64()) + .and_then(|(used, total)| percentage(used, total)); + let request_error_percent = percentage( + number(&requests["errors_total"]), + number(&requests["completed_total"]), + ); + let web_requests = detail["web_server"]["services"] + .as_array() + .map_or(0u64, |items| { + items + .iter() + .map(|item| number(&item["web"]["requests"])) + .fold(0u64, u64::saturating_add) + }); + let stream_active = detail["streams"]["items"].as_array().map_or(0u64, |items| { + items + .iter() + .map(|item| number(&item["metrics"]["active"])) + .fold(0u64, u64::saturating_add) + }); + let capacity_limit = number(&detail["capacity"]["requests_limit"]); + let capacity_available = number(&detail["capacity"]["requests_available"]); + let backend_bytes_per_second = backend_bytes_per_second + .is_finite() + .then_some(backend_bytes_per_second); + let vhosts_summary = json!({ + "total": vhosts["total"], "enabled": enabled, "disabled": vhosts["disabled"], + "serving_estimate": serving_estimate, + "backend_ready": backend_vhosts_ready, "backend_not_ready": backend_vhosts_unready, + "docker_enabled": detail["docker"]["vhosts"]["enabled"] + }); + let backends_summary = json!({ + "total": backend_members, "configured_static": backends["total"], + "up": up, "down": down, "checking": checking, + "draining": draining, "disabled": disabled, "unknown": unknown, + "pools_total": backends["pools"].as_array().map_or(0, Vec::len), + "pools_ready": ready_pools, + "active_exchanges": backends["active"], + "traffic_bytes_per_second": backend_bytes_per_second + }); + let traffic_summary = json!({ + "requests_total": requests["total"], + "requests_completed_total": requests["completed_total"], + "requests_active": requests["active"], + "requests_per_second": rates.requests_per_second, + "requests_per_second_average": (uptime > 0).then(|| number(&requests["total"]) as f64 / uptime as f64), + "errors_total": requests["errors_total"], + "errors_per_second": rates.errors_per_second, + "error_percent": request_error_percent, + "status_2xx_total": requests["status"]["2xx"], + "status_4xx_total": requests["status"]["4xx"], + "status_5xx_total": requests["status"]["5xx"], + "bytes_in_total": bytes_in, + "bytes_out_total": bytes_out, + "bytes_total": bytes_total, + "megabytes_total": bytes_total as f64 / 1_000_000.0, + "bytes_in_per_second": rates.bytes_in_per_second, + "bytes_out_per_second": rates.bytes_out_per_second, + "bytes_per_second": rates.bytes_in_per_second.zip(rates.bytes_out_per_second).map(|(a, b)| a + b), + "sample_interval_seconds": rates.sample_interval_seconds, + "network_rx_bytes_per_second": system["network_rx_bytes_per_second"], + "network_tx_bytes_per_second": system["network_tx_bytes_per_second"] + }); + let system_summary = json!({ + "cpu_percent": system["cpu_percent"], + "process_cpu_percent": system["process_cpu_percent"], + "memory_percent": memory_percent, + "memory_used_bytes": system["memory_used_bytes"], + "memory_total_bytes": system["memory_total_bytes"], + "process_rss_bytes": system["process_rss_bytes"], + "sampled_at_ms": system["sampled_at_ms"] + }); + let cache_summary = json!({ + "hits_total": cache["hits"], "misses_total": cache["misses"], + "hit_percent": cache["hit_ratio"].as_f64().map(|v| v * 100.0), + "hot_entries": cache["entries"], "cold_entries": cache["cold_entries"], + "hot_bytes": cache["resident_bytes"], "cold_bytes": cache["cold_bytes"] + }); + json!({ + "collected_at_ms": detail["collected_at_ms"], + "uptime_seconds": uptime, + "vhosts": vhosts_summary, + "backends": backends_summary, + "traffic": traffic_summary, + "system": system_summary, + "cache": cache_summary, + "web_server": { + "services_total": detail["web_server"]["total"], + "requests_total": web_requests + }, + "streams": { + "total": detail["streams"]["total"], + "enabled": detail["streams"]["enabled"], + "active_sessions": stream_active + }, + "capacity": { + "requests_limit": capacity_limit, + "requests_used": capacity_limit.saturating_sub(capacity_available), + "requests_used_percent": percentage(capacity_limit.saturating_sub(capacity_available), capacity_limit) + } + }) +} + +fn snapshot(app: &App) -> Value { + let snapshot = app.config.load_full(); + let config = &snapshot.envelope.config; + let metrics = app.metrics.view(); + let system = app.diagnostics.view(); + let cache = app.cache.view(); + + let vhosts = config + .vhosts + .iter() + .map(|host| { + let live = snapshot.vhost_metrics.get(&host.id).map(|m| m.view()); + let pool_ready = snapshot + .pools + .iter() + .find(|pool| pool.id == host.id) + .map(|pool| pool.ready()); + let serving_estimate = + host.enabled && (pool_ready == Some(true) || !host.source.is_reverse_proxy()); + json!({"id": host.id, "enabled": host.enabled, "metrics": live, + "backend_ready": pool_ready, "serving_estimate": serving_estimate}) + }) + .collect::>(); + let mut backend_up = 0usize; + let mut backend_down = 0usize; + let mut backend_active = 0u64; + let pools = snapshot + .pools + .iter() + .map(|pool| { + let view = pool.view(); + let members = view["members"] + .as_array() + .map(|items| { + items.iter().map(|member| { + let status = member["status"].as_str().unwrap_or("unknown"); + if status == "up" { backend_up += 1; } + if status == "down" { backend_down += 1; } + backend_active = backend_active.saturating_add(member["active"].as_u64().unwrap_or(0)); + json!({ + "id": member["id"], "status": status, "healthy": member["healthy"], + "active": member["active"], "requests_total": member["requests"], + "errors_total": member["errors"], "latency_ms": member["latency_ms"], + "traffic_bytes_per_second": member["traffic_bytes_per_second"] + }) + }).collect::>() + }) + .unwrap_or_default(); + json!({"vhost": pool.id, "ready": pool.ready(), "members": members}) + }) + .collect::>(); + + let services = config + .web_services + .iter() + .filter_map(|service| { + let compiled = snapshot.web_php_services.get(&service.id)?; + let php = compiled.php.as_ref().map(|handler| { + let available = handler.permits.available_permits(); + json!({"metrics": handler.metrics.view(), "capacity_limit": handler.max_concurrent, + "capacity_used": handler.max_concurrent.saturating_sub(available), + "capacity_available": available}) + }); + Some(json!({"id": service.id, "web": compiled.web_metrics.view(), "php": php})) + }) + .collect::>(); + let streams = config + .streams + .iter() + .map(|stream| { + let live = app.stream_metrics.get(&stream.id).map(|m| { + let view = m.view(); + // The dashboard's full stream view includes configured backend + // socket addresses and health internals. Metrics consumers only + // need counters/status, never infrastructure addresses. + let backends = view + .backends + .iter() + .map(|backend| { + json!({ + "id": backend.id, + "enabled": backend.enabled, + "status": backend.status, + "active": backend.active, + "sessions_total": backend.sessions_total, + "sessions_completed": backend.sessions_completed, + "errors": backend.errors, + "rejected": backend.rejected, + "bytes_in": backend.bytes_in, + "bytes_out": backend.bytes_out, + "packets_in": backend.packets_in, + "packets_out": backend.packets_out + }) + }) + .collect::>(); + json!({ + "sessions_total": view.sessions_total, + "sessions_completed": view.sessions_completed, + "active": view.active, + "rejected": view.rejected, + "errors": view.errors, + "bytes_in": view.bytes_in, + "bytes_out": view.bytes_out, + "packets_in": view.packets_in, + "packets_out": view.packets_out, + "backends": backends + }) + }); + json!({"id": stream.id, "enabled": stream.enabled, "metrics": live}) + }) + .collect::>(); + let traffic = app.traffic.view(None); + let data = &traffic.data; + let methods = data + .method_families + .iter() + .filter(|x| { + super::safe_traffic_state( + &x.value, + &[ + "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE", + "OTHER", + ], + ) + }) + .collect::>(); + let statuses = data + .statuses + .iter() + .filter(|x| super::safe_traffic_state(&x.value, &["1xx", "2xx", "3xx", "4xx", "5xx"])) + .collect::>(); + let cache_results = data + .cache + .iter() + .filter(|x| super::safe_traffic_state(&x.value, &["HIT", "MISS", "BYPASS", "-"])) + .collect::>(); + let sources = data + .sources + .iter() + .filter(|x| { + super::safe_traffic_state( + &x.value, + &[ + "reverse_proxy", + "web_server", + "local_response", + "unresolved", + ], + ) + }) + .collect::>(); + let agents = &config.docker_agents.agents; + let docker_vhosts = agents + .iter() + .map(|agent| agent.assignments.len()) + .sum::(); + let docker_enabled = agents + .iter() + .filter(|agent| agent.enabled) + .flat_map(|agent| &agent.assignments) + .filter(|assignment| assignment.enabled) + .count(); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Split the JSON builders: a single giant json! macro would exceed the + // default macro recursion depth on supported stable Rust toolchains. + let requests = json!({ + "total": metrics.requests, "completed_total": metrics.completed, + "active": metrics.active, "errors_total": metrics.errors, + "rejected_total": metrics.rejected_requests, + "status": { + "1xx": app.metrics.status_1xx.load(Ordering::Relaxed), + "2xx": app.metrics.status_2xx.load(Ordering::Relaxed), + "3xx": app.metrics.status_3xx.load(Ordering::Relaxed), + "4xx": app.metrics.status_4xx.load(Ordering::Relaxed), + "5xx": app.metrics.status_5xx.load(Ordering::Relaxed) + }, + "bytes_in_total": app.metrics.bytes_in.load(Ordering::Relaxed), + "bytes_out_total": app.metrics.bytes_out.load(Ordering::Relaxed), + "mean_duration_ms": metrics.mean_duration_ms, + "connections_total": metrics.connections, + "connections_active": metrics.active_connections, + "websockets_total": metrics.websocket_total, + "websockets_active": metrics.websocket_active, + "http1_total": metrics.http1_requests, + "http2_total": metrics.http2_requests, + "http3_total": metrics.http3_requests + }); + let system = json!({ + "sampled_at_ms": system.sampled_at_ms, + "cpu_percent": system.cpu_percent, + "process_cpu_percent": system.process_cpu_percent, + "logical_cpus": system.logical_cpus, + "system_uptime_seconds": system.system_uptime_seconds, + "memory_total_bytes": system.memory_total_bytes, + "memory_used_bytes": system.memory_used_bytes, + "process_rss_bytes": system.process_rss_bytes, + "process_virtual_memory_bytes": system.process_virtual_memory_bytes, + "process_threads": system.process_threads, + "open_fds": system.open_fds, + "max_fds": system.max_fds, + "process_read_bytes": system.process_read_bytes, + "process_write_bytes": system.process_write_bytes, + "cgroup_memory_current_bytes": system.cgroup_memory_current_bytes, + "cgroup_memory_max_bytes": system.cgroup_memory_max_bytes, + "swap_total_bytes": system.system_swap_total_bytes, + "swap_used_bytes": system.system_swap_used_bytes, + "network_rx_bytes_per_second": system.network_rx_bytes_per_second, + "network_tx_bytes_per_second": system.network_tx_bytes_per_second, + "network_rx_bytes_total": system.network_rx_bytes, + "network_tx_bytes_total": system.network_tx_bytes, + "network_interfaces": system.network_interfaces, + "load_average": system.load_average + }); + let traffic = json!({ + "requests_total": data.requests, + "errors_total": data.errors, + "request_bytes_total": data.request_bytes, + "response_bytes_total": data.response_bytes, + "mean_duration_ms": data.mean_duration_ms, + "queue_dropped_total": traffic.queue_dropped, + "approximate_top_lists": traffic.approximate_top_lists, + "methods": methods, "statuses": statuses, + "cache": cache_results, "sources": sources + }); + let mut detail = json!({ + "version": env!("CARGO_PKG_VERSION"), + "collected_at_ms": now_ms, + "uptime_seconds": app.started.elapsed().as_secs(), + "revision": snapshot.envelope.revision, + "vhosts": { + "total": config.vhosts.len(), + "enabled": config.vhosts.iter().filter(|h| h.enabled).count(), + "disabled": config.vhosts.iter().filter(|h| !h.enabled).count(), + "items": vhosts + }, + "redirect_hosts": counts(config.redirects.len(), config.redirects.iter().filter(|h| h.enabled).count()), + "not_found_hosts": counts(config.not_found_hosts.len(), config.not_found_hosts.iter().filter(|h| h.enabled).count()), + "requests": requests, + "system": system, + "cache": cache, + "backends": { + "total": config.vhosts.iter().map(|h| h.backends.len()).sum::(), + "up": backend_up, "down": backend_down, "active": backend_active, + "pools": pools + }, + "web_server": {"total": config.web_services.len(), "services": services}, + "streams": { + "total": config.streams.len(), + "enabled": config.streams.iter().filter(|s| s.enabled).count(), + "disabled": config.streams.iter().filter(|s| !s.enabled).count(), + "items": streams + }, + "docker": { + "agents": counts(agents.len(), agents.iter().filter(|a| a.enabled).count()), + "vhosts": counts(docker_vhosts, docker_enabled) + }, + "traffic": traffic, + "transform": app.transform_metrics.total(), + "logging": { + "dropped_total": app.logs.dropped.load(Ordering::Relaxed), + "sinks": app.logs.stats() + }, + "capacity": { + "requests_limit": app.max_inflight, + "requests_available": app.inflight.available_permits(), + "listeners_active": app.active_listeners.len() + } + }); + // Share the exact same computed summary between the full and small APIs. + detail["summary"] = summary_from_snapshot(&detail, app.metrics.summary_rates()); + detail +} + +pub fn summary_payload(app: &App) -> Value { + snapshot(app)["summary"].clone() +} + +pub fn summary_response(app: &App) -> Response { + ( + [ + ("cache-control", "no-store"), + ("x-content-type-options", "nosniff"), + ], + axum::Json(summary_payload(app)), + ) + .into_response() +} + +pub fn payload(app: &App) -> Value { + let mut result = snapshot(app); + // Use one exporter implementation, so new Prometheus families automatically + // appear here without implementing the same counters a second time. + result["prometheus"] = json!(parse_exposition(&super::render(app))); + result +} + +pub fn response(app: &App) -> Response { + ( + [ + ("cache-control", "no-store"), + ("x-content-type-options", "nosniff"), + ], + axum::Json(payload(app)), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn summary_computes_backend_states_rps_totals_and_percentages() { + let detail: Value = serde_json::from_str( + r#"{ + "collected_at_ms": 456, "uptime_seconds": 100, + "vhosts": {"total": 4, "enabled": 3, "disabled": 1, "items": [ + {"enabled": true, "backend_ready": true, "serving_estimate": true}, + {"enabled": true, "backend_ready": false, "serving_estimate": false}, + {"enabled": true, "backend_ready": null, "serving_estimate": true}, + {"enabled": false, "backend_ready": true, "serving_estimate": false} + ]}, + "docker": {"vhosts": {"enabled": 2}}, + "backends": {"total": 3, "active": 5, "pools": [ + {"ready": true, "members": [ + {"status": "up", "traffic_bytes_per_second": 1000.0}, + {"status": "down", "traffic_bytes_per_second": 50.0}, + {"status": "checking", "traffic_bytes_per_second": null} + ]}, + {"ready": false, "members": [ + {"status": "disabled", "traffic_bytes_per_second": 0.0}, + {"status": "draining", "traffic_bytes_per_second": 250.0} + ]} + ]}, + "requests": {"total": 100, "completed_total": 80, "active": 2, + "errors_total": 4, "bytes_in_total": 20000000, + "bytes_out_total": 80000000, + "status": {"2xx": 70, "4xx": 5, "5xx": 5}}, + "system": {"cpu_percent": 25.0, "process_cpu_percent": 4.0, + "memory_used_bytes": 200, "memory_total_bytes": 800, + "process_rss_bytes": 32, "sampled_at_ms": 111, + "network_rx_bytes_per_second": 10.0, + "network_tx_bytes_per_second": 20.0}, + "cache": {"hits": 3, "misses": 1, "hit_ratio": 0.75, + "entries": 1, "cold_entries": 2, "resident_bytes": 300, "cold_bytes": 500}, + "web_server": {"total": 2, "services": [ + {"web": {"requests": 4}}, {"web": {"requests": 5}}]}, + "streams": {"total": 2, "enabled": 1, "items": [ + {"metrics": {"active": 2}}, {"metrics": null}]}, + "capacity": {"requests_limit": 10, "requests_available": 7} + }"#, + ) + .unwrap(); + let summary = summary_from_snapshot( + &detail, + crate::metrics::SummaryRates { + requests_per_second: Some(7.5), + errors_per_second: Some(0.5), + bytes_in_per_second: Some(1000.0), + bytes_out_per_second: Some(2000.0), + sample_interval_seconds: Some(2.0), + }, + ); + assert_eq!(summary["vhosts"]["serving_estimate"], 2); + assert_eq!(summary["vhosts"]["backend_ready"], 1); + assert_eq!(summary["vhosts"]["backend_not_ready"], 1); + assert_eq!(summary["backends"]["total"], 5); + assert_eq!(summary["backends"]["configured_static"], 3); + assert_eq!(summary["backends"]["up"], 1); + assert_eq!(summary["backends"]["down"], 1); + assert_eq!(summary["backends"]["checking"], 1); + assert_eq!(summary["backends"]["draining"], 1); + assert_eq!(summary["backends"]["disabled"], 1); + assert_eq!(summary["backends"]["traffic_bytes_per_second"], 1300.0); + assert_eq!(summary["traffic"]["requests_per_second"], 7.5); + assert_eq!(summary["traffic"]["bytes_per_second"], 3000.0); + assert_eq!(summary["traffic"]["bytes_total"], 100_000_000); + assert_eq!(summary["traffic"]["megabytes_total"], 100.0); + assert_eq!(summary["traffic"]["error_percent"], 5.0); + assert_eq!(summary["system"]["memory_percent"], 25.0); + assert_eq!(summary["cache"]["hit_percent"], 75.0); + assert_eq!(summary["web_server"]["requests_total"], 9); + assert_eq!(summary["streams"]["active_sessions"], 2); + assert_eq!(summary["capacity"]["requests_used"], 3); + assert_eq!(summary["capacity"]["requests_used_percent"], 30.0); + } + + #[test] + fn json_samples_preserve_labels_large_counters_and_histograms() { + let labels = super::super::labels(&[ + ("vhost", "a,\\\"b\nwith newline"), + ("domain", "example.test"), + ]); + let source = format!( + "# TYPE rust_proxy_requests_total counter\nrust_proxy_requests_total 9007199254740993\n\ + # TYPE rust_proxy_vhost_exchange_duration_seconds histogram\n\ + rust_proxy_vhost_exchange_duration_seconds_bucket{{{labels},le=\"+Inf\"}} 2\n\ + rust_proxy_vhost_exchange_duration_seconds_sum{{{labels}}} 1.25\n" + ); + let parsed = parse_exposition(&source); + assert_eq!(parsed.types["rust_proxy_requests_total"], "counter"); + assert_eq!( + parsed.types["rust_proxy_vhost_exchange_duration_seconds"], + "histogram" + ); + assert_eq!( + parsed.samples["rust_proxy_requests_total"][0] + .value + .as_u64(), + Some(9007199254740993) + ); + let bucket = &parsed.samples["rust_proxy_vhost_exchange_duration_seconds_bucket"][0]; + assert_eq!(bucket.labels["vhost"], "a,\\\"b\nwith newline"); + assert_eq!(bucket.labels["le"], "+Inf"); + assert_eq!(bucket.value.as_u64(), Some(2)); + assert_eq!( + parsed.samples["rust_proxy_vhost_exchange_duration_seconds_sum"][0] + .value + .as_f64(), + Some(1.25) + ); + assert!(parse_labels("vhost=\"broken").is_none()); + assert!(parse_labels("vhost=\"one\",vhost=\"two\"").is_none()); + assert_eq!(sample_value("+Inf"), serde_json::Value::Null); + } +} diff --git a/src/prometheus/model.rs b/src/prometheus/model.rs index 503808d..ba31e19 100644 --- a/src/prometheus/model.rs +++ b/src/prometheus/model.rs @@ -140,10 +140,15 @@ pub fn router(app: Arc, token: Option>) -> Router { async fn scrape(State(exporter): State>, request: Request) -> Response { let config = exporter.app.config.load_full(); let settings = &config.envelope.config.prometheus; + // JSON is a sibling of the configured path, with identical transport auth. + let json_path = format!("{}.json", settings.path); + let wants_json = request.uri().path() == json_path.as_str(); + let summary_path = format!("{}.summary.json", settings.path); + let wants_summary = request.uri().path() == summary_path.as_str(); // Revocation is immediate even when changing bind/credentials needs restart. if !settings.enabled || settings.restart_differs(&exporter.app.active_prometheus) - || request.uri().path() != settings.path + || (request.uri().path() != settings.path && !wants_json && !wants_summary) || request.uri().query().is_some() { return StatusCode::NOT_FOUND.into_response(); @@ -201,7 +206,13 @@ async fn scrape(State(exporter): State>, request: Request) -> Resp let Ok(_permit) = exporter.slots.try_acquire() else { return StatusCode::TOO_MANY_REQUESTS.into_response(); }; - response(&exporter.app) + if wants_json { + json_response(&exporter.app) + } else if wants_summary { + summary_response(&exporter.app) + } else { + response(&exporter.app) + } } pub fn response(app: &App) -> Response { ( diff --git a/src/prometheus/tests.rs b/src/prometheus/tests.rs index f28327c..7b9a33e 100644 --- a/src/prometheus/tests.rs +++ b/src/prometheus/tests.rs @@ -56,4 +56,168 @@ mod tests { assert_eq!(text.matches("# TYPE x").count(), 1); assert!(text.ends_with('\n')); } + + #[tokio::test] + async fn json_exporter_enforces_peer_bearer_and_exact_path() { + use crate::{ + auth, + config::{self, Config, ValidationPolicy}, + db::Store, + }; + use axum::body::Body; + use http::Request; + use std::{sync::Arc, time::Duration}; + use tower::ServiceExt; + + let dir = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.prometheus.enabled = true; + let policy = ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + }; + let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); + store + .initialize_admin("owner", "unused-test-hash", "test") + .await + .unwrap(); + let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) + .await + .unwrap(); + let snapshot = config::compile(config, 0, &policy).unwrap(); + let state = App::new( + store.clone(), + snapshot, + policy, + auth, + 10, + Duration::from_secs(30), + Duration::from_secs(30), + Duration::from_secs(300), + ); + let token = "test-secret-1234567890-abcdefghijklmnopqrstuvwxyz"; + state + .prometheus_token_hash + .store(Some(Arc::new(auth::secret_hash(token)))); + let router = router(state); + let make = |path: &str, peer: Option<&str>, bearer: Option<&str>| { + let mut builder = Request::builder().uri(path); + if let Some(peer) = peer { + builder = builder.extension(PeerAddr(peer.parse().unwrap())); + } + if let Some(bearer) = bearer { + builder = builder.header("authorization", format!("Bearer {bearer}")); + } + builder.body(Body::empty()).unwrap() + }; + for (path, peer, bearer, expected) in [ + ("/metrics.json", None, Some(token), StatusCode::FORBIDDEN), + ( + "/metrics.json", + Some("203.0.113.1:5555"), + Some(token), + StatusCode::FORBIDDEN, + ), + ( + "/metrics.json", + Some("127.0.0.1:5555"), + None, + StatusCode::UNAUTHORIZED, + ), + ( + "/metrics.json", + Some("127.0.0.1:5555"), + Some("invalid"), + StatusCode::UNAUTHORIZED, + ), + ( + "/metrics.json?no=1", + Some("127.0.0.1:5555"), + Some(token), + StatusCode::NOT_FOUND, + ), + ( + "/metrics.json/", + Some("127.0.0.1:5555"), + Some(token), + StatusCode::NOT_FOUND, + ), + ( + "/metrics.summary.json", + None, + Some(token), + StatusCode::FORBIDDEN, + ), + ( + "/metrics.summary.json", + Some("127.0.0.1:5555"), + None, + StatusCode::UNAUTHORIZED, + ), + ( + "/metrics.summary.json?any=1", + Some("127.0.0.1:5555"), + Some(token), + StatusCode::NOT_FOUND, + ), + ( + "/metrics.summary.json/", + Some("127.0.0.1:5555"), + Some(token), + StatusCode::NOT_FOUND, + ), + ] { + let response = router + .clone() + .oneshot(make(path, peer, bearer)) + .await + .unwrap(); + assert_eq!(response.status(), expected, "{path} {peer:?}"); + } + let response = router + .clone() + .oneshot(make("/metrics.json", Some("127.0.0.1:5555"), Some(token))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(response.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("application/json")); + let bytes = axum::body::to_bytes(response.into_body(), 8_000_000) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["vhosts"]["total"], 0); + assert!(body["requests"]["total"].is_number()); + assert!(body["prometheus"]["samples"]["rust_proxy_requests_total"].is_array()); + let response = router + .clone() + .oneshot(make( + "/metrics.summary.json", + Some("127.0.0.1:5555"), + Some(token), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["cache-control"], "no-store"); + let bytes = axum::body::to_bytes(response.into_body(), 1_000_000) + .await + .unwrap(); + let summary: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(summary["vhosts"]["total"], 0); + assert!(summary["traffic"]["bytes_total"].is_number()); + assert!(summary.get("prometheus").is_none()); + let response = router + .oneshot(make("/metrics", Some("127.0.0.1:5555"), Some(token))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + store.close().await; + } } diff --git a/src/web_server/response/headers.rs b/src/web_server/response/headers.rs index 8fced2a..1af8292 100644 --- a/src/web_server/response/headers.rs +++ b/src/web_server/response/headers.rs @@ -21,7 +21,8 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio_util::io::ReaderStream; pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Response { - // Keep internal diagnostics in server logs, not in public HTTP bodies. + // Keep implementation diagnostics in logs; use the existing embedded + // application-style pages for every Web Server-generated error. if status.is_server_error() { tracing::warn!( status = status.as_u16(), @@ -29,46 +30,46 @@ pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Respo "Web Server error" ); } - let message = format!( - "{} {}\n", - status.as_u16(), - status.canonical_reason().unwrap_or("Error") - ); - let mut response = Response::new(Body::from(message.clone())); - *response.status_mut() = status; - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response.headers_mut().insert( - CONTENT_LENGTH, - HeaderValue::from_str(&message.len().to_string()).unwrap(), - ); - response - .headers_mut() - .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); - response.headers_mut().insert( - "x-content-type-options", - HeaderValue::from_static("nosniff"), - ); - response + // The public serve() facade strips bodies from HEAD responses while + // retaining the same Content-Length as the corresponding GET. + builtin_error_response(status, &Method::GET) } pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Response { - let html = crate::pages::builtin_gateway_error(status) - .unwrap_or("

Error

"); - let mut response = Response::new(if *method == Method::HEAD { - Body::empty() - } else { - Body::from(html) - }); + // Reuse the bundled pages wherever one exists. For statuses such as 405, + // use the shared styled application fallback instead of a plain-text or + // unstyled generic error. Do not mark these as PageServed so normal Vhost + // error-page policy can still replace them. + let (body, length) = match crate::pages::builtin_gateway_error(status) { + Some(html) => ( + if *method == Method::HEAD { + Body::empty() + } else { + Body::from(html) + }, + html.len(), + ), + None => { + let html = crate::pages::builtin_application_page(status); + let length = html.len(); + ( + if *method == Method::HEAD { + Body::empty() + } else { + Body::from(html) + }, + length, + ) + } + }; + let mut response = Response::new(body); *response.status_mut() = status; response.headers_mut().insert( CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ); - if let Ok(length) = HeaderValue::from_str(&html.len().to_string()) { - response.headers_mut().insert(CONTENT_LENGTH, length); + if let Ok(value) = HeaderValue::from_str(&length.to_string()) { + response.headers_mut().insert(CONTENT_LENGTH, value); } response .headers_mut() diff --git a/src/web_server/tests.rs b/src/web_server/tests.rs index a161e4d..361dcc6 100644 --- a/src/web_server/tests.rs +++ b/src/web_server/tests.rs @@ -119,10 +119,13 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() { .await; assert_eq!(missing.status(), StatusCode::NOT_FOUND); assert_eq!(missing.headers()[CACHE_CONTROL], "no-store"); - assert_eq!(missing.headers()[CONTENT_TYPE], "text/plain; charset=utf-8"); + 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(), - "404 Not Found\n" + missing.into_body().collect().await.unwrap().to_bytes().as_ref(), + crate::pages::builtin_gateway_error(StatusCode::NOT_FOUND) + .unwrap() + .as_bytes() ); let missing_head = serve_request( @@ -132,7 +135,14 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() { ) .await; assert_eq!(missing_head.status(), StatusCode::NOT_FOUND); - assert_eq!(missing_head.headers()[CONTENT_LENGTH], "14"); + let missing_length = crate::pages::builtin_gateway_error(StatusCode::NOT_FOUND) + .unwrap() + .len() + .to_string(); + assert_eq!( + missing_head.headers()[CONTENT_LENGTH], + HeaderValue::from_str(&missing_length).unwrap() + ); assert!(missing_head .into_body() .collect() @@ -171,42 +181,105 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() { .await; assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); assert_eq!(method.headers()[ALLOW], "GET, HEAD"); - assert_eq!( - method.into_body().collect().await.unwrap().to_bytes(), - "405 Method Not Allowed\n" - ); + assert_eq!(method.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(method.headers()[CACHE_CONTROL], "no-store"); + let method_body = method.into_body().collect().await.unwrap().to_bytes(); + let method_html = std::str::from_utf8(&method_body).unwrap(); + assert!(method_html.contains("

405 - Method Not Allowed

")); + assert!(method_html.contains("background: #131615")); } #[tokio::test] async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses() { use std::io::ErrorKind; - for (kind, expected_status, expected_text) in [ - ( - ErrorKind::NotFound, - StatusCode::NOT_FOUND, - "404 Not Found\n", - ), - ( - ErrorKind::PermissionDenied, - StatusCode::FORBIDDEN, - "403 Forbidden\n", - ), - ( - ErrorKind::Other, - StatusCode::INTERNAL_SERVER_ERROR, - "500 Internal Server Error\n", - ), + for (kind, expected_status) in [ + (ErrorKind::NotFound, StatusCode::NOT_FOUND), + (ErrorKind::PermissionDenied, StatusCode::FORBIDDEN), + (ErrorKind::Other, StatusCode::INTERNAL_SERVER_ERROR), ] { let error = std::io::Error::from(kind); let response = super::response::io_response(&error); assert_eq!(response.status(), expected_status); assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); let body = response.into_body().collect().await.unwrap().to_bytes(); - assert_eq!(body.as_ref(), expected_text.as_bytes()); - assert!(!body.starts_with(b"RustProxy:")); + assert_eq!( + body.as_ref(), + crate::pages::builtin_gateway_error(expected_status) + .unwrap() + .as_bytes() + ); + assert!(!body.windows(b"RustProxy:".len()).any(|w| w == b"RustProxy:")); } } +#[tokio::test] +async fn web_server_validation_and_unmapped_error_statuses_are_styled() { + let tmp = TempDir::new().unwrap(); + let service = service(tmp.path(), false); + // Path validation follows the same existing built-in page as other 400s. + let invalid = serve_request(&service, &request(Method::GET, "/"), "/../secret").await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + assert_eq!(invalid.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!( + invalid.into_body().collect().await.unwrap().to_bytes().as_ref(), + crate::pages::builtin_gateway_error(StatusCode::BAD_REQUEST) + .unwrap() + .as_bytes() + ); + + // 405 has no dedicated packaged HTML file: reuse the embedded styled + // fallback, while preserving Allow and the actual HTTP status. + let method = serve_request(&service, &request(Method::PUT, "/"), "/").await; + assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(method.headers()[ALLOW], "GET, HEAD"); + assert_eq!(method.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + let body = method.into_body().collect().await.unwrap().to_bytes(); + assert!(std::str::from_utf8(&body) + .unwrap() + .contains("405 - Method Not Allowed")); + + // An operator-configured status without a dedicated HTML template still + // uses the common styled fallback, including for HEAD requests. + let fallback_service = Service { + id: "teapot".into(), + name: "Teapot".into(), + document_root: tmp.path().to_path_buf(), + try_files: vec!["=418".into()], + ..Default::default() + } + .compile() + .unwrap(); + let fallback = serve_request( + &fallback_service, + &request(Method::GET, "/missing"), + "/missing", + ) + .await; + 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()); + 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 -")); + assert!(fallback_html.contains("background: #131615")); + + let head = serve_request( + &fallback_service, + &request(Method::HEAD, "/missing"), + "/missing", + ) + .await; + assert_eq!(head.status(), StatusCode::IM_A_TEAPOT); + assert_eq!( + head.headers()[CONTENT_LENGTH], + HeaderValue::from_str(&fallback_html.len().to_string()).unwrap() + ); + assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty()); +} + #[tokio::test] async fn last_modified_condition_returns_304() { let tmp = TempDir::new().unwrap();