This commit is contained in:
Mateusz Gruszczyński committed 2026-10-19 15:40:37 +02:00
1 parent 34fb534e02
commit b2b5b45d8a
22 files changed
+2141 -102

No files matched your search

+2 -1
View File
@@ -4,9 +4,10 @@ Release history focused on **features, settings, default behavior, compatibility
## 0.34.2 — configuration storage visibility and public HTTP error responses ## 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. - **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. - 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. - Removed an unused forwarding accessor (`peer_header`) to avoid a Rust `dead_code` warning without changing forwarded header behavior.
+3 -2
View File
@@ -18,9 +18,10 @@ Features and default settings vary by component; configure only the capabilities
## 0.34.2 ## 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. - **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. - 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 ## 0.34.1
@@ -60,4 +61,4 @@ The authenticated **`/docs`** page includes the guides and a separate interactiv
## Status of this source archive ## 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).
+28 -26
View File
@@ -2,12 +2,12 @@
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
65441f53fe87a21b1b960ddd7b76a6346a7ab97d6c75ba71c04a1d28504a7ee0 AGENTS.md 65441f53fe87a21b1b960ddd7b76a6346a7ab97d6c75ba71c04a1d28504a7ee0 AGENTS.md
9141d37b0acd820916bb0157f2c206d4c20ac56e72454cecf3baea34ab1051b5 CHANGELOG.md cf8531560d308e9acb81c5155b644b5ee0821b8f8ad57d96955e7668030f883c CHANGELOG.md
b8d1eceeb2e8aa506da7d5675307a3ca7f101bc7a78a8cc00bb3b441a4dd85a9 Cargo.lock b8d1eceeb2e8aa506da7d5675307a3ca7f101bc7a78a8cc00bb3b441a4dd85a9 Cargo.lock
55788c48310d81442f3a937de1a42e67cae7482f88ec4f1699b50e37f6f56acb Cargo.toml 55788c48310d81442f3a937de1a42e67cae7482f88ec4f1699b50e37f6f56acb Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
25c622f1c49edea413432091b4100ff6542e76d0145480feba91bc16172e05c3 README.md c5cd6441c38a73f4abfe10e353e84bd93ad54f6fa4b86a1c83a8524ca312fec7 README.md
447fe8a026b819f326d27b941aa35caea2bcfd493c4de46e3fde77f8f13997c8 ROADMAP.md ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
99ac985e49fc0bf7ddfb0b157c241261ecb69f24f92e5dcc99e4f72d58ce6cea deploy/README.md 99ac985e49fc0bf7ddfb0b157c241261ecb69f24f92e5dcc99e4f72d58ce6cea deploy/README.md
@@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
b0eaa2cfe4f6e86582f67891e457591ae650d15145e025d85cab43e37c883ac8 docs/ACCESS.md b0eaa2cfe4f6e86582f67891e457591ae650d15145e025d85cab43e37c883ac8 docs/ACCESS.md
64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md 64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md
adb6062ae518c64ba4eb3a358cb1bb147e41ef46f0e42cdc80e27833eddcbf39 docs/API.md 9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
7e6a7b9421541c94181a712d17f6166a328e7e28c5a1d629e7d335c0ae5aa1de docs/CACHE.md 7e6a7b9421541c94181a712d17f6166a328e7e28c5a1d629e7d335c0ae5aa1de docs/CACHE.md
@@ -40,16 +40,16 @@ d73d0ff31f20f8ca96377bde5cb05753c5c29e417d25b908baac4e51a628ea48 docs/INSTALL.m
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md 37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md 8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md 888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
1e2796dcf723b74035bc8f31c38bc8ed5e00de2150e6e73379d7b52c26142c46 docs/MONITORING.md e4ffd659be1e6c445b582a76643a46408699ef1e929d3a4c6e741d8146ded031 docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
5c0cd602645b500d9c52130b81911b2fc34d464566366fb7e9caa2e56e9f064a docs/README.md 3061263d5ac19ea579dd3f00ca9d9583296a9e761b84180595a33833daf7b9e0 docs/README.md
7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md 7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md 701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md 519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md
f2f8916e5782bc9d4deef3405c36eeddcc1c1dde5198f9b415407c0351bf364e docs/TRANSPORT.md f2f8916e5782bc9d4deef3405c36eeddcc1c1dde5198f9b415407c0351bf364e docs/TRANSPORT.md
f4c23ee07723a78e1e104991c99bac6be61d72b7b912141e69cffffddf7232fe docs/TROUBLESHOOTING.md f4c23ee07723a78e1e104991c99bac6be61d72b7b912141e69cffffddf7232fe docs/TROUBLESHOOTING.md
23765b0d3e32766966e922aa79951b8bc94fc93c0ffa38824e9dfc7e6cd4a1aa docs/VHOSTS.md 23765b0d3e32766966e922aa79951b8bc94fc93c0ffa38824e9dfc7e6cd4a1aa docs/VHOSTS.md
a0e1a93a3bbd0f38b6f06e6f7d7768946c0751146879e79f1115b8542ddcb0bb docs/WEB_SERVER.md f80fe921f0e93f1833ade5740b102c548210519b3b792d386762a57b92ea54cc docs/WEB_SERVER.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json 5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json 2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json
@@ -125,7 +125,7 @@ d1dcb7bdfb7cfdc9c3b88cfb6908201c58532bc888744a13f45853079f214659 src/acme/provi
f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs
e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs
b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.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 0a8a6fb22b90b11ab8ed329779aa3813f6a63a39edac7fe8d821402821073635 src/admin/api_tokens.rs
b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs
51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs 51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs
@@ -138,9 +138,9 @@ ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/docu
ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.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 8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
508ed1890154ed109297b35cae652c24c4c7dc46633dcd9ebf170a5ea2dab53d src/admin/openapi.json 4ef9b39fdde727e44c1ab1b7907cf5dc4d477c26d4d98da54cd482a2dfa6d436 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -150,13 +150,13 @@ d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/oper
4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs 4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs 9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
8c56885198c27cf83c2464b41db32689cf0ba95d180e82c96669a1ae6e18ff8a src/admin/router.rs d3e35d7c5b066990fd6cea07464970e6ddced8989e6c92ca327709b5d1c87017 src/admin/router.rs
0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs 0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs
71fabd06da516df1680cf323336f81c782a87aaf860b86aa2caf437fdeabe0a5 src/admin/status.rs 66111616ccb59a561003c723554c25ba35925afbef7f9b73e5479ca8f0b35b54 src/admin/status.rs
4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs 4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
1a8048ae2f8aa84545d2a42eece2bc7c0bcbbd631641028cc8d770778f537081 src/admin/tests.rs 5e52252a8e886d8e28eed1d456cdfb7387ea6a193007043662889ff366f6d6f5 src/admin/tests.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
@@ -216,7 +216,7 @@ c1b831617bf3cd03dfa416b43791777c8e71f0711c78f9d467fd0f1930ca2a1e src/admin/web/
17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js 17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js 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 d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
@@ -312,7 +312,7 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs 61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs
f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs
f3598f4e71dfa0171a3b3fc74f0229f34a3d2a5a668fc2daa724e557243754ef src/forwarding/tests.rs 01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs
af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs
80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs 80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs
06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs 06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs
@@ -342,25 +342,27 @@ b9bef61d332df0dec47f15d864a7aaea240935c432bf935e40144916e37553bc src/logging/ru
33935951388699d3a78bcd4314fbde8226bc535e9c05932e7edef65e894bd7b9 src/main/shutdown.rs 33935951388699d3a78bcd4314fbde8226bc535e9c05932e7edef65e894bd7b9 src/main/shutdown.rs
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs 1def53b16ae1a498e6d31e4ecd7337af240121abe36ca505fed087bd4e426ab8 src/metrics.rs
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs 6eb36a1a8a2bd04833aba17cca9645a8c55fcf93d512c9ac44c5368b83fe4059 src/metrics/global.rs
2715e2aa3068d9a4dd8f9cbf44e31917c4166111e6fd770219fc2e26cbbec3e0 src/metrics/rate_sampling.rs
78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs 78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs
e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs
c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs
d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs 0393d094c02b5ca76c887b4e4e24ebd7cbc59277eb2eceee65683be34acd9416 src/pages.rs
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs 63a84af77b6c3dd390aa9dfde71c527eec6e1a3c0a38c1f02c08055e525f250c src/prometheus.rs
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs 74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs
fefc761639392f7be68e6ccc161f47a7a7cb608374977f27ad4d5a9f5e46d8b4 src/prometheus/model.rs 175e4b8566eaddddc51370f971bef5548b9f87636c703433c7cd542fbf4bd949 src/prometheus/json.rs
eb92fc3c9606788e58e1f4015be1d3fb2287b20c8dd7007655101106944f54e5 src/prometheus/tests.rs c34c3204bfb07a0de95250dfc1208979a5e701ccf5b4093cfbfd9b996ff3ba15 src/prometheus/model.rs
59fd9e547516999dd54ad07d25066abbd39e58c60bc1c313280b5fb0bdcc982d src/prometheus/tests.rs
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs 028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs
2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs 2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs
66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.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 aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs
676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs 676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs
3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs
@@ -381,7 +383,7 @@ cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/head
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs 55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs 2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs
b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.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 1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs 0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_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 833c9fea744b9649046e1a64d6fc6972fbf6cea640e1943b0db52b7ccfa9faad src/web_server/response/file.rs
ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs
c2e54fd803a10f74490f183e8c030fd0e3e72f5de4e79c55bc393ab57d90ab9a src/web_server/response/file_body/tests.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 305c0109b4f869a99381a142853f9e2205f5c7c4f904b2dfa4643274bfc542af src/web_server/routing.rs
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs 400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
161fb2c88dd1d90f3c6d8f1f1cf9f3a060f8e06846f34d1f4995491eac6fcf49 src/web_server/serve.rs c645740e5175e32a9ee743700e99a2f16b6fd894de5f734b84021b774dd747eb src/web_server/serve.rs
8550770a40db5db72f068399b241b6c29c299acbb02f8ae7ada245722b40dbc6 src/web_server/tests.rs dc3a18c14c3cc28519103e860b0f42a59d163db15df3f68b76778cdfa6786e46 src/web_server/tests.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs 1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs 347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
+1
View File
@@ -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/logs` · `GET /api/live-logs` | Recent and streaming request logs. |
| `GET /api/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. | | `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/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/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. |
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. | | `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. | | `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
+111
View File
@@ -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. 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 ## 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. 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.
+2 -2
View File
@@ -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. - [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. - [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. - [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. - [PHP-FPM](PHP_FPM.md) — PHP service profiles, security and application examples.
- [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL and invalidation. - [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL and invalidation.
- [Response delivery](RESPONSE_DELIVERY.md) — compression, static transformations and response headers. - [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. - [Docker discovery](DOCKER_DISCOVERY.md) — agent installation, networking and generated Vhosts.
- [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools. - [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools.
- [Dashboard](DASHBOARD.md) — metrics panels, presets and configuration history. - [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. - [Logs](LOGS.md) — live logs, stored logs, error logs and searches.
- [Backups and recovery](BACKUP.md) — encrypted backups, restore and Secret Store. - [Backups and recovery](BACKUP.md) — encrypted backups, restore and Secret Store.
- [Migration](MIGRATION.md) — managed upgrades and Nginx Proxy Manager import. - [Migration](MIGRATION.md) — managed upgrades and Nginx Proxy Manager import.
+3 -1
View File
@@ -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). 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. 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.
+2
View File
@@ -20,6 +20,8 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
| "/api/file-logs" | "/api/file-logs"
| "/api/file-logs/query" | "/api/file-logs/query"
| "/api/metrics" | "/api/metrics"
| "/api/metrics/json"
| "/api/metrics/summary"
| "/api/cache/bans" | "/api/cache/bans"
); );
let write = permission == "rw" let write = permission == "rw"
+12 -2
View File
@@ -298,6 +298,12 @@ async fn audit(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
async fn metrics(State(state): State<Arc<App>>) -> Response { async fn metrics(State(state): State<Arc<App>>) -> Response {
crate::prometheus::response(&state) crate::prometheus::response(&state)
} }
async fn metrics_json(State(state): State<Arc<App>>) -> Response {
crate::prometheus::json_response(&state)
}
async fn metrics_summary(State(state): State<Arc<App>>) -> Response {
crate::prometheus::summary_response(&state)
}
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
struct EventsQuery { struct EventsQuery {
@@ -311,7 +317,10 @@ async fn events(
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let interval = query.interval.unwrap_or(3); let interval = query.interval.unwrap_or(3);
if ![1, 2, 3, 5, 10].contains(&interval) { 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) let secret = auth::cookie_secret(&headers)
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?; .ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?;
@@ -349,7 +358,8 @@ async fn events(
_ => return None, _ => return None,
} }
let payload = 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); let event = Event::default().event("snapshot").data(payload);
Some(( Some((
Ok::<Event, Infallible>(event), Ok::<Event, Infallible>(event),
+691
View File
@@ -6801,6 +6801,557 @@
"database_path" "database_path"
], ],
"additionalProperties": false "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": { "/docs/openapi.json": {
"get": { "get": {
"summary": "This protected OpenAPI document", "summary": "This protected OpenAPI document",
@@ -12841,6 +13462,76 @@
} }
} }
} }
},
"/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": [] "tags": []
+12 -2
View File
@@ -200,9 +200,17 @@ pub fn router(state: Arc<App>) -> Router {
"/api/certificates/{certificate_id}/reload", "/api/certificates/{certificate_id}/reload",
post(certificate_reload_one).layer(DefaultBodyLimit::max(4096)), 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/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( .route(
"/api/certificate-api", "/api/certificate-api",
get(certificate_api_status) get(certificate_api_status)
@@ -267,6 +275,8 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/metrics", get(metrics)) .route("/metrics", get(metrics))
.route("/api/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") }) .fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") })
.layer(DefaultBodyLimit::max( .layer(DefaultBodyLimit::max(
crate::config::MAX_CONFIG_BYTES + 1024, crate::config::MAX_CONFIG_BYTES + 1024,
+79
View File
@@ -2250,6 +2250,7 @@ mod tests {
"/api/acme", "/api/acme",
"/api/auth/session", "/api/auth/session",
"/api/metrics", "/api/metrics",
"/api/metrics/json",
"/metrics", "/metrics",
"/docs/openapi.json", "/docs/openapi.json",
"/docs/content.json", "/docs/content.json",
@@ -3664,6 +3665,84 @@ mod tests {
store.close().await; 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] #[test]
fn storage_metadata_is_exposed_only_through_configuration_get_to_api_tokens() { fn storage_metadata_is_exposed_only_through_configuration_get_to_api_tokens() {
assert!(general_api_token_allowed(&Method::GET, "/api/config", "ro")); assert!(general_api_token_allowed(&Method::GET, "/api/config", "ro"));
+1
View File
@@ -1,4 +1,5 @@
//! Aggregate metrics: no attacker-controlled labels or unbounded series. //! Aggregate metrics: no attacker-controlled labels or unbounded series.
include!("metrics/global.rs"); include!("metrics/global.rs");
include!("metrics/rate_sampling.rs");
include!("metrics/vhost.rs"); include!("metrics/vhost.rs");
+1
View File
@@ -2,6 +2,7 @@ use serde::Serialize;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)] #[derive(Default)]
pub struct Metrics { pub struct Metrics {
pub summary_rate_sampler: RateSampler,
pub admin_requests: AtomicU64, pub admin_requests: AtomicU64,
pub admin_response_headers: AtomicU64, pub admin_response_headers: AtomicU64,
pub admin_header_duration_us: AtomicU64, pub admin_header_duration_us: AtomicU64,
+100
View File
@@ -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<f64>,
pub errors_per_second: Option<f64>,
pub bytes_in_per_second: Option<f64>,
pub bytes_out_per_second: Option<f64>,
pub sample_interval_seconds: Option<f64>,
}
#[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<RateState>);
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));
}
}
+27 -9
View File
@@ -8,7 +8,8 @@ use std::collections::BTreeMap;
pub const MAX_HTML_BYTES: usize = 32 * 1024; 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 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 /// Built-in operational gateway pages. They are deliberately not marked as
/// `PageServed`, so an administrator-configured vhost/global error page can /// `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, _ => None,
} }
} }
fn builtin_application_page(status: StatusCode) -> String { pub(crate) fn builtin_application_page(status: StatusCode) -> String {
if status == StatusCode::OK { if status == StatusCode::OK {
return include_str!("admin/web/status_200.html").to_owned(); 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. // as the packaged pages, rather than the former bright bare 200/4xx page.
// Code and canonical reason originate from http::StatusCode, never a request. // Code and canonical reason originate from http::StatusCode, never a request.
let reason = status.canonical_reason().unwrap_or("Response"); let reason = status.canonical_reason().unwrap_or("Response");
format!(r#"<!doctype html> format!(
r#"<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -59,7 +61,10 @@ fn builtin_application_page(status: StatusCode) -> String {
<p>RustProxy generated this response.</p> <p>RustProxy generated this response.</p>
</main> </main>
</body> </body>
</html>"#, code = status.as_u16(), reason = reason) </html>"#,
code = status.as_u16(),
reason = reason
)
} }
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
@@ -184,8 +189,15 @@ impl Page {
.status .status
.and_then(|v| StatusCode::from_u16(v).ok()) .and_then(|v| StatusCode::from_u16(v).ok())
.unwrap_or(default_status); .unwrap_or(default_status);
let bodyless = matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED); let bodyless = matches!(
let body = if bodyless { String::new() } else { self.body(status) }; 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 length = body.len();
let mut response = Response::new(Body::from(body)); let mut response = Response::new(Body::from(body));
*response.status_mut() = status; *response.status_mut() = status;
@@ -259,9 +271,13 @@ impl VhostPages {
validate_errors(&self.errors)?; validate_errors(&self.errors)?;
ensure!( ensure!(
self.intercept_statuses.len() <= ERROR_CODES.len() 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() distinct.len() == self.intercept_statuses.len()
}, },
"intercepted application status codes must be unique supported HTTP error codes" "intercepted application status codes must be unique supported HTTP error codes"
@@ -312,7 +328,9 @@ pub fn intercept_application_error(
status: None, status: None,
html: String::new(), html: String::new(),
}; };
let selected = vhost_pages.errors.get(&code) let selected = vhost_pages
.errors
.get(&code)
.or_else(|| global_pages.errors.get(&code)) .or_else(|| global_pages.errors.get(&code))
.unwrap_or(&default_page); .unwrap_or(&default_page);
let mut body_only = selected.clone(); let mut body_only = selected.clone();
+2
View File
@@ -6,6 +6,8 @@ include!("prometheus/traffic.rs");
include!("prometheus/web_php.rs"); include!("prometheus/web_php.rs");
include!("prometheus/vhosts_backends.rs"); include!("prometheus/vhosts_backends.rs");
include!("prometheus/docker_misc.rs"); include!("prometheus/docker_misc.rs");
mod json;
pub use json::{response as json_response, summary_response};
#[cfg(test)] #[cfg(test)]
include!("prometheus/tests.rs"); include!("prometheus/tests.rs");
+758
View File
@@ -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<String, String>,
samples: BTreeMap<String, Vec<Sample>>,
}
#[derive(Serialize)]
struct Sample {
labels: BTreeMap<String, String>,
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::<u64>() {
return json!(value);
}
if let Ok(value) = raw.parse::<i64>() {
return json!(value);
}
raw.parse::<f64>()
.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<BTreeMap<String, String>> {
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<f64> {
(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::<Vec<_>>();
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::<Vec<_>>()
})
.unwrap_or_default();
json!({"vhost": pool.id, "ready": pool.ready(), "members": members})
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
let statuses = data
.statuses
.iter()
.filter(|x| super::safe_traffic_state(&x.value, &["1xx", "2xx", "3xx", "4xx", "5xx"]))
.collect::<Vec<_>>();
let cache_results = data
.cache
.iter()
.filter(|x| super::safe_traffic_state(&x.value, &["HIT", "MISS", "BYPASS", "-"]))
.collect::<Vec<_>>();
let sources = data
.sources
.iter()
.filter(|x| {
super::safe_traffic_state(
&x.value,
&[
"reverse_proxy",
"web_server",
"local_response",
"unresolved",
],
)
})
.collect::<Vec<_>>();
let agents = &config.docker_agents.agents;
let docker_vhosts = agents
.iter()
.map(|agent| agent.assignments.len())
.sum::<usize>();
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::<usize>(),
"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);
}
}
+12 -1
View File
@@ -140,10 +140,15 @@ pub fn router(app: Arc<App>, token: Option<Zeroizing<String>>) -> Router {
async fn scrape(State(exporter): State<Arc<Exporter>>, request: Request) -> Response { async fn scrape(State(exporter): State<Arc<Exporter>>, request: Request) -> Response {
let config = exporter.app.config.load_full(); let config = exporter.app.config.load_full();
let settings = &config.envelope.config.prometheus; 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. // Revocation is immediate even when changing bind/credentials needs restart.
if !settings.enabled if !settings.enabled
|| settings.restart_differs(&exporter.app.active_prometheus) || 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() || request.uri().query().is_some()
{ {
return StatusCode::NOT_FOUND.into_response(); return StatusCode::NOT_FOUND.into_response();
@@ -201,7 +206,13 @@ async fn scrape(State(exporter): State<Arc<Exporter>>, request: Request) -> Resp
let Ok(_permit) = exporter.slots.try_acquire() else { let Ok(_permit) = exporter.slots.try_acquire() else {
return StatusCode::TOO_MANY_REQUESTS.into_response(); return StatusCode::TOO_MANY_REQUESTS.into_response();
}; };
if wants_json {
json_response(&exporter.app)
} else if wants_summary {
summary_response(&exporter.app)
} else {
response(&exporter.app) response(&exporter.app)
}
} }
pub fn response(app: &App) -> Response { pub fn response(app: &App) -> Response {
( (
+164
View File
@@ -56,4 +56,168 @@ mod tests {
assert_eq!(text.matches("# TYPE x").count(), 1); assert_eq!(text.matches("# TYPE x").count(), 1);
assert!(text.ends_with('\n')); 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;
}
} }
+31 -30
View File
@@ -21,7 +21,8 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Response { 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() { if status.is_server_error() {
tracing::warn!( tracing::warn!(
status = status.as_u16(), status = status.as_u16(),
@@ -29,46 +30,46 @@ pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Respo
"Web Server error" "Web Server error"
); );
} }
let message = format!( // The public serve() facade strips bodies from HEAD responses while
"{} {}\n", // retaining the same Content-Length as the corresponding GET.
status.as_u16(), builtin_error_response(status, &Method::GET)
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
} }
pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Response { pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Response {
let html = crate::pages::builtin_gateway_error(status) // Reuse the bundled pages wherever one exists. For statuses such as 405,
.unwrap_or("<!doctype html><html><body><h1>Error</h1></body></html>"); // use the shared styled application fallback instead of a plain-text or
let mut response = Response::new(if *method == Method::HEAD { // 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() Body::empty()
} else { } else {
Body::from(html) 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.status_mut() = status;
response.headers_mut().insert( response.headers_mut().insert(
CONTENT_TYPE, CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"), HeaderValue::from_static("text/html; charset=utf-8"),
); );
if let Ok(length) = HeaderValue::from_str(&html.len().to_string()) { if let Ok(value) = HeaderValue::from_str(&length.to_string()) {
response.headers_mut().insert(CONTENT_LENGTH, length); response.headers_mut().insert(CONTENT_LENGTH, value);
} }
response response
.headers_mut() .headers_mut()
+99 -26
View File
@@ -119,10 +119,13 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
.await; .await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND); assert_eq!(missing.status(), StatusCode::NOT_FOUND);
assert_eq!(missing.headers()[CACHE_CONTROL], "no-store"); 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!( assert_eq!(
missing.into_body().collect().await.unwrap().to_bytes(), missing.into_body().collect().await.unwrap().to_bytes().as_ref(),
"404 Not Found\n" crate::pages::builtin_gateway_error(StatusCode::NOT_FOUND)
.unwrap()
.as_bytes()
); );
let missing_head = serve_request( let missing_head = serve_request(
@@ -132,7 +135,14 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
) )
.await; .await;
assert_eq!(missing_head.status(), StatusCode::NOT_FOUND); 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 assert!(missing_head
.into_body() .into_body()
.collect() .collect()
@@ -171,42 +181,105 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
.await; .await;
assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(method.headers()[ALLOW], "GET, HEAD"); assert_eq!(method.headers()[ALLOW], "GET, HEAD");
assert_eq!( assert_eq!(method.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
method.into_body().collect().await.unwrap().to_bytes(), assert_eq!(method.headers()[CACHE_CONTROL], "no-store");
"405 Method Not Allowed\n" 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("<h1>405 - Method Not Allowed</h1>"));
assert!(method_html.contains("background: #131615"));
} }
#[tokio::test] #[tokio::test]
async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses() { async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses() {
use std::io::ErrorKind; use std::io::ErrorKind;
for (kind, expected_status, expected_text) in [ for (kind, expected_status) in [
( (ErrorKind::NotFound, StatusCode::NOT_FOUND),
ErrorKind::NotFound, (ErrorKind::PermissionDenied, StatusCode::FORBIDDEN),
StatusCode::NOT_FOUND, (ErrorKind::Other, StatusCode::INTERNAL_SERVER_ERROR),
"404 Not Found\n",
),
(
ErrorKind::PermissionDenied,
StatusCode::FORBIDDEN,
"403 Forbidden\n",
),
(
ErrorKind::Other,
StatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error\n",
),
] { ] {
let error = std::io::Error::from(kind); let error = std::io::Error::from(kind);
let response = super::response::io_response(&error); let response = super::response::io_response(&error);
assert_eq!(response.status(), expected_status); assert_eq!(response.status(), expected_status);
assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); 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(); let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), expected_text.as_bytes()); assert_eq!(
assert!(!body.starts_with(b"RustProxy:")); 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::<crate::pages::PageServed>().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("<h1>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] #[tokio::test]
async fn last_modified_condition_returns_304() { async fn last_modified_condition_returns_304() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();