diff --git a/AGENTS.md b/AGENTS.md index a2fb7ce..c882af4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # RustProxy source and documentation maintenance -Current source version: **0.34.12**. The Cargo package and binary are named `rust-proxy`. +Current source version: **0.34.13**. The Cargo package and binary are named `rust-proxy`. Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index f102962..5f74816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,24 @@ # Changelog -Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.12. +Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.13. + +## 0.34.13 — Global security, section-based configuration API and panel UX (source candidate) + +- **GeoIP:** on-demand data refresh moved from the isolated `POST /api/geoip/update` to `POST /api/config/geoip/update`, colocated with the revisioned GeoIP settings. This action does not change the config revision; the UI and RO/RW token allowlist use the new URL. Section paths now consistently use kebab-case, including `not-found-hosts` (the JSON property remains `not_found_hosts`). Old action/section paths are removed. +- **API:** all 23 supported configuration sections now have `GET /api/config/{section}` returning `{revision, section, value}` and `PUT /api/config/{section}` accepting `{expected_revision, value}`. PUT still returns the updated full `{revision, config}` envelope so clients can refresh all sections. Both methods use the same section registry. Unknown/removed sections are 404; stale writes return 409. Writes remain full section replacements, validated and committed atomically; there are no partial PATCH semantics. +- **Global security:** the canonical User-Agent blocklist is now `config.global_security.user_agent_blocking`. `PUT /api/config/global-security` accepts one `GlobalSecurity` object, including an explicit `user_agent_blocking`, and no longer requires a special two-object wrapper. The old independent `/api/config/user-agent-blocking` section is removed. Per-vhost User-Agent policies are unaffected. +- **Upgrade safety:** legacy top-level `config.user_agent_blocking` is accepted on full-document input, compiled into the nested policy and omitted from normalized saved configuration. A payload containing contradictory legacy and nested policies is rejected rather than silently enabling/disabling a rule. Raw unnormalized backup payloads retain the old value until migrated. +- **Grouped sections:** `logging` is now strictly `{logging, error_log}`; `response` is `{server_header, diagnostic_headers, compression}`. The old bare logging-only PUT shape is rejected to prevent partial group replacement. All other section PUT payloads retain their previous single-field value formats. RO API tokens may read individual sections; RW tokens may write them subject to existing restrictions. +- **Settings → Rules & headers → Global block / allow:** replaces the separate global User-Agent blocklist editor with a unified global country block/allow policy, the existing User-Agent patterns, IP/CIDR and allow-only dynamic DNS ACL, HTTP method allow/block rules, request Content-Type denylist, and an optional client-IP request-rate limit shared across public HTTP vhosts. Each control is disabled by default. Existing User-Agent settings are retained, not reset. Global and per-vhost restrictions are cumulative, and access checks run before upstream requests, redirects and cache invalidation; TCP/UDP Streams and the private admin listener are excluded. +- **Global security headers:** separate subtab for HSTS, nosniff, X-Frame-Options, Referrer-Policy, CSP and Permissions-Policy. Global values form a response baseline; explicit per-vhost header values override matching settings. HSTS is sent only over HTTPS. Test restrictive policies before production rollout. +- **Config/API:** additive `global_security` configuration object, backwards-compatible defaults, atomic `PUT /api/config/global-security` payload containing the complete nested `global_security` object (including `user_agent_blocking`). Country detection uses trusted client IP/GeoIP; global DNS ACL rules use the existing safe background resolver. English and Polish panel strings, OpenAPI and guides updated. +- **Panel layout:** separated static cookie-cache advice from response-header toggles, added space between transform numeric controls and minification checkboxes, and moved certificate access-permission guidance next to the generated command preview. The **Copy commands** button remains hidden until commands are generated. ## 0.34.12 — cache diagnostics and optional chart axes (source candidate) - Origin-provided `Cache-Status`, `X-Cache`, `X-Cache-Hits`, `X-Cache-Reason`, `X-Cache-Resident-Age` and `X-Cache-Status` are retained on forwarded and cached responses; the HOT/COLD cache and conditional `304` refresh no longer erase these values from stored metadata. Cache-disabled and HIT-only display modes leave origin headers untouched. - `Cache-Status` follows RFC 9211's ordered member model: preserve upstream members and append the RustProxy member if enabled. RustProxy reports `hit; ttl=...` for a local cache hit, `fwd=miss`/`fwd=bypass` for forwarded requests, and `fwd=stale; fwd-status=304` when revalidation used a `304`. The existing fixed reason goes into `detail="..."`; transformation metadata applies to RustProxy's member only. No non-standard `backend=` parameter is generated. -- Existing `X-Cache*` RustProxy diagnostics remain available for compatibility. To avoid deleting an upstream value on a naming conflict, displaced origin values are exposed separately as `X-Upstream-Cache*`, one field value for each original value. These aliases are non-standard; clients should prefer `Cache-Status` when available. +- Existing `X-Cache*` RustProxy diagnostics remain available so clients can refresh all sections. To avoid deleting an upstream value on a naming conflict, displaced origin values are exposed separately as `X-Upstream-Cache*`, one field value for each original value. These aliases are non-standard; clients should prefer `Cache-Status` when available. - Added regression coverage for origin/proxy naming collisions, disable/HIT-only modes, HOT/COLD storage, RFC 9211 member order and metadata generated after revalidation. - **Appearance → Charts:** optional account-scoped **Show X/Y scales** switch, off by default. On sampled line plots, HTML labels show the exact Y range used by the existing SVG renderer (including fixed percentage and adaptive Vhost/Docker ranges) and the X sample indices. Charts with separately normalized HTTP/WebSocket protocol lines identify each series' own Y maximum instead of inventing a common scale. Missing/compacted samples are not mislabeled as minutes; any clipped Vhost peak remains available in the existing legend. Disable the switch to restore original geometry. Donuts, gauges and categorical bars are unchanged. - The new preference is included in API/Reader docs and both interface languages. Existing accounts retain their chart style and the new switch stays off until explicitly enabled. diff --git a/Cargo.lock b/Cargo.lock index 321a9a8..bca4c19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1925,7 +1925,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.34.12" +version = "0.34.13" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index ebd8289..99b7afc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.34.12" +version = "0.34.13" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index ac0ccbc..72692ce 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustProxy 0.34.12 +# RustProxy 0.34.13 RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language. @@ -18,6 +18,11 @@ Features and default settings vary by component; configure only the capabilities ## 0.34.12 — upstream cache diagnostics and RFC 9211 +- **Web Admin** cleanups and +- **API** refactor + +## 0.34.12 — upstream cache diagnostics and RFC 9211 + - **Cache and upstream headers:** retain origin-supplied `Cache-Status` and `X-Cache*` diagnostics on BYPASS, MISS and HOT/COLD HIT; no longer silently strip them when cache diagnostics are disabled or limited to HIT. Origin `Cache-Status` members remain in order; an enabled RustProxy `Cache-Status` member is appended with its own `hit`, `ttl`, `fwd` and `detail` parameters rather than overwriting earlier members. On collisions with the non-standard legacy `X-Cache*` headers, RustProxy keeps its existing names and preserves upstream values as `X-Upstream-Cache*`. These options affect only RustProxy's own diagnostic output. See [Cache](docs/CACHE.md) for examples and RFC 9211 semantics. - **Appearance:** **Settings → Appearance → Charts → Show X/Y scales** adds optional, off-by-default numeric chart scales to live SVG plots. The existing lines, colors and dynamic Y limits (including adaptive Vhost/Docker rate scales) are unchanged; Y uses the exact plotted range and X shows sample numbers, not inferred minutes. The setting is account-scoped and reversible. See [Dashboard](docs/DASHBOARD.md). diff --git a/ROADMAP.md b/ROADMAP.md index 12313e6..e69de29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +0,0 @@ -# RustProxy roadmap - -This file records **work still requiring acceptance or planned future consideration**, rather than repeating completed release history. Implemented functionality is described in the [documentation](docs/README.md), and operator-visible changes by release are in the [changelog](CHANGELOG.md). - -## Chart axes acceptance follow-up - -The opt-in X/Y sample-scale display is implemented in **0.34.12**, with current SVG ranges preserved and no elapsed-time inference. Before production acceptance, review live screenshots at small screen widths, all themes, dense/custom card layouts, charts with empty data and compacted rate histories; check the toggle with per-account stored preferences and the full Rust service. The original axis-free appearance remains the default. - diff --git a/SHA256SUMS b/SHA256SUMS index 5e2eb5c..9d4114e 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,13 +1,13 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -26115eda9fc03552f26f900865df1126ae252ddff39fe65a81e68650edc1372b AGENTS.md -9571c19750663ce2f764d15cb8c752fb092f009058264ef134776bf105173556 CHANGELOG.md -2b52395199055d2dbc09ecff16bf0ad238cbc15ac41d5022a6f94e65883a2e53 Cargo.lock -bdea624ad5b7e6f7cb749c0398af6846615bf641d59348bb092406383ca2a94c Cargo.toml +754db9d80640bdb05116d14cf91e1b69bfbbcfee2c522659ace4a4c7d48bb10d AGENTS.md +191e5abff142bf0353d4e5c29994d4516799f550ec25dc2173be3003a0028a58 CHANGELOG.md +b548ffccc83f59c886fd111bbfb63b40b34c35889f752e44e586c8e22131cfb8 Cargo.lock +bb70eb65066a0aa23859a2ae006ba1bb75ff86b87afd4fa6160eae78a8d42af9 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -1151dada0b9f3e85a2437f3eb7b7f3a2a40b56a14daca525fb593cf0be8a344f README.md -55d9e5a155bd2e164e81c949620518e2b86d5792165a3b571b964d2386c6cb8c ROADMAP.md +a698cd5979a56ea5f8ab8aa4ca8ccf5f6d72515f689fc833b73e16f37ca64a6c README.md +9edb7d4a4da46f0f836e504002f3676b88639208d28c1588e8637b085594c154 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs 2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md @@ -27,13 +27,13 @@ d660f46f7ef8c539dd3f5486a31d5a1254b9737be8a278c7f9352c254558f8ac docker-agent/m e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go -5664af2afde7f6c88d44e02aa8a9cd719a6019db15b570510185e4114c83799e docs/ACCESS.md -dd186c91244be2d1dde47626d5022c2b24ce7a6834e0f08b1b401cec71017b4f docs/ACME_PROVIDERS.md -989293ade34917b0ab3718da3b6c84e897b45cd6fe22345757f151e1cbd510fd docs/API.md +aa1be1593874450822055e72be2228c914e0b146c04ab8247d7a957afee9da22 docs/ACCESS.md +147755fb33e9e91098a4d2dfbe6df8b0d92d602e002d568e64a0f815641be2c2 docs/ACME_PROVIDERS.md +ba40dd0ec51e0992c0fecd12934b9875e5e7252eced8db533cb79ec01ac8dcc7 docs/API.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md -d71c36578d944f3fc64dc317937a8d0ca2360a6a1a15de18cb63223c8d6d0de7 docs/CACHE.md -f24767c2f9d4db4db5005b700d03323dd20f0a1d180ae939be4fe54a126aaf32 docs/CONFIGURATION.md +3219aae15fd2e10157343ecdad8b175bd9735f39bccb31a5cb811553c13084dd docs/CACHE.md +b29bceb308327db9a45faba3f144c2fd7e057c833d5cafdcc216cf82aedd7854 docs/CONFIGURATION.md 80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md e350c5081085b79e579d1500fa07feee94a709adaf6dfde7b3d9af124a78d184 docs/DASHBOARD.md @@ -44,9 +44,9 @@ c891ee5272e2e47e1a8f97133a413e2330844103d543fb5cbcc4d0724d59a1a5 docs/INSTALL.m 888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md 27ba3ed337963d9cd05ba382666eed9c414f58c52e5897b4c1c45603f412b456 docs/MONITORING.md d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md -81b14192310c59ae020dada4910b1984303ce2cbc8b4dfde933abcde7bf5dd51 docs/README.md +42603310536acefd915f3a629c653664056fa4282801ce0ebae19025b2359649 docs/README.md 490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md -e2b36152c13d65621a367c69dd400118dae1e4426a4d2b2139d2f63fa30442d6 docs/SECURITY.md +a7c5b01ad1e6aeb27bccf1085504854b70ed4bf020b481915aafcc4a8aeb1f54 docs/SECURITY.md bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md 6508819f181e2a935ffa117451915aa5568a31e2890c57fa10e04543db82f3e7 docs/TLS_POST_QUANTUM.md 43a2c6230f95f81bf4efd1575a4f7cdc8b02244df38e19b1ec9c3cd736d3afe6 docs/TRANSPORT.md @@ -77,8 +77,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md -4de6c67c2ba4f7b6806d5747ec6402b058eb1f2050eeb031dcb61abb21e8288b langs/en_US.json -43dfc96953ef84a0d9fb04df728e3dea6ff70e6f8c8e7b4f0bf6de61f6fbc4a7 langs/pl_pl.json +0aa1109be04a8a3e32a3aebed25ae25054e4a44574d92556eccff1424e31c148 langs/en_US.json +1fcc95afa3f7374bb6a9105004877ee207ee6fec1b616fc72ee8f832054756bd langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -115,11 +115,12 @@ f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/prefli 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py 174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh +7302f3446e2306a8618a1c026178e4c34f6215066ef7f7718bb29a42116bb354 scripts/test_admin_config_browser.py 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py 5130529d8f55ba9d89c29235a7eac133a80c9e9b2591267b1d082207568abbeb src/acl.rs -adab6c8cd7c3f826e300effbd7d10c1bf12ee49253b3e5ad445e4ac04900f5dc src/acl/dynamic/mod.rs +68650bf15c8d1147bcfd49b81fb313171d417100758c1b86a6260c4eeb3f765c src/acl/dynamic/mod.rs 1eee2ea2c8dd6068dd0bbb058a06d26436f14767d8efbc2010bbdb4e3e523672 src/acl/dynamic/tests.rs 75120cd3115aea6fc40665086617eab9816f08f556cef8db370806195c7641bf src/acl/tests.rs 6189029ba493982acc38c9dc1f47fa1f0c933dec5ecfb95b60f8be83d33b5aa3 src/acme.rs @@ -132,12 +133,13 @@ c51fd51177a476be6462ca980e9bc13f0d0b41538f520118b80a685130f0dbf0 src/acme/runti 2f02b08d88ad3c1fc9827e5bd3ef463effba633834d9c05e9c0d6d2a0e8869c5 src/acme/runtime_versions.rs cfc13a293c126bb511745859669719a14c72dd7ff6be197bc9e0d0e560deebbd src/acme/tests.rs b28a08ea996befe10949676f40fab5f1036a274c7d0ab1175cf5c43c59849dc3 src/admin/access.rs -bcf131abdae425a85b5a54ade51346f623de1e10fa08e18f7c10f1fd2dd0aec1 src/admin/api_token_access.rs +69e26e4f06f6084a61b0c40ee2e8d3f027913eaa87d877477f0b9b1fc02589b4 src/admin/api_token_access.rs 5f5b150bb8f51f57462e519650093354ae7b40fcb80c4380fd3391a33f4b30f2 src/admin/api_tokens.rs 34e851f725e8605e3c5d9946e56ac035996f3bd9d608169cba3408060d229c9b src/admin/certificate_api.rs 4a8b6406a44508b8ba102fef0d8309e871dbe0cbdbee1e6c8721baaca0c1aee1 src/admin/certificate_inspection_tests.rs fe1934fe1ed611277a55485f187a4f62e1fbc3cd196676e64eb1deee4a4c0323 src/admin/certificates.rs 521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs +39963903d93ea5b16be834741c7095f7e9bd9fd8fb7dc6ae645d673d0e7f010f src/admin/config_sections.rs 159c3970d25fa29acf2483fc9422b003e5d63884713f62baa8375e4a4020730e src/admin/dashboard_presets.rs aae1ca0e70d987830bd63948d431f54302083148177c1b551565be12df7fe771 src/admin/documentation.rs da28dd57087df138913b784b71dca11d36c68100f2f100f452fbf4e4d7982433 src/admin/documentation/documents.rs @@ -146,10 +148,10 @@ da28dd57087df138913b784b71dca11d36c68100f2f100f452fbf4e4d7982433 src/admin/docu ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file_logs_io.rs 282352eda7957477e7da44f16b90984b26a4a67a8755833bc0a6b34eaf93e3af src/admin/file_logs_query.rs -f66ff718f82a326f80808b0a5088bd05a48625be802e5ba305fff3c344b59948 src/admin/logging_api.rs -1ba0520c6348246cbb35b60e596e99dc600137c0696de86508cf06587bd86563 src/admin/mod.rs -5d00cd59bb9ce85fe0d2762757645592eec5a1bef803e0692edda687dd7966a8 src/admin/openapi.json -7cfd6593ac9bcea3d1b9e61bbcb7ed98d65ed3190f54f94c1e104541f4fbb4f5 src/admin/operations.rs +444d7715406ffd2305a72decf7fdf633aa4c2752cd881c8fa3b95ed971d79820 src/admin/logging_api.rs +243f4a9c3fa99781e19c3952354d2632c0ce65e1dafac729567cbe3c177f94cb src/admin/mod.rs +8b5eaeef6ae3f216afae39abed1d47e58ac41bacd1d3e54ab65e0e02f6cda093 src/admin/openapi.json +1f4ec45d146e00ef664fae37517f8fcb5455d47daec496c02b2fc15ddec49131 src/admin/operations.rs 257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/preferences.rs @@ -158,13 +160,13 @@ b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/pref 62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs -6d104cd21ff86c55e80b9a5039be6b61f0d82c7ba9e35748515d73e9249441b3 src/admin/router.rs +6d7f3f67b9a0723ba443b09ab3668b5c84bd825613b1200a91b181f9c116977c src/admin/router.rs e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/session.rs 09cc50a86960897371730cc1abed83f4f640c86b680ec7ff6d6b84c7e96a3c0b src/admin/status.rs 2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs 8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -6548710af7070f2e73e09c2cce1054d85edbccd4e3d042e5cc3951303ce31d9f src/admin/tests.rs +0d304b616bd03fbd63bcbd371fae1034ef4e1e9cf28da7320f53d8fd307fc0a8 src/admin/tests.rs c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js @@ -172,7 +174,7 @@ c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/ aed3aea4eef0011c6b51d39736782e7d1ec52662e422f40e0c7b9ddfc19d0378 src/admin/web/app/certificate-inspection.js 1a4d40a86067493e75b47f2d735d50fa4d161344ce145d4e502b0225144ed45a src/admin/web/app/chart-scales.js d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js -fece68287c2c58b813b9313fdad1ef6617c55230b5c5322d8ba7b9fda32455b2 src/admin/web/app/core.js +f0bc2a8b7c8faf60938c71f49764ddd4376685e19454c176a61307f885c5369d src/admin/web/app/core.js 622d34e1616fee9a480d208192fc5a84d62d84ef4233d6a7d95e2bfb8cef415b src/admin/web/app/dashboard-inline-layout.js 851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js @@ -180,23 +182,23 @@ a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/ 5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js db9012546cae8c58fc2bcbee71c9a95956a13a6db9e6d126c6a3770d45a4e4d2 src/admin/web/app/overview-wide-metrics.js -717820edf68b51af47b94ce18367635da99b36ce7331f44a0ec9f06e6f59ca4b src/admin/web/app/path-permissions.js +bfd9fafb3112197e8f875dbf87a9a66c81aef0fe83687601a84a66f5e13f69bc src/admin/web/app/path-permissions.js 46c7cd36397375eb7e8c230c8aa97b1dbea0a63800beb977efe1ec67a14b237e src/admin/web/app/preferences.js 8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js 43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js 7888eaea26c5cecc097ad276b73ae6c63def1db896053def6852ddf9d6739906 src/admin/web/app/runtime.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js -fa00bce2651308a4e0b1303cc0146be17ed4794b67b2f0cebfaf861558d8c78c src/admin/web/app/settings.js +c7a18238ac426de4d1cf84a395ee4dc66410d60a57a3822695dc10adc668d956 src/admin/web/app/settings.js b7ead49224a4ec67181d3f24f9d9634bd6d76756f826a4d5d993300637d1d8f3 src/admin/web/app/stream-dashboard.js 7de070cc8cdde45d7e0932259299e341e25b32c9ffbd87e4fcce934d0f090de4 src/admin/web/app/web-overview.js 596205feca534302699120b84848cb0f3e9ef2ad7ba24eac18d5751532a40194 src/admin/web/cache-panel.js e9788a9d1e0bce8775f612a5c842afea8a0dcf01301acdef4b69ff6a52d2ef45 src/admin/web/certificate-api.js 9d000e98497b3387641500d5273d23eda45d532c714f22e187572928da152a97 src/admin/web/css/00-tokens.css -b86b530c86ff7996f41644dcff7c26145f1fa8bf0d9476dc34cdb73a67188a9a src/admin/web/css/10-base.css +c6be9f713a97cad7f6d71408944f99f547201a707c277ebc88212ea3823f7065 src/admin/web/css/10-base.css 4aae1657e8f5faa755cab0399769e6179364148f80243e742fc7a5e6e237b3e6 src/admin/web/css/20-shell.css 707d08b7b8505c7346d5a98f3a8058f10577ddac7b9e6a5a9894285bde9e300a src/admin/web/css/30-overview.css ce5ffb911d7e4cd2317ca047534921132b6f93efd70a26673f0c428e55320a4d src/admin/web/css/40-editors.css -5af1a00c7d348979fa62d64e14d4e9a4c65c097e5ac96f956c3d87cb1ad71e17 src/admin/web/css/50-settings.css +6537a3f97d046b8325c744a281a74f8c4005a8bbcd97284ecf7eb5b6973ae1df src/admin/web/css/50-settings.css d019d9741951c9d652fab9e520693e0dd3f00b395e32169e5c7ca3ccaf04132c src/admin/web/css/60-monitoring.css 6afa10207938787013f5a4731910630b19bec4a22682a07c3e67707d17cfd97e src/admin/web/css/70-responsive.css f2a2b0476cc55d8dadb4fd52c2c589d64785ad400ccc6cac5a2d10cfca729928 src/admin/web/css/README.md @@ -223,8 +225,8 @@ ff72fc4c6a11907a16234c4b3c7d7f7bcf82eb72868dc3fbf654b04aff806fc8 src/admin/web/ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js -b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -38e651a95a1c347f6086fff6553d2e27ac4f1d096561cbef716fef6108c881f3 src/admin/web/index.html +3f0525ca1d4e088ccd4988b8a117b26a8cf8c12213b24c37081d41b68d2b0ff2 src/admin/web/host404.js +1ddd36f0a89a807b8859d6d8363f787d4b318b9421fb5b4fd95b9de8d65c2a45 src/admin/web/index.html 7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html @@ -234,7 +236,7 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/ a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/partials/overview-metrics.html 3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js -3ee4508cdda0b0b0e0f880ecac4b085368098b46240d24f2e6efed21c96bd5e9 src/admin/web/settings.js +2e891f34cad2a2950a47e529e15ed9dd41beedec71280b8035b19cfb91a5d317 src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 1ea78a6e5b5d813febf2359ecec7fde42dc1278531053e91428f4d72b8a1d62b src/admin/web/streams.js d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js @@ -312,13 +314,13 @@ b7c063ed4c8ca96714e1dc5d279f38fc521da2132faad7bd4f40311352a71e7c src/cache/v022 2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs 9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs -f9a400d66f37056a0a2d8c77fb44c2b3db9fceba3511e4a5b1e4b98890d27b13 src/config/compile.rs +f4df024acdb24be450822e58108af23db3dc82bf1f77531cd16fe340fc01062c src/config/compile.rs fbea01fea1213d98aea812c1b78d5db289e09200b98ae514d742235cd3fa70e1 src/config/internal_routing.rs -0d9f62fe4600b230e200f41652effc22ac7c1b649fbe63505ed6ea34c644feea src/config/model.rs +2669ee64cf00ebe5eeafe37dbbdd963c859f3ced9fea97a00c3f1ba2a42a6e57 src/config/model.rs 75839458ddbc7480ec4492a95eeb67b617ba7d62f7b67e7a767a3453b74dbcbc src/config/route_policy_tests.rs 40e9483d7b2450f55d29b46c408727f4e331b85ecda44fbd7b96bd9a09f02655 src/config/routing.rs dd9a6ad8f58e0cdd1ae74f3f972fee0ac14383cd8b8f518403b9a36c169f35bd src/config/snapshot.rs -e943525706c51846adbac23d4a4f4b1a22bd20103b3004b7de1cd1e5e270ca50 src/config/tests.rs +255b35ca29d25b960d339d2f8088441cea587c556da25de6a159d93f944a9eea src/config/tests.rs f35de0ee1516ece6bbfd2b2a7db7d1e0f5048b0ec47cf2a3765dffb799abfd93 src/config/transport_tests.rs eb04a8922da26e45c9b6005c0cdf1b1ddf2da3c3b680650b22a1d1f16d17a4b2 src/db.rs c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tokens.rs @@ -385,7 +387,7 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus f1b0254f53b1a4336bd8170ad441ba072b49a114e497b985cb20699b4e375044 src/prometheus/web_php.rs 569eda61453affd053fbe24fa59652289cb12d22b77e64caa38325bbea48f7df src/proxy/body/mod.rs 57a71c9c6e4e0deba5d05963b9870af8f4c4d8dbd321ed4e52cee88746c997f0 src/proxy/body/tests.rs -31d70f8be178f9213159bb030af448f37cf32ddb40e3142068a3b335df8749f7 src/proxy/cache_helpers.rs +bbcb1791a0b1dc48ec6f8e37622f1028109e9b614e57466d75c79717ede3cf91 src/proxy/cache_helpers.rs 7e3837d2f6ba7d07ca2cbabda57b315f5a7f4d75a13e5eddf6861a150d0d2ed6 src/proxy/cache_http_invalidation.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/cache_http_invalidation_tests.rs a03b1735daa8fa8e40cf5bd9c6e200d211baf19c30f62522d4237ed56642bce7 src/proxy/cache_plan/mod.rs @@ -400,7 +402,7 @@ a6580dc359d1f377a48ccc1a902056dc5c2ef86c89180b9b0995068d9817ae8a src/proxy/forw 744daef016d4a6c821ab12eeedaa2f29e2de72e2ac9dae16481d4f43476cb713 src/proxy/forward/success_prepare.rs e9ac9be64f6f83c4ca2cd5d299649f24dd16197712083c6dc10e55c662c65b80 src/proxy/forward/upstream.rs 690ff6a6860a7ca8ecd502657a68e5012b7f176036d6d7033dabc514735dc2f5 src/proxy/forward/web_source.rs -3340583d395fcc859cf3421f4e33cf8347915ee4c468f7d0e4abebce8156fdca src/proxy/forward_entry.rs +a5e2825d3ba1cbdc9d4afbaa133964ba0585fd776dcd8aa98c9817cc027a6b97 src/proxy/forward_entry.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs 684ea1e2a7591a17e3c633576fc66e2a30d6636738fbce61dab5af44f2c51a67 src/proxy/headers/authority_tests.rs 66efc52d64d34667ba7419f8a2da89d2efb1af1f426684f4fff8caccfdf19996 src/proxy/headers/mod.rs @@ -491,7 +493,7 @@ fe9d4deb34cea2408ac5a1030315989f29a6a7d078a46ac1b59d12aa07f67878 src/upstream/c eb513923f8951c8bf30bad3cbac77d34f5ebbc7af3ba69a08a25bae834f8a016 src/upstream/client_tests.rs 8c88d928b823e4b6ba6d852f74664c770d8b3fce32bc76c583dc1f672fe33f14 src/upstream/protocol_tests.rs ae880b40197454ea3fbf4d2113984dc372098b2e54b97e87c83cc319de253377 src/upstream/tests.rs -e68599c376d190ae7bdeb154cb3e5a7151feabeb0d688360327dff06574d985d src/user_agent/mod.rs +5c42f1befec267293b6f03eada118a11c90adfb4b32e0134d7f0033c7453c7cc src/user_agent/mod.rs 6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs d61c427c1e9cf457f1232b822d73e846e9f329cfbe61215e7ca061d53762155d src/vhost.rs 54b0c1d2f04e5c654123c3422645eb5a0a9609141cc4a1c6addca2d028392772 src/vhost/error_fallback.rs @@ -525,6 +527,7 @@ f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json +0930ab8fae4eaaf76185bb1c6c9d4c2ee705a9e09a3ae74d81674ca4631a0169 tests/test_config_api_contract.py eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md 7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py diff --git a/docs/ACCESS.md b/docs/ACCESS.md index 6662089..334f634 100644 --- a/docs/ACCESS.md +++ b/docs/ACCESS.md @@ -2,6 +2,12 @@ Access control can be configured globally, on a Vhost and on selected Custom locations. Policies are **cumulative**: a more specific allow rule does not cancel a denial at a broader level. Changes should be tested with both an allowed and a denied client. +## Global block / allow policy (0.34.13) + +**Settings → Rules & headers → Global block / allow** applies to public HTTP traffic across all vhosts, redirects and not-found hosts before upstream access. In addition to the existing User-Agent deny patterns, use the global country block/allow list (ISO two-letter codes), client IP/CIDR and allow-only DNS ACL, HTTP method policy, request Content-Type denylist and shared per-IP rate limit. All are disabled by default except previously configured User-Agent blocking. An explicit IP allow does **not** bypass the country policy: both must permit a request. Missing GeoIP country is allowed for country block mode and denied for country allow mode. + +Global restrictions add to per-vhost and path rules. Rate-limit requests are counted for each client IP **across** vhosts. Prefer a conservative shared ceiling to avoid blocking shared NAT clients. A global method denial returns 405, the content-type/ACL/UA policy returns 403, and the shared rate limiter returns 429 with `Retry-After`. Admin and TCP/UDP stream listeners are outside this HTTP scope. For global response headers, use the adjacent **Security headers** subtab; see [Security](SECURITY.md). + ## IP, country and DDNS rules - **IP/CIDR ACL:** allow or deny IPv4/IPv6 networks. Use reusable ACL profiles for settings shared by several domains. Both ordinary and Docker Vhosts use a searchable multi-select with selected badges and a preview of IP/CIDR rules, country restrictions and the default policy; the selection changes presentation only, not ACL evaluation. A Vhost can select up to eight profiles (`acl_profile_ids`, or one legacy `acl_profile_id`); Docker assignments can override or inherit the agent's profile selection. diff --git a/docs/ACME_PROVIDERS.md b/docs/ACME_PROVIDERS.md index e58f6b0..bad6180 100644 --- a/docs/ACME_PROVIDERS.md +++ b/docs/ACME_PROVIDERS.md @@ -11,6 +11,10 @@ ancestor even though it can host a Web Server document root. See [Installation: external paths](INSTALL.md#linux-debrpm-read-only-access-to-external-paths) for scoped ACLs and Certbot renewal handling. +### Certificate file permission command preview (0.34.13) + +The permissions explanation now appears next to generated read/traversal ACL commands, rather than alongside the path inputs. The **Copy commands** action is offered only after a valid path selection produces a command preview. These commands grant read access to the certificate/private key and traversal of parent directories; they do not grant write permission. + ## Supported certificate methods | Mode | Requirements | diff --git a/docs/API.md b/docs/API.md index dc6f3c1..2989d49 100644 --- a/docs/API.md +++ b/docs/API.md @@ -16,7 +16,8 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot | `POST /api/config/reload` | Revalidate and hot-reload manually changed `--config` JSON; revision-aware. | | `GET/POST /api/api-tokens` · `DELETE /api/api-tokens/{id}` | List/generate/revoke general RO/RW API tokens (browser-admin-only). | | `GET/POST/DELETE /api/prometheus-token` | Inspect/generate/rotate/revoke a standalone Prometheus scrape token (browser-admin-only). | -| `PUT /api/config/{section}` | Update one supported configuration section. | +| `GET /api/config/{section}` · `PUT /api/config/{section}` | Read or atomically replace one of 23 supported configuration sections. | +| `POST /api/config/geoip/update` | Run a GeoIP download/check/reload immediately using already-saved GeoIP settings; does not change revision. | | `GET /api/config/history` · `POST /api/config/history/restore` | Review/restore retained configuration snapshots. | | `GET /api/status` · `GET /api/backends` | Operational status and backend observations. | | `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. | @@ -38,11 +39,71 @@ Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed f ## Authentication and safe writes -- Ordinary API calls require an administrator browser/session cookie on the admin listener, or a scoped general API Bearer token for explicitly allowlisted API methods/paths. Logging in also provides CSRF context. Writes require correct Host/Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1` as enforced by the server. +- Ordinary API calls require an administrator browser/session cookie on the admin listener, or a scoped general API Bearer token for explicitly allowlisted API methods/paths. Browser-session writes require the matching Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1`, in addition to Host validation. Scoped Bearer-token requests are exempt from browser CSRF requirements; the server enforces the token permission, source IP allowlist and HTTPS for non-loopback access instead. - The Swagger **API** tab uses the signed-in session. Read calls can be executed; mutations require explicit confirmation. It cannot bypass server-side authorization. - Configuration writes are revisioned (`expected_revision`) and rejected if based on a stale save. `GET /api/config` returns `{revision, config, storage}`; `storage` is runtime metadata and must not be copied inside the editable `config` object or included as a write parameter. PUT and history-restore responses remain `{revision, config}`. Prefer section updates over replacing the entire document when practical. +- Each supported section has the same **GET** and **PUT** value shape. `GET /api/config/cache` returns `{revision, section: "cache", value: {…}}`. `PUT /api/config/cache` accepts `{"expected_revision": 12, "value": {…}}`, using the **entire** section value from a fresh GET after modification. The response is the full updated `{revision, config}` envelope (not a section view). Even changes to different sections must use the latest global revision: concurrent stale writes return 409. This is replacement, not JSON Merge Patch. +- Section boundaries: `logging` uses `{"logging": {…}, "error_log": {…}}`; `response` uses `{"server_header": {…}, "diagnostic_headers": {…}, "compression": {…}}`; `global-security` uses a **single** `GlobalSecurity` object including `user_agent_blocking`. For this section, omitting `user_agent_blocking` is an invalid PUT rather than silently clearing the denylist. Independent `user-agent-blocking` and `error_log` section endpoints do not exist. The prior logging-only `PUT /api/config/logging` payload is no longer accepted: read the full logging section and send the paired value. +- The section names use kebab-case (e.g. `not-found-hosts`, `docker-agents`, `global-security`) even when the matching configuration JSON key is snake_case (`not_found_hosts`, `docker_agents`, `global_security`). GeoIP configuration lives at `GET/PUT /api/config/geoip` (serialized from `config.geoip_updates`). After saving, `POST /api/config/geoip/update` triggers a source refresh. The old standalone `/api/geoip/update` no longer exists. This action changes loaded database files, **not** the config revision. A read-only API token cannot execute it; a read/write token can. +- Full-document config imports remain backward compatible with older root-level `user_agent_blocking`; it is migrated into `global_security.user_agent_blocking` when validated. If both positions contain conflicting policies, the import is rejected. Canonical GET and saved config use the nested field. Per-vhost `user_agent_blocking` is unchanged. RO General API tokens may GET sections, RW tokens may PUT sections; the same existing authentication, Host/Origin, revision and audit checks apply. - `/docs`, API schemas, configuration, logs, certificates and most observations are **not anonymous**. Keep the listener restricted and use HTTPS or a tunnel for remote access. +### Available configuration sections + +The section name in the URL and its corresponding JSON member in a full configuration are: + +| Section path (`/api/config/…`) | JSON member(s) in `config` | +|---|---| +| `listeners` | `listeners` | +| `docker-agents` | `docker_agents` | +| `http2` | `http2` | +| `pages` | `pages` | +| `cache` | `cache` | +| `transform` | `transform` | +| `forwarding` | `forwarding` | +| `headers` | `headers` | +| `prometheus` | `prometheus` | +| `geoip` | `geoip_updates` | +| `administration` | `administration` | +| `tls` | `tls` | +| `vhosts` | `vhosts` | +| `web-services` | `web_services` | +| `php-fpm-profiles` | `php_fpm_profiles` | +| `redirects` | `redirects` | +| `not-found-hosts` | `not_found_hosts` | +| `streams` | `streams` | +| `acl-profiles` | `acl_profiles` | +| `host-policies` | `host_policies` | +| `response` | `server_header`, `diagnostic_headers`, `compression` | +| `logging` | `logging`, `error_log` | +| `global-security` | `global_security`, including `user_agent_blocking` | + +Every GET returns a `value` object or array whose shape can be sent unchanged to the matching PUT. For the three grouped sections the `value` contains exactly the named members; for the other sections it is the serialized value of the matching configuration member. Saving does not alter unrelated sections. + +### Complete section-update example + +```http +GET /api/config/global-security +``` + +```json +{ + "revision": 7, + "section": "global-security", + "value": { + "user_agent_blocking": {"enabled": true, "patterns": ["bad-bot"]}, + "country_access": {"mode": "off", "countries": []}, + "acl": null, + "method_policy": {"mode": "off", "methods": []}, + "content_type_policy": {"enabled": false, "blocked": []}, + "rate_limit": {"enabled": false, "requests": 100, "window_seconds": 60, "burst": 20}, + "security_headers": {"hsts": {"enabled": false, "max_age_seconds": 31536000, "include_subdomains": false, "preload": false}, "nosniff": false, "frame_options": "preserve", "referrer_policy": null, "content_security_policy": null, "permissions_policy": null} + } +} +``` + +Use the actual **complete** `value` returned from GET as the PUT `value` after editing it; the example is illustrative, not a substitute for fetching the active defaults. Then send `PUT /api/config/global-security` with `{"expected_revision":7,"value":{...}}`. A successful PUT returns the complete new configuration envelope; HTTP **409** requires re-reading the section and applying the intended changes to the newer revision. GET never writes. The UI and API use the same validation and transaction path. + ## Local CLI alternatives The included `rust-proxy` program supports configuration and backup operations without turning browser sessions into automation tokens: diff --git a/docs/CACHE.md b/docs/CACHE.md index 58884dd..e07eee1 100644 --- a/docs/CACHE.md +++ b/docs/CACHE.md @@ -2,6 +2,10 @@ RustProxy provides an optional shared HTTP response cache with a bounded in-memory **HOT** tier and optional persistent-disk **COLD** tier. Both the global cache and a Vhost's effective policy must permit caching. **COLD storage is disabled by default**, and newly created Vhosts do not automatically cache dynamic content. +### Cache controls in Settings (0.34.13) + +The static-extension cookie-bypass warning sits with its extension list, separate from **Cache response headers** options. Only enable this optimization for files whose bytes do not depend on the user's session. `Cookie` can then be omitted from the shared cache key for the configured extensions; responses with `Set-Cookie`, `private`, `no-store` or `Vary: Cookie` remain uncacheable. + ## Enable caching safely 1. Open **Settings → Cache**, enable the global cache and choose memory limits. Enable COLD only if you want durable disk storage and have enough private disk capacity. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 407e264..3d0a681 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -62,6 +62,27 @@ ACLs support IP/CIDR and **allow-only** DNS/DDNS hostname rules, plus reusable p Global response headers, per-Vhost rules and Web Service-specific headers are distinct scopes. Security and proxy-protocol headers may be protected from rewriting. Compression supports eligible response encodings; availability is still constrained by global limits and the requesting client. +### Global HTTP block / allow and security headers (0.34.13) + +Under **Settings → Rules & headers**, choose **Global block / allow** to configure rules applying to every public HTTP vhost, redirect and 404 host. Independent controls cover **country block/allow**, global **User-Agent deny patterns**, **IP/CIDR and allow-only DNS ACL**, **HTTP method allow/block**, **request Content-Type denylist** and a **shared per-client-IP rate limit**. Disabled policies are neutral. If country allow mode is selected, an unknown GeoIP country is denied; block mode permits unknown countries. Global IP and country checks are cumulative, as are global rules and more restrictive per-vhost policies. Verify the trusted client-IP and GeoIP configuration before enabling country or rate controls. The global rate limit uses the same bucket for a given client across all public vhosts. It does not affect TCP/UDP streams or the separate administration listener. + +The adjacent **Security headers** subtab applies a global response baseline: **HSTS** (HTTPS only), **nosniff**, **X-Frame-Options**, **Referrer-Policy**, **Content-Security-Policy** and **Permissions-Policy**. Explicit per-vhost security-header values take precedence; absence of a vhost value does not cancel a global header. Test HSTS and CSP against all sites before enabling. **Global custom headers** remains a distinct subtab for arbitrary request/response header rules. + +In the updated **0.34.13** API, global User-Agent blocking is nested at `config.global_security.user_agent_blocking`; it is no longer a separate root-level setting. Example (only the relevant fragment): + +```json +{ + "global_security": { + "user_agent_blocking": {"enabled": true, "patterns": ["bad-bot"]}, + "country_access": {"mode": "off", "countries": []} + } +} +``` + +`GET /api/config/global-security` returns `{revision, section, value}` where `value` is the entire `global_security` object. To update it, send `PUT /api/config/global-security` with `{ "expected_revision": , "value": }`; the User-Agent member is **required** in the section PUT. Both Global block / allow and Security headers forms save this complete object, preserving unchanged controls from the current revision. Validation and persistence are atomic. The formerly separate `PUT /api/config/user-agent-blocking` is no longer supported. + +On upgrade, older JSON, database rows, backups and revision history containing a root-level `user_agent_blocking` are migrated at validation/compilation to the nested policy. If both old and new keys are supplied with differing values, validation fails instead of choosing one. Old configurations without `global_security` retain disabled defaults for all newly added policies. See [Administration API](API.md), [Access](ACCESS.md) and [Security](SECURITY.md). + ## Cache and transforms Caching is opt-in. HOT holds bounded memory entries; COLD is an optional disk tier (off by default). Freshness follows eligible HTTP policy and `Vary`; private/no-store responses and protected requests are not shared. Configuration saves can selectively invalidate affected cached responses; 0.34.0 behavior needs production validation. HTTP BAN/PURGE is **disabled by default** and uses an explicit source-IP allowlist. See [Cache](CACHE.md). diff --git a/docs/README.md b/docs/README.md index d8033fe..9618ede 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # RustProxy documentation -Practical guides for **RustProxy 0.34.12**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive. +Practical guides for **RustProxy 0.34.13**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive. ## Getting started @@ -22,7 +22,7 @@ Practical guides for **RustProxy 0.34.12**. This index and the listed pages are - [Experimental TLS post-quantum key exchange](TLS_POST_QUANTUM.md) — opt-in X25519MLKEM768, compatibility, rollback and validation. - [TCP/UDP Streams](STREAMS.md) — non-HTTP forwarding and its security boundaries. -The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. See [Dashboard](DASHBOARD.md). +The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. Release 0.34.13 adds global HTTP security policies and UI layout improvements, groups the global User-Agent policy under `global_security`, provides consistent section-based GET/PUT APIs, and moves manual GeoIP refresh to `POST /api/config/geoip/update`. See [Dashboard](DASHBOARD.md). ## Integrations and operations diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 25b697e..4e0b1c5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -17,6 +17,12 @@ RustProxy provides several security controls, but it is not a complete Web Appli - Honor forwarding identity headers only from correctly configured trusted peers. A client can forge arbitrary `X-Forwarded-*` and User-Agent headers. - Restrict Basic Auth to HTTPS and protect Tinyauth endpoints. Authentication traffic should never be stored in public shared cache. +### Global HTTP security policy + +**Settings → Rules & headers → Global block / allow** is a shared first gate for public HTTP listeners. Countries, User-Agents, IP/CIDR (including allow-only DNS), methods, declared request Content-Types and an optional shared rate limit can be configured there. These rules run before vhost routing, redirects and cache invalidation and do not replace Basic Auth, Tinyauth, application controls or OS firewalling. The country and IP decisions are both required to permit a request; a country deny cannot be bypassed by an IP allow and vice versa. Country mode **allow** is fail-closed for an unknown country; **block** mode is fail-open for unknown GeoIP data. Resolve identity from a trusted source and keep GeoIP updates healthy. + +The **Security headers** subtab controls an HTTPS HSTS policy and optional global nosniff, frame protection, referrer, CSP and permissions policies. They are a common baseline; per-vhost values can set matching headers, but an unset field does not remove a global value. Rejecting only declared Content-Type is not a WAF, and changing HSTS/CSP may break sites. Changes do not apply to TCP/UDP Streams or the private administration listener. + ## Content and cache privacy - Shared cache must refuse private/no-store content and unsafe authenticated representations. Test behavior with cookies, `Authorization`, `Set-Cookie`, `Vary` and conditional requests. diff --git a/langs/en_US.json b/langs/en_US.json index 170c81e..2785613 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -18,6 +18,44 @@ "streamBackendHealthSection": "Health checks", "fileLogsSortAscending": "Sort ascending", "fileLogsSortDescending": "Sort descending", + "globalBlockAllow": "Global block / allow", + "globalSecurityHeaders": "Security headers", + "globalPolicyScope": "PUBLIC HTTP LISTENERS", + "globalPolicyHelp": "Rules are evaluated for public HTTP traffic before connecting to the upstream, including redirects and cache invalidation. Global and per-vhost rules are cumulative; disabled controls leave existing behavior unchanged. DNS-based country rules need GeoIP data.", + "globalCountryTitle": "Country access", + "globalCountryHelp": "Block listed countries or allow only listed countries (ISO 3166-1 alpha-2). Unknown countries are denied in allow mode. Configure GeoIP updates under Settings.", + "globalCountryMode": "Country policy", + "globalCountryList": "Country codes (one per line or comma separated)", + "globalUaTitle": "User-Agent blocklist", + "globalUaEnabled": "Enable global User-Agent blocking", + "globalIpTitle": "IP / CIDR and dynamic DNS access", + "globalIpHelp": "Optional allow/deny rules for client IPs, subnets and allow-only DNS names. Deny wins. The default action applies when no rule matches.", + "globalIpEnabled": "Enable IP / CIDR access rules", + "globalIpDefault": "Default action", + "globalIpRules": "Rules (allow or deny, followed by IP/CIDR; DNS is allow-only)", + "globalMethodTitle": "HTTP methods", + "globalMethodHelp": "Block selected methods or allow only those listed. Applies before redirects and cache invalidation; denied requests receive 405.", + "globalMethodMode": "Method policy", + "globalMethodList": "Methods (comma, space or newline separated)", + "globalContentTypeTitle": "Request Content-Type", + "globalContentTypeHelp": "Reject declared MIME types (exact or type/*) with 403. This is a header rule, not body inspection.", + "globalContentTypeEnabled": "Enable Content-Type denylist", + "globalContentTypeList": "Blocked MIME types (one per line)", + "globalRateTitle": "Shared rate limit", + "globalRateHelp": "Optional per-client-IP limit shared by all public vhosts. Runs in addition to per-vhost limits; use conservative values to avoid blocking legitimate shared clients.", + "globalRateEnabled": "Enable global request rate limit", + "globalRateRequests": "Requests per window", + "globalRateWindow": "Window (seconds)", + "globalRateBurst": "Burst allowance", + "globalPolicySave": "Apply global block / allow rules", + "globalHeadersSecurityHelp": "Global response-header baseline for public HTTP vhosts, redirects and 404 hosts. Explicit per-vhost values override matching global headers. HSTS is sent only over HTTPS; test CSP carefully before rollout.", + "globalSecurityHeadersSave": "Apply security headers", + "globalCountriesRequired": "Enter at least one country code or turn off the country policy.", + "globalCountriesInvalid": "Use two-letter country codes, for example PL or DE.", + "globalMethodsRequired": "Enter at least one HTTP method or turn off the method policy.", + "globalContentTypesRequired": "Enter at least one MIME type or disable Content-Type blocking.", + "cacheHeadersSection": "Cache response headers", + "transformOperationsTitle": "Transformations", "policyRulesTitle": "Rules & headers", "vhUaRateTitle": "User-Agent rate limit overrides", "vhUaRatePattern": "User-Agent pattern", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 13c6f3c..5a55091 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -18,6 +18,44 @@ "streamBackendHealthSection": "Kontrola dostępności", "fileLogsSortAscending": "Sortuj rosnąco", "fileLogsSortDescending": "Sortuj malejąco", + "globalBlockAllow": "Globalne blokady / zezwolenia", + "globalSecurityHeaders": "Nagłówki bezpieczeństwa", + "globalPolicyScope": "PUBLICZNE LISTENERY HTTP", + "globalPolicyHelp": "Reguły dotyczą publicznego ruchu HTTP i są sprawdzane przed połączeniem z backendem, także przy przekierowaniach i unieważnianiu cache. Reguły globalne i per-vhost działają łącznie. Wyłączone opcje nie zmieniają dotychczasowego działania. Reguły krajów wymagają danych GeoIP.", + "globalCountryTitle": "Dostęp według kraju", + "globalCountryHelp": "Blokuj wskazane kraje albo zezwalaj tylko na nie (dwuliterowe kody ISO 3166-1 alpha-2). W trybie zezwalania nierozpoznany kraj jest blokowany. Aktualizacje GeoIP ustawisz w Settings.", + "globalCountryMode": "Polityka krajów", + "globalCountryList": "Kody krajów (każdy w nowym wierszu lub po przecinku)", + "globalUaTitle": "Blokada User-Agent", + "globalUaEnabled": "Włącz globalną blokadę User-Agent", + "globalIpTitle": "Dostęp według IP / CIDR / DNS", + "globalIpHelp": "Opcjonalne reguły allow/deny dla IP, podsieci i nazw DNS (DNS tylko allow). Blokada ma pierwszeństwo. Akcja domyślna obowiązuje, gdy nic nie pasuje.", + "globalIpEnabled": "Włącz reguły IP / CIDR", + "globalIpDefault": "Akcja domyślna", + "globalIpRules": "Reguły (allow lub deny oraz IP/CIDR; DNS tylko allow)", + "globalMethodTitle": "Metody HTTP", + "globalMethodHelp": "Blokuj wybrane metody albo zezwalaj tylko na wskazane. Działa również dla przekierowań i unieważniania cache; odmowa zwraca 405.", + "globalMethodMode": "Polityka metod", + "globalMethodList": "Metody HTTP (oddzielone przecinkami, spacjami lub nową linią)", + "globalContentTypeTitle": "Content-Type żądania", + "globalContentTypeHelp": "Blokuj zadeklarowane typy MIME (dokładnie lub typ/*) kodem 403. Sprawdzany jest nagłówek, nie zawartość żądania.", + "globalContentTypeEnabled": "Włącz blokadę Content-Type", + "globalContentTypeList": "Blokowane typy MIME (każdy w nowym wierszu)", + "globalRateTitle": "Wspólny limit żądań", + "globalRateHelp": "Opcjonalny limit na adres IP klienta, wspólny dla wszystkich publicznych vhostów. Działa obok limitów per-vhost. Zbyt niski limit może blokować klientów korzystających z jednego adresu.", + "globalRateEnabled": "Włącz globalny limit żądań", + "globalRateRequests": "Żądania na przedział", + "globalRateWindow": "Przedział (sekundy)", + "globalRateBurst": "Dodatkowe żądania (burst)", + "globalPolicySave": "Zastosuj globalne blokady / zezwolenia", + "globalHeadersSecurityHelp": "Globalne nagłówki dla odpowiedzi publicznych vhostów HTTP, przekierowań i stron 404. Jawnie ustawione nagłówki vhosta mają pierwszeństwo. HSTS jest wysyłane tylko przez HTTPS; przed wdrożeniem sprawdź działanie CSP.", + "globalSecurityHeadersSave": "Zastosuj nagłówki bezpieczeństwa", + "globalCountriesRequired": "Wpisz przynajmniej jeden kod kraju lub wyłącz politykę krajów.", + "globalCountriesInvalid": "Użyj dwuliterowego kodu kraju, np. PL lub DE.", + "globalMethodsRequired": "Wpisz przynajmniej jedną metodę HTTP lub wyłącz politykę metod.", + "globalContentTypesRequired": "Wpisz przynajmniej jeden typ MIME lub wyłącz blokadę Content-Type.", + "cacheHeadersSection": "Nagłówki odpowiedzi cache", + "transformOperationsTitle": "Operacje transformacji", "policyRulesTitle": "Reguły i nagłówki", "vhUaRateTitle": "Limity dla User-Agent", "vhUaRatePattern": "Wzorzec User-Agent", diff --git a/scripts/test_admin_config_browser.py b/scripts/test_admin_config_browser.py new file mode 100755 index 0000000..8721cbe --- /dev/null +++ b/scripts/test_admin_config_browser.py @@ -0,0 +1,124 @@ +from pathlib import Path +import json +import os +import re +from playwright.sync_api import sync_playwright + +ROOT=Path(__file__).resolve().parents[1] +admin=ROOT/'src/admin' +router=(admin/'router.rs').read_text() +main=router.split('fn admin_app_javascript()',1)[1].split('fn admin_vhosts_javascript()',1)[0] +web=admin/'web' +vhosts=['core.js','protocol.js','health.js','rates.js','editor.js','render.js'] +chunk='\n'.join((web/'vhosts'/f).read_text() for f in vhosts) +app=['"use strict";', 'const ADMIN_DASHBOARD_DEFAULTS = Object.freeze('+ (web/'dashboard-defaults.json').read_text()+');'] +parts=re.findall(r'include_str!\("(web/[^"\n]+)"\)|(?{\n'+chunk+'\n})();') +js='\n'.join(app) +startup='if (document.readyState === "complete") startAdminPanel();\nelse document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true });' +assert startup in js, 'runtime script bootstrap not found' +js=js.replace(startup,'// Browser mock harness: startup disabled') +# App JS is reconstructed in the same module order as router.rs. + +html=(web/'index.html').read_text() +for marker,rel,split in [('RP_METRICS_MAIN','partials/overview-metrics.html',''),('RP_CHARTS_MAIN','partials/overview-charts.html','')]: + raw=(web/rel).read_text(); one,two=raw.split(split) + html=html.replace(f'',one).replace(f'',two) +html=re.sub(r']*src="/assets/app_[^\"]+"[^>]*>', '', html) +html=re.sub(r']*href="/assets/style_[^\"]+"[^>]*>', '', html) +html=html.replace('__RUSTPROXY_VERSION__','0.34.13') + +mock_cfg={ + 'global_security': {'user_agent_blocking':{'enabled':True,'patterns':['bad-bot','curl/*']},'country_access':{'mode':'block','countries':['RU']},'acl':None,'method_policy':{'mode':'block','methods':['TRACE']},'content_type_policy':{'enabled':False,'blocked':[]},'rate_limit':{'enabled':False,'requests':100,'window_seconds':60,'burst':20},'security_headers':{'hsts':{'enabled':False,'max_age_seconds':31536000,'include_subdomains':False,'preload':False},'nosniff':True,'frame_options':'deny','referrer_policy':None,'content_security_policy':None,'permissions_policy':None}}, + 'geoip_updates':{'enabled':False,'format':'csv_pair','ipv4_url':'','ipv6_url':'','mmdb_url':'','max_bytes':1000000,'interval_seconds':86400}, + 'logging': {'capture_user_agent': True, 'capture_country': False,'file':{'enabled':False},'loki':{'enabled':False},'logstash':{'enabled':False},'format':{}}, + 'error_log':{'enabled':False,'path':'/var/log/rustproxy/error.log','format':'JSON'}, + 'cache':{},'transform':{},'compression':{},'server_header':{},'diagnostic_headers':{}, + 'vhosts':[],'redirects':[],'streams':[],'not_found_hosts':[],'host_policies':[],'acl_profiles':[],'listeners':{},'tls':{}, +} +with sync_playwright() as p: + browser=p.chromium.launch(headless=True,executable_path='/usr/bin/chromium',args=['--no-sandbox','--disable-dev-shm-usage']) + page=browser.new_page() + page.set_default_timeout(4000) + errors=[] + page.on('pageerror',lambda exc: errors.append(str(exc))) + page.set_content(html,wait_until='load') + page.evaluate('''() => { window.__calls=[]; window.__mockFetch=async function(path,options={}) { + const request={path:typeof path==='string'?path:path.url,method:options.method||'GET',body:options.body?JSON.parse(options.body):null}; + __calls.push(request); + const reply=JSON.parse(JSON.stringify(envelope||{revision:0,config:{}})); + if (request.method==='PUT') { + if (request.path==='/api/config/global-security') reply.config.global_security=request.body.value; + if (request.path==='/api/config/logging') {reply.config.logging=request.body.value.logging;reply.config.error_log=request.body.value.error_log;} + if (request.path==='/api/config/geoip') reply.config.geoip_updates=request.body.value; + reply.revision+=1; + } + if(request.method==='POST' && request.path==='/api/config/geoip/update')return new Response(JSON.stringify({outcome:'current'}),{status:200,headers:{'content-type':'application/json'}}); + return new Response(JSON.stringify(reply),{status:200,headers:{'content-type':'application/json'}}); + }; window.fetch=window.__mockFetch; }''') + try: + page.add_script_tag(content=js) + except Exception as e: + print('INIT ERRORS',errors) + raise + print('script page errors:', errors[:8]); assert not errors, errors + page.evaluate('''(config) => {authenticated=true; csrf='test'; envelope={revision:10,config}; + renderConfig=()=>{}; refresh=async()=>{}; renderSettings();}''',mock_cfg) + assert not errors,errors + print('initial global UA:',page.locator('#global-ua-block-patterns').input_value()) + # Global rules save: UA + header untouched + page.locator('#global-ua-block-patterns').evaluate('(el)=>{el.value="bad-bot\\nvery-bad";el.dispatchEvent(new Event("input",{bubbles:true}));}') + page.locator('#global-security-form').evaluate('(form)=>form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))') + page.wait_for_function('window.__calls.length >= 1') + result=page.evaluate('window.__calls.at(-1)') + print('global rules save',result['path'],result['body']['value']['user_agent_blocking'],result['body']['value']['security_headers']['frame_options']) + assert result['path']=='/api/config/global-security' + assert result['body']['value']['user_agent_blocking']['patterns']==['bad-bot','very-bad'] + assert result['body']['value']['security_headers']['frame_options']=='deny' + page.wait_for_timeout(200) + # Global headers save: UA untouched, changed nosniff + page.locator('#global-nosniff').evaluate('(el)=>{el.checked=false;el.dispatchEvent(new Event("change",{bubbles:true}));}') + page.locator('#global-security-headers-form').evaluate('(form)=>form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))') + page.wait_for_function('window.__calls.length >= 2') + result=page.evaluate('window.__calls.at(-1)') + print('global headers save',result['path'],result['body']['value']['user_agent_blocking'],result['body']['value']['security_headers']['nosniff']) + assert result['body']['value']['user_agent_blocking']['patterns']==['bad-bot','very-bad'] + assert result['body']['value']['security_headers']['nosniff']==False + page.wait_for_timeout(200) + # GeoIP triggered as action not section save + page.locator('#geoip-update-now').evaluate('(el)=>el.click()') + page.wait_for_function('window.__calls.some(x=>x.method==="POST" && x.path==="/api/config/geoip/update")') + result=page.evaluate('window.__calls.at(-1)') + print('geoip update',result['method'],result['path']) + assert result['path']=='/api/config/geoip/update' + # Error-log subsection must save logging and error_log together. + page.evaluate('renderSettings()') + page.locator('#error-log-enabled').evaluate('(el)=>{el.checked=true;el.dispatchEvent(new Event("change",{bubbles:true}));}') + page.locator('#error-log-form').evaluate('(form)=>form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))') + page.wait_for_function('window.__calls.some(x=>x.path==="/api/config/logging" && x.method==="PUT")') + result=page.evaluate('window.__calls.at(-1)') + print('logging group save',result['path'],list(result['body']['value'])) + assert result['path']=='/api/config/logging' + assert set(result['body']['value'])=={'logging','error_log'} + assert result['body']['value']['error_log']['enabled']==True + assert result['body']['value']['logging']['capture_user_agent']==True + page.wait_for_timeout(120) + # GeoIP settings are saved through its section, while update remains POST. + page.evaluate('renderSettings()') + page.locator('#geoip-interval-hours').evaluate('(el)=>{el.value="48";el.dispatchEvent(new Event("input",{bubbles:true}));}') + page.locator('#geoip-form').evaluate('(form)=>form.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))') + page.wait_for_function('window.__calls.some(x=>x.path==="/api/config/geoip" && x.method==="PUT")') + result=page.evaluate('window.__calls.at(-1)') + print('geoip section save',result['path'],result['body']['value']['interval_seconds']) + assert result['path']=='/api/config/geoip' + assert result['body']['value']['interval_seconds']==48*3600 + assert result['body']['expected_revision']==13 + print('browser final errors',errors[:10]); assert not errors,errors + browser.close() diff --git a/src/acl/dynamic/mod.rs b/src/acl/dynamic/mod.rs index 38d3991..d9d55b2 100644 --- a/src/acl/dynamic/mod.rs +++ b/src/acl/dynamic/mod.rs @@ -67,6 +67,7 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result> { } Ok(()) }; + inspect(config.global_security.acl.as_ref())?; for policy in &config.host_policies { inspect(Some(&policy.acl))?; } for profile in &config.acl_profiles { inspect(profile.acl.as_ref())?; } for vhost in &config.vhosts { diff --git a/src/admin/api_token_access.rs b/src/admin/api_token_access.rs index d3c2718..8e300ec 100644 --- a/src/admin/api_token_access.rs +++ b/src/admin/api_token_access.rs @@ -21,17 +21,14 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b | "/api/metrics/json" | "/api/metrics/summary" | "/api/cache/bans" - ); + ) || (method == Method::GET + && path.strip_prefix("/api/config/") + .is_some_and(|section| ConfigSection::from_name(section).is_some())); let write = permission == "rw" && ((method == Method::PUT && (path == "/api/config" - || path.strip_prefix("/api/config/").is_some_and(|part| { - !part.is_empty() - && part != "history" - && part != "reload" - && part != "storage" - && !part.contains('/') - }))) + || path.strip_prefix("/api/config/") + .is_some_and(|section| ConfigSection::from_name(section).is_some()))) || (method == Method::POST && matches!( path, @@ -39,7 +36,7 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b | "/api/config/history/restore" | "/api/cache/purge" | "/api/cache/invalidate" - | "/api/geoip/update" + | "/api/config/geoip/update" | "/api/docker/refresh" | "/api/certificates/reload" )) diff --git a/src/admin/config_sections.rs b/src/admin/config_sections.rs new file mode 100644 index 0000000..c0e9930 --- /dev/null +++ b/src/admin/config_sections.rs @@ -0,0 +1,242 @@ +// GET and PUT resolve exactly the same named sections. All writes go through +// App::update_as: full validation, revision check under the update lock, and +// one durable transaction. These handlers do not implement partial patches. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConfigSection { + Listeners, + DockerAgents, + Http2, + Pages, + Cache, + Transform, + Forwarding, + Headers, + Prometheus, + Geoip, + Administration, + Tls, + Vhosts, + WebServices, + PhpFpmProfiles, + Redirects, + NotFoundHosts, + Streams, + AclProfiles, + HostPolicies, + Response, + Logging, + GlobalSecurity, +} + +impl ConfigSection { + fn from_name(name: &str) -> Option { + match name { + "listeners" => Some(Self::Listeners), + "docker-agents" => Some(Self::DockerAgents), + "http2" => Some(Self::Http2), + "pages" => Some(Self::Pages), + "cache" => Some(Self::Cache), + "transform" => Some(Self::Transform), + "forwarding" => Some(Self::Forwarding), + "headers" => Some(Self::Headers), + "prometheus" => Some(Self::Prometheus), + "geoip" => Some(Self::Geoip), + "administration" => Some(Self::Administration), + "tls" => Some(Self::Tls), + "vhosts" => Some(Self::Vhosts), + "web-services" => Some(Self::WebServices), + "php-fpm-profiles" => Some(Self::PhpFpmProfiles), + "redirects" => Some(Self::Redirects), + "not-found-hosts" => Some(Self::NotFoundHosts), + "streams" => Some(Self::Streams), + "acl-profiles" => Some(Self::AclProfiles), + "host-policies" => Some(Self::HostPolicies), + "response" => Some(Self::Response), + "logging" => Some(Self::Logging), + "global-security" => Some(Self::GlobalSecurity), + _ => None, + } + } + + fn read(self, config: &crate::config::Config) -> Value { + match self { + Self::Listeners => json!(&config.listeners), + Self::DockerAgents => json!(&config.docker_agents), + Self::Http2 => json!(&config.http2), + Self::Pages => json!(&config.pages), + Self::Cache => json!(&config.cache), + Self::Transform => json!(&config.transform), + Self::Forwarding => json!(&config.forwarding), + Self::Headers => json!(&config.headers), + Self::Prometheus => json!(&config.prometheus), + Self::Geoip => json!(&config.geoip_updates), + Self::Administration => json!(&config.administration), + Self::Tls => json!(&config.tls), + Self::Vhosts => json!(&config.vhosts), + Self::WebServices => json!(&config.web_services), + Self::PhpFpmProfiles => json!(&config.php_fpm_profiles), + Self::Redirects => json!(&config.redirects), + Self::NotFoundHosts => json!(&config.not_found_hosts), + Self::Streams => json!(&config.streams), + Self::AclProfiles => json!(&config.acl_profiles), + Self::HostPolicies => json!(&config.host_policies), + Self::Response => json!({ + "server_header": &config.server_header, + "diagnostic_headers": &config.diagnostic_headers, + "compression": &config.compression, + }), + Self::Logging => json!({ + "logging": &config.logging, + "error_log": &config.error_log, + }), + Self::GlobalSecurity => json!(&config.global_security), + } + } + + fn replace(self, config: &mut crate::config::Config, name: &str, value: Value) -> Result<(), ApiError> { + match self { + Self::Listeners => config.listeners = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::DockerAgents => config.docker_agents = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Http2 => config.http2 = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Pages => config.pages = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Cache => config.cache = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Transform => config.transform = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Forwarding => config.forwarding = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Headers => config.headers = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Prometheus => config.prometheus = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Geoip => config.geoip_updates = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Administration => config.administration = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Tls => config.tls = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Vhosts => config.vhosts = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::WebServices => config.web_services = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::PhpFpmProfiles => config.php_fpm_profiles = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Redirects => config.redirects = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::NotFoundHosts => config.not_found_hosts = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Streams => config.streams = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::AclProfiles => config.acl_profiles = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::HostPolicies => config.host_policies = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?, + Self::Response => { + let part: ResponseSection = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?; + config.server_header = part.server_header; + config.diagnostic_headers = part.diagnostic_headers; + config.compression = part.compression; + } + Self::Logging => { + // A logging save must include both sinks; there is no implicit fallback + // to a single-field payload that could reset error_log accidentally. + let part: LoggingSection = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?; + config.logging = part.logging; + config.error_log = part.error_log; + } + Self::GlobalSecurity => { + let part: crate::config::GlobalSecurity = serde_json::from_value(value) + .map_err(|error| invalid_section(name, error))?; + if part.user_agent_blocking.is_none() { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "global-security requires user_agent_blocking in its complete section value".into(), + )); + } + config.global_security = part; + } + } + Ok(()) + } +} + +fn requested_config_section(name: &str) -> Result { + ConfigSection::from_name(name).ok_or_else(|| ApiError( + StatusCode::NOT_FOUND, + "unknown configuration section".into(), + )) +} + +async fn configuration_section( + State(state): State>, + Path(section): Path, +) -> Result, ApiError> { + let selected = requested_config_section(§ion)?; + let snapshot = state.config.load(); + Ok(Json(json!({ + "revision": snapshot.envelope.revision, + "section": section, + "value": selected.read(&snapshot.envelope.config), + }))) +} + +async fn update_section( + State(state): State>, + Extension(session): Extension, + Path(section): Path, + Json(input): Json, +) -> Result, ApiError> { + // Resolve the name before taking the snapshot, including for stale requests. + let selected = requested_config_section(§ion)?; + let current = state.config.load_full(); + if current.envelope.revision != input.expected_revision { + return Err(ApiError( + StatusCode::CONFLICT, + "configuration changed; reload before saving".into(), + )); + } + let mut config = current.envelope.config.clone(); + selected.replace(&mut config, §ion, input.value)?; + state.update_as(Update { expected_revision: input.expected_revision, config }, session.username) + .await.map(Json) +} + +// GeoIP is an explicit side-effecting action within the geoip settings section. +// GET and PUT are only for desired settings; POST refreshes external data. +async fn geoip_update( + State(state): State>, + Extension(session): Extension, +) -> Result, ApiError> { + let result = crate::geoip::update_now(&state).await.map_err(|error| { + tracing::warn!(error = %format!("{error:#}"), "manual GeoIP update failed"); + ApiError( + StatusCode::BAD_GATEWAY, + format!("GeoIP update failed: {error:#}"), + ) + })?; + state + .store + .audit( + &session.username, + "geoip.update", + &format!( + "outcome={}; files={}", + result.outcome, + if result.updated_files.is_empty() { + "none".to_string() + } else { + result.updated_files.join(",") + } + ), + ) + .await + .map_err(auth::internal)?; + Ok(Json(result)) +} + diff --git a/src/admin/logging_api.rs b/src/admin/logging_api.rs index afa7d29..9277666 100644 --- a/src/admin/logging_api.rs +++ b/src/admin/logging_api.rs @@ -1,138 +1,3 @@ -async fn update_section( - State(state): State>, - Extension(session): Extension, - Path(section): Path, - Json(input): Json, -) -> Result, ApiError> { - let current = state.config.load_full(); - if current.envelope.revision != input.expected_revision { - return Err(ApiError( - StatusCode::CONFLICT, - "configuration changed; reload before saving".into(), - )); - } - let mut config = current.envelope.config.clone(); - let value = input.value; - match section.as_str() { - "listeners" => { - config.listeners = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "docker-agents" => { - config.docker_agents = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "http2" => { - config.http2 = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "response" => { - let part: ResponseSection = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?; - config.server_header = part.server_header; - config.diagnostic_headers = part.diagnostic_headers; - config.compression = part.compression; - } - "pages" => { - config.pages = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "cache" => { - config.cache = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "transform" => { - config.transform = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "logging" => { - if value.get("logging").is_some() || value.get("error_log").is_some() { - let part: LoggingSection = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?; - config.logging = part.logging; - config.error_log = part.error_log; - } else { - config.logging = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?; - } - } - "forwarding" => { - config.forwarding = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "headers" => { - config.headers = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "prometheus" => { - config.prometheus = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "geoip" => { - config.geoip_updates = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "administration" => { - config.administration = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "tls" => { - config.tls = serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "vhosts" => { - config.vhosts = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "web-services" => { - config.web_services = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "php-fpm-profiles" => { - config.php_fpm_profiles = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "redirects" => { - config.redirects = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "not_found_hosts" => { - config.not_found_hosts = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "streams" => { - config.streams = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "acl-profiles" => { - config.acl_profiles = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "host-policies" => { - config.host_policies = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - "user-agent-blocking" => { - config.user_agent_blocking = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? - } - _ => { - return Err(ApiError( - StatusCode::NOT_FOUND, - "unknown configuration section".into(), - )) - } - } - state - .update_as( - Update { - expected_revision: input.expected_revision, - config, - }, - session.username, - ) - .await - .map(Json) -} fn logs_payload(state: &App) -> Value { json!({ "entries": state.logs.recent_limit(crate::logging::LIVE_WINDOW), diff --git a/src/admin/mod.rs b/src/admin/mod.rs index f92a09e..a3a1216 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -5,6 +5,7 @@ include!("session.rs"); include!("preferences.rs"); include!("dashboard_presets.rs"); include!("status.rs"); +include!("config_sections.rs"); include!("logging_api.rs"); include!("config_reload.rs"); include!("api_tokens.rs"); diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 5b4936d..c855b17 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.34.12", + "version": "0.34.13", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only." }, "servers": [ @@ -1025,11 +1025,22 @@ }, "description": "Named PHP-FPM configurations reusable by Web Services." }, + "global_security": { + "$ref": "#/components/schemas/GlobalSecurity" + }, "user_agent_blocking": { - "$ref": "#/components/schemas/UserAgentBlocking" + "deprecated": true, + "writeOnly": true, + "description": "Migration-only input for full configuration imports from older installations. The live GET response and canonical saved configuration use global_security.user_agent_blocking instead. Conflicting legacy and nested policies are rejected.", + "allOf": [ + { + "$ref": "#/components/schemas/UserAgentBlocking" + } + ] } }, - "additionalProperties": true + "additionalProperties": false, + "description": "The canonical global User-Agent policy lives at global_security.user_agent_blocking; the old root-level key is accepted only as a migration input, not returned by this API." }, "Envelope": { "type": "object", @@ -1797,10 +1808,10 @@ "expected_revision": { "type": "integer", "format": "int64", - "description": "Revision returned by the latest GET /api/config." + "description": "Current revision from GET /api/config or GET /api/config/{section}." }, "value": { - "description": "Complete value of the selected configuration section. This is a section replacement, not a merge patch." + "description": "Complete value of the selected configuration section. This is a section replacement, not a merge patch. Read GET /api/config/{section} first, then return its complete `value` with edits. Even an empty array is a full replacement of that collection." } }, "additionalProperties": false, @@ -7585,6 +7596,135 @@ } }, "additionalProperties": false + }, + "GlobalSecurity": { + "type": "object", + "description": "One atomic global HTTP security configuration for all public HTTP hosts. Includes User-Agent blocking, country/IP restrictions, methods, request Content-Type, shared rate limiting and response security headers. Individual policies default off. Old top-level user_agent_blocking in an imported full config is migrated at validation time; conflicting old and new values are rejected.", + "additionalProperties": false, + "properties": { + "country_access": { + "$ref": "#/components/schemas/CountryAccess" + }, + "acl": { + "allOf": [ + { + "$ref": "#/components/schemas/Acl" + } + ], + "nullable": true, + "description": "Optional global client IP/CIDR and allow-only DNS ACL; null disables this layer." + }, + "method_policy": { + "$ref": "#/components/schemas/MethodPolicy" + }, + "content_type_policy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "blocked": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 64, + "description": "Blocked MIME types, e.g. application/json, image/*." + } + } + }, + "rate_limit": { + "$ref": "#/components/schemas/VhostRateLimit" + }, + "security_headers": { + "type": "object", + "additionalProperties": false, + "properties": { + "hsts": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "max_age_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 63072000, + "default": 31536000 + }, + "include_subdomains": { + "type": "boolean", + "default": false + }, + "preload": { + "type": "boolean", + "default": false + } + } + }, + "nosniff": { + "type": "boolean", + "default": false + }, + "frame_options": { + "type": "string", + "enum": [ + "preserve", + "deny", + "sameorigin" + ], + "default": "preserve" + }, + "referrer_policy": { + "type": "string", + "nullable": true, + "maxLength": 8192 + }, + "content_security_policy": { + "type": "string", + "nullable": true, + "maxLength": 8192 + }, + "permissions_policy": { + "type": "string", + "nullable": true, + "maxLength": 8192 + } + } + }, + "user_agent_blocking": { + "$ref": "#/components/schemas/UserAgentBlocking" + } + }, + "required": [ + "user_agent_blocking" + ] + }, + "ConfigSectionRead": { + "type": "object", + "description": "Read one whole configuration section with the same value format accepted by PUT. It does not contain storage metadata or unrelated sections.", + "additionalProperties": false, + "properties": { + "revision": { + "type": "integer", + "format": "int64" + }, + "section": { + "type": "string" + }, + "value": { + "description": "Serialized complete value for the requested section. The format is identical to PUT /api/config/{section}. See x-rustproxy-section-fields on the operation for section-to-Config mapping." + } + }, + "required": [ + "revision", + "section", + "value" + ] } } }, @@ -9813,66 +9953,6 @@ ] } }, - "/api/geoip/update": { - "post": { - "summary": "Check, download and hot-reload GeoIP country databases now", - "description": "Download the configured direct GeoIP source(s), optionally verify publisher SHA-256 values, validate the selected CSV/MMDB format, atomically publish changed files and hot-reload future GeoIP lookups.", - "tags": [ - "Settings" - ], - "operationId": "post_api_geoip_update", - "responses": { - "200": { - "description": "GeoIP check completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GeoIpUpdateResult" - } - } - } - }, - "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" - } - } - } - }, - "502": { - "description": "Publisher check, download, validation or reload failed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "AdminSession": [] - }, - { - "GeneralApiBearer": [] - } - ] - } - }, "/api/cache/purge": { "post": { "summary": "Purge all cache entries or one vhost", @@ -10644,9 +10724,19 @@ } } } + }, + "404": { + "description": "Unknown or removed configuration section", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } }, - "description": "Preferred UI/control-plane save path. Replaces only the selected section while preserving every other section from the current revision. This is not a merge patch. Cache identity is independent from the global configuration revision: logging, listeners, TLS inventory, metrics and administration saves do not purge cached objects. Changes which can alter origin requests or cached responses (cache/transform/response/forwarding/vhosts) rotate the cache compatibility identity and purge incompatible hot/cold objects. Supported section names include docker-agents, vhosts, redirects, not_found_hosts, streams, listeners, http2, tls, pages, cache, transform, logging, forwarding, headers, prometheus, geoip, administration, acl-profiles, host-policies and response.", + "description": "Replaces the named section in one revision-checked, validated, durable configuration update; unrelated sections are preserved. This is a full section replacement, not a merge/patch. Use GET /api/config/{section} to read the exact value shape before PUT. logging requires {logging, error_log}; response requires {server_header, diagnostic_headers, compression}; global-security requires one GlobalSecurity object containing user_agent_blocking. The legacy standalone user-agent-blocking section and standalone error_log are not writable. Global revision conflicts return 409, even for writes to different sections; retry after fetching a new revision. PUT returns the complete updated {revision, config} envelope so the UI can refresh all sections. Origin-dependent cache policy changes still invalidate incompatible cached objects; unrelated logging/listener/administration updates do not purge the cache. See x-rustproxy-section-fields for canonical JSON field mapping.", "parameters": [ { "name": "section", @@ -10671,11 +10761,13 @@ "tls", "vhosts", "redirects", - "not_found_hosts", + "not-found-hosts", "streams", "acl-profiles", "host-policies", - "user-agent-blocking" + "global-security", + "web-services", + "php-fpm-profiles" ] }, "description": "Configuration section to replace." @@ -10705,6 +10797,9 @@ { "AdminSession": [], "CsrfToken": [] + }, + { + "GeneralApiBearer": [] } ], "requestBody": { @@ -10716,6 +10811,248 @@ } } } + }, + "x-rustproxy-section-fields": { + "listeners": [ + "listeners" + ], + "docker-agents": [ + "docker_agents" + ], + "http2": [ + "http2" + ], + "pages": [ + "pages" + ], + "cache": [ + "cache" + ], + "transform": [ + "transform" + ], + "forwarding": [ + "forwarding" + ], + "headers": [ + "headers" + ], + "prometheus": [ + "prometheus" + ], + "geoip": [ + "geoip_updates" + ], + "administration": [ + "administration" + ], + "tls": [ + "tls" + ], + "vhosts": [ + "vhosts" + ], + "web-services": [ + "web_services" + ], + "php-fpm-profiles": [ + "php_fpm_profiles" + ], + "redirects": [ + "redirects" + ], + "not-found-hosts": [ + "not_found_hosts" + ], + "streams": [ + "streams" + ], + "acl-profiles": [ + "acl_profiles" + ], + "host-policies": [ + "host_policies" + ], + "response": [ + "server_header", + "diagnostic_headers", + "compression" + ], + "logging": [ + "logging", + "error_log" + ], + "global-security": [ + "global_security" + ] + } + }, + "get": { + "summary": "Read one revisioned configuration section", + "description": "Returns {revision, section, value}. The value is the complete canonical section payload accepted by PUT /api/config/{section}, not a partial patch. logging groups logging + error_log, response groups the three response controls, and global-security contains user_agent_blocking and all other global security rules. No storage paths are returned by this endpoint. See x-rustproxy-section-fields for the exact Config property mapping of all supported names.", + "tags": [ + "Configuration" + ], + "operationId": "get_api_config_section", + "parameters": [ + { + "name": "section", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "listeners", + "docker-agents", + "response", + "pages", + "cache", + "transform", + "logging", + "forwarding", + "headers", + "prometheus", + "geoip", + "administration", + "http2", + "tls", + "vhosts", + "redirects", + "not-found-hosts", + "streams", + "acl-profiles", + "host-policies", + "global-security", + "web-services", + "php-fpm-profiles" + ] + }, + "description": "Exactly one supported configuration section." + } + ], + "responses": { + "200": { + "description": "Section value and current global revision", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigSectionRead" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden by Host or token policy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Unknown or removed configuration section", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "AdminSession": [] + }, + { + "GeneralApiBearer": [] + } + ], + "x-rustproxy-section-fields": { + "listeners": [ + "listeners" + ], + "docker-agents": [ + "docker_agents" + ], + "http2": [ + "http2" + ], + "pages": [ + "pages" + ], + "cache": [ + "cache" + ], + "transform": [ + "transform" + ], + "forwarding": [ + "forwarding" + ], + "headers": [ + "headers" + ], + "prometheus": [ + "prometheus" + ], + "geoip": [ + "geoip_updates" + ], + "administration": [ + "administration" + ], + "tls": [ + "tls" + ], + "vhosts": [ + "vhosts" + ], + "web-services": [ + "web_services" + ], + "php-fpm-profiles": [ + "php_fpm_profiles" + ], + "redirects": [ + "redirects" + ], + "not-found-hosts": [ + "not_found_hosts" + ], + "streams": [ + "streams" + ], + "acl-profiles": [ + "acl_profiles" + ], + "host-policies": [ + "host_policies" + ], + "response": [ + "server_header", + "diagnostic_headers", + "compression" + ], + "logging": [ + "logging", + "error_log" + ], + "global-security": [ + "global_security" + ] } } }, @@ -13905,6 +14242,66 @@ } ] } + }, + "/api/config/geoip/update": { + "post": { + "summary": "Update GeoIP data using saved geoip configuration now", + "description": "Manually start a GeoIP data refresh using the already-saved settings at GET/PUT /api/config/geoip. This is an operational POST, not a configuration PUT and it does not increment the configuration revision. Downloads configured GeoIP sources, verifies optional SHA-256 checksums, checks CSV/MMDB, atomically publishes changed databases and hot-reloads future lookups. Save any edited GeoIP settings before calling this action. The response is GeoIpUpdateResult; a failed external fetch returns 502 and existing usable data remains untouched.", + "tags": [ + "Configuration" + ], + "operationId": "post_api_config_geoip_update", + "responses": { + "200": { + "description": "GeoIP check completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeoIpUpdateResult" + } + } + } + }, + "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" + } + } + } + }, + "502": { + "description": "Publisher check, download, validation or reload failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "AdminSession": [] + }, + { + "GeneralApiBearer": [] + } + ] + } } }, "tags": [] diff --git a/src/admin/operations.rs b/src/admin/operations.rs index 2d61d04..605c0f5 100644 --- a/src/admin/operations.rs +++ b/src/admin/operations.rs @@ -1,34 +1,3 @@ -async fn geoip_update( - State(state): State>, - Extension(session): Extension, -) -> Result, ApiError> { - let result = crate::geoip::update_now(&state).await.map_err(|error| { - tracing::warn!(error = %format!("{error:#}"), "manual GeoIP update failed"); - ApiError( - StatusCode::BAD_GATEWAY, - format!("GeoIP update failed: {error:#}"), - ) - })?; - state - .store - .audit( - &session.username, - "geoip.update", - &format!( - "outcome={}; files={}", - result.outcome, - if result.updated_files.is_empty() { - "none".to_string() - } else { - result.updated_files.join(",") - } - ), - ) - .await - .map_err(auth::internal)?; - Ok(Json(result)) -} - async fn transform_status(State(state): State>) -> Json { let snapshot = state.config.load_full(); let global = &snapshot.envelope.config.transform; diff --git a/src/admin/router.rs b/src/admin/router.rs index 20dd0b6..e532d33 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -235,7 +235,6 @@ pub fn router(state: Arc) -> Router { .route("/api/traffic", get(traffic_status)) .route("/api/cache", get(cache_status)) .route("/api/transform", get(transform_status)) - .route("/api/geoip/update", post(geoip_update)) .route("/api/cache/purge", post(cache_purge)) .route( "/api/cache/invalidate", @@ -260,7 +259,8 @@ pub fn router(state: Arc) -> Router { "/api/config/history/restore", post(restore_config_history).layer(DefaultBodyLimit::max(4096)), ) - .route("/api/config/{section}", put(update_section)) + .route("/api/config/geoip/update", post(geoip_update)) + .route("/api/config/{section}", get(configuration_section).put(update_section)) .route("/api/logs", get(logs)) .route("/api/live-logs", get(live_logs_stream)) .route("/api/file-logs", get(file_logs)) diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 1763e02..a5143a0 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -651,6 +651,99 @@ mod tests { store.close().await; } + #[test] + fn config_section_registry_read_and_replace_are_symmetric() { + let names = [ + "listeners", "docker-agents", "http2", "pages", "cache", "transform", + "forwarding", "headers", "prometheus", "geoip", "administration", + "tls", "vhosts", "web-services", "php-fpm-profiles", "redirects", + "not-found-hosts", "streams", "acl-profiles", "host-policies", + "response", "logging", "global-security", + ]; + let mut original = Config::default(); + original.global_security.compile().unwrap(); + let expected = serde_json::to_value(&original).unwrap(); + for name in names { + let section = ConfigSection::from_name(name).expect(name); + let value = section.read(&original); + let mut updated = original.clone(); + section.replace(&mut updated, name, value.clone()).unwrap(); + assert_eq!(serde_json::to_value(&updated).unwrap(), expected, "{name}"); + assert_eq!(section.read(&updated), value, "{name}"); + } + for name in ["user-agent-blocking", "error_log", "history", "storage", "unknown", "tls/nested"] { + assert!(ConfigSection::from_name(name).is_none(), "{name}"); + } + } + + #[tokio::test] + async fn section_get_matches_put_payload_and_rejects_unknown_names() { + let (router, store, _dir, secret, _csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for name in [ + "listeners", "docker-agents", "http2", "pages", "cache", "transform", + "forwarding", "headers", "prometheus", "geoip", "administration", "tls", + "vhosts", "web-services", "php-fpm-profiles", "redirects", "not-found-hosts", + "streams", "acl-profiles", "host-policies", "response", "logging", + "global-security", + ] { + let response = router.clone().oneshot( + request(&format!("/api/config/{name}")) + .header("cookie", &cookie) + .body(Body::empty()).unwrap(), + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{name}"); + let data = json_body(response).await; + assert_eq!(data["section"], name); + assert_eq!(data["revision"], 0); + assert!(!data["value"].is_null(), "{name}"); + if name == "logging" { + assert!(data["value"].get("logging").is_some()); + assert!(data["value"].get("error_log").is_some()); + } + if name == "response" { + assert_eq!(data["value"].as_object().unwrap().len(), 3); + } + if name == "global-security" { + assert!(data["value"]["user_agent_blocking"].is_object()); + } + } + let unknown = router.clone().oneshot( + request("/api/config/user-agent-blocking") + .header("cookie", &cookie) + .body(Body::empty()).unwrap(), + ).await.unwrap(); + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + store.close().await; + } + + #[test] + fn geoip_update_is_scoped_to_settings_for_general_api_tokens() { + assert!(general_api_token_allowed(&Method::GET, "/api/config/geoip", "ro")); + assert!(general_api_token_allowed(&Method::PUT, "/api/config/geoip", "rw")); + assert!(!general_api_token_allowed(&Method::PUT, "/api/config/geoip", "ro")); + assert!(general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "rw")); + assert!(!general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "ro")); + assert!(!general_api_token_allowed(&Method::POST, "/api/geoip/update", "rw")); + assert!(!general_api_token_allowed(&Method::GET, "/api/config/geoip/update", "rw")); + } + + #[tokio::test] + async fn geoip_action_new_path_and_old_path_route_contract() { + let (router, store, _dir, secret, _csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for (path, expected) in [ + ("/api/config/geoip/update", StatusCode::METHOD_NOT_ALLOWED), + ("/api/geoip/update", StatusCode::NOT_FOUND), + ] { + let response = router.clone().oneshot( + request(path).header("cookie", &cookie).body(Body::empty()).unwrap(), + ).await.unwrap(); + assert_eq!(response.status(), expected, "{path}"); + } + store.close().await; + } + #[tokio::test] async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() { let (router, store, _dir, secret, csrf) = fixture().await; @@ -658,7 +751,7 @@ mod tests { let logging = Config::default().logging; let body = serde_json::to_vec(&json!({ "expected_revision": 0, - "value": logging, + "value": {"logging": logging, "error_log": Config::default().error_log}, })) .unwrap(); let response = router @@ -721,7 +814,10 @@ mod tests { .header("x-csrf-token", &csrf) .body(Body::from( serde_json::to_vec( - &json!({"expected_revision":0,"value":Config::default().logging}), + &json!({"expected_revision":0,"value":{ + "logging":Config::default().logging, + "error_log":Config::default().error_log + }}), ) .unwrap(), )) @@ -750,6 +846,69 @@ mod tests { store.close().await; } + + #[tokio::test] + async fn global_security_section_saves_rules_and_existing_ua_atomically() { + let (router, _store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let value = serde_json::json!({ + "country_access": {"mode":"block", "countries":["ru"]}, + "method_policy": {"mode":"block", "methods":["TRACE"]}, + "security_headers": {"nosniff":true}, + "user_agent_blocking": {"enabled":true, "patterns":["bad-bot"]} + }); + let body = serde_json::to_vec(&json!({"expected_revision":0, "value":value})).unwrap(); + let response = router.clone().oneshot( + request("/api/config/global-security") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(body)).unwrap(), + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json = json_body(response).await; + assert_eq!(json["revision"], 1); + assert_eq!(json["config"]["global_security"]["country_access"]["countries"][0], "RU"); + assert_eq!(json["config"]["global_security"]["security_headers"]["nosniff"], true); + assert_eq!(json["config"]["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot"); + assert!(json["config"].get("user_agent_blocking").is_none()); + + // A partial section update must not silently reset the existing UA policy. + let body = serde_json::to_vec(&json!({ + "expected_revision":1, + "value": {"method_policy":{"mode":"off","methods":[]}} + })).unwrap(); + let response = router.clone().oneshot( + request("/api/config/global-security") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(body)).unwrap(), + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // GET returns the same standalone section shape accepted by PUT, at the + // matching revision and without unrequested configuration fields. + let read = router.clone().oneshot( + request("/api/config/global-security") + .header("cookie", &cookie) + .body(Body::empty()).unwrap(), + ).await.unwrap(); + assert_eq!(read.status(), StatusCode::OK); + let view = json_body(read).await; + assert_eq!(view["revision"], 1); + assert_eq!(view["section"], "global-security"); + assert_eq!(view["value"]["user_agent_blocking"]["patterns"][0], "bad-bot"); + assert!(view.get("config").is_none()); + assert!(view["value"].get("global_security").is_none()); + } + #[tokio::test] async fn logging_section_persists_error_log_settings_atomically() { let (router, store, dir, secret, csrf) = fixture().await; @@ -2295,7 +2454,7 @@ mod tests { ("/api/acme/issue", "POST"), ("/api/acme/renew", "POST"), ("/api/docker/refresh", "POST"), - ("/api/geoip/update", "POST"), + ("/api/config/geoip/update", "POST"), ("/api/cache/purge", "POST"), ("/api/cache/invalidate", "POST"), ("/api/cache/bans", "GET"), diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index 16dad83..57ddff4 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -112,7 +112,7 @@ function appConfirm(message, options = {}) { } function apiArea(path) { const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1]; - if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security", "user-agent-blocking": "security" })[section] || "configuration"; + if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not-found-hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security", "global-security": "settings" })[section] || "configuration"; if (path === "/api/config") return "configuration"; if (path.startsWith("/api/traffic")) return "traffic"; if (path.startsWith("/api/cache")) return "cache"; @@ -386,9 +386,6 @@ function renderConfig() { $("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; })); if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty")); if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length); - const uaPolicy = config.user_agent_blocking || {}; - if ($("global-ua-block-enabled")) $("global-ua-block-enabled").checked = uaPolicy.enabled === true; - if ($("global-ua-block-patterns")) $("global-ua-block-patterns").value = (uaPolicy.patterns || []).join("\n"); } async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } } async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); } @@ -514,7 +511,69 @@ document.addEventListener("click", event => { if (dashboardSidebar?.classList.co document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } }); window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); }); $("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } }); -$("global-ua-block-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const patterns = [...new Set($("global-ua-block-patterns").value.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]; await saveSection("user-agent-blocking", { enabled: $("global-ua-block-enabled").checked, patterns }); }); } catch (e) { toast(e.message, true); } }); +// Global HTTP controls are a single atomic API section. A header-only save preserves +// the current block rules; a block-only save preserves the current security headers. +const globalLines = id => [...new Set($(id).value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean))]; +const globalPatternLines = id => [...new Set($(id).value.split(/\r?\n/).map(v => v.trim()).filter(Boolean))]; +function globalSecurityPayload(security, ua) { + return { ...security, user_agent_blocking: ua }; +} +$("global-security-form")?.addEventListener("submit", async event => { + event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; + try { + await withFormSubmitState(form, event.submitter, async () => { + const previous = envelope.config.global_security || {}, rate = previous.rate_limit || {}; + const countryMode = $("global-country-mode").value; + const countries = globalLines("global-country-list").map(v => v.toUpperCase()); + if (countryMode !== "off" && !countries.length) throw fieldError("global-country-list", t("globalCountriesRequired")); + if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError("global-country-list", t("globalCountriesInvalid")); + const methodMode = $("global-method-mode").value; + const methods = [...new Set($("global-method-list").value.split(/[\s,]+/).map(v => v.trim().toUpperCase()).filter(Boolean))]; + if (methodMode !== "off" && !methods.length) throw fieldError("global-method-list", t("globalMethodsRequired")); + const contentTypes = globalLines("global-content-type-list").map(v => v.toLowerCase()); + if ($("global-content-type-enabled").checked && !contentTypes.length) throw fieldError("global-content-type-list", t("globalContentTypesRequired")); + const acl = $("global-ip-enabled").checked ? { default: $("global-ip-default").value, rules: parseRules($("global-ip-rules").value, $("global-ip-rules")) } : null; + const ua = { enabled: $("global-ua-block-enabled").checked, patterns: globalPatternLines("global-ua-block-patterns") }; + const security = { + ...previous, + country_access: { mode: countryMode, countries }, + acl, + method_policy: { mode: methodMode, methods }, + content_type_policy: { enabled: $("global-content-type-enabled").checked, blocked: contentTypes }, + rate_limit: { ...rate, enabled: $("global-rate-enabled").checked, + requests: Number($("global-rate-requests").value), + window_seconds: Number($("global-rate-window").value), + burst: Number($("global-rate-burst").value) }, + }; + if (await saveSection("global-security", globalSecurityPayload(security, ua))) { + setSettingsDirty("global-security-form", false); renderSettings(); + } + }); + } catch (e) { toast(e.message, true); } +}); +$("global-security-headers-form")?.addEventListener("submit", async event => { + event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; + try { + await withFormSubmitState(form, event.submitter, async () => { + const previous = envelope.config.global_security || {}; + const optional = id => $(id).value.trim() || null; + const security = { ...previous, security_headers: { + hsts: { enabled: $("global-hsts-enabled").checked, + max_age_seconds: Number($("global-hsts-age").value), + include_subdomains: $("global-hsts-enabled").checked && $("global-hsts-sub").checked, + preload: $("global-hsts-enabled").checked && $("global-hsts-preload").checked }, + nosniff: $("global-nosniff").checked, frame_options: $("global-frame").value, + referrer_policy: optional("global-referrer"), + content_security_policy: optional("global-csp"), + permissions_policy: optional("global-permissions") + }}; + if (await saveSection("global-security", globalSecurityPayload(security, previous.user_agent_blocking || { enabled: false, patterns: [] }))) { + setSettingsDirty("global-security-headers-form", false); renderSettings(); + } + }); + } catch (e) { toast(e.message, true); } +}); + $("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } }); $("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } }); $("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); }); diff --git a/src/admin/web/app/path-permissions.js b/src/admin/web/app/path-permissions.js index a9efcf3..fde9f3b 100644 --- a/src/admin/web/app/path-permissions.js +++ b/src/admin/web/app/path-permissions.js @@ -108,6 +108,7 @@ output.textContent = target.hidden ? "" : commands({ ...options, user: account.value }); output.hidden = !output.textContent; description.hidden = output.hidden; + copy.hidden = !output.textContent; copy.disabled = !output.textContent; empty.hidden = !!output.textContent; empty.textContent = t(options.kind === "web" ? "pathPermissionsWebPrompt" : "pathPermissionsCertificatePrompt"); @@ -133,7 +134,7 @@ const empty = el("p", undefined, "fine"); details.append(summary, label, help); actions.append(copy, details); - target.replaceChildren(description, output, empty, actions); + target.replaceChildren(output, description, empty, actions); view = { target, options, description, summary, help, accountLabel, account, output, empty, copy }; views.set(target, view); account.addEventListener("input", () => update(view)); diff --git a/src/admin/web/app/settings.js b/src/admin/web/app/settings.js index ac3bf52..3ffee69 100644 --- a/src/admin/web/app/settings.js +++ b/src/admin/web/app/settings.js @@ -76,6 +76,61 @@ function renderSettings() { $("error-log-status").textContent = errorSink ? `${t("loggingQueue")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(errorSink.queue_capacity)}` : ""; if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; } if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); } + const global = config.global_security || {}; + if (!settingsDirty.has("global-security-form")) { + const country = global.country_access || {}, methods = global.method_policy || {}, content = global.content_type_policy || {}, rate = global.rate_limit || {}; + const ua = global.user_agent_blocking || {}, acl = global.acl; + $("global-country-mode").value = country.mode || "off"; + $("global-country-list").value = (country.countries || []).join("\n"); + $("global-ua-block-enabled").checked = ua.enabled === true; + $("global-ua-block-patterns").value = (ua.patterns || []).join("\n"); + $("global-ip-enabled").checked = !!acl; + $("global-ip-default").value = acl?.default || "allow"; + $("global-ip-rules").value = rulesText(acl); + $("global-method-mode").value = methods.mode || "off"; + $("global-method-list").value = (methods.methods || []).join("\n"); + $("global-content-type-enabled").checked = content.enabled === true; + $("global-content-type-list").value = (content.blocked || []).join("\n"); + $("global-rate-enabled").checked = rate.enabled === true; + $("global-rate-requests").value = rate.requests ?? 120; + $("global-rate-window").value = rate.window_seconds ?? 60; + $("global-rate-burst").value = rate.burst ?? 30; + syncGlobalSecurityControls(); + } + if (!settingsDirty.has("global-security-headers-form")) { + const headers = global.security_headers || {}, hsts = headers.hsts || {}; + $("global-hsts-enabled").checked = hsts.enabled === true; + $("global-hsts-age").value = hsts.max_age_seconds ?? 31536000; + $("global-hsts-sub").checked = hsts.include_subdomains === true; + $("global-hsts-preload").checked = hsts.preload === true; + $("global-nosniff").checked = headers.nosniff === true; + $("global-frame").value = headers.frame_options || "preserve"; + $("global-referrer").value = headers.referrer_policy || ""; + $("global-csp").value = headers.content_security_policy || ""; + $("global-permissions").value = headers.permissions_policy || ""; + syncGlobalSecurityControls(); + } +} +function syncGlobalSecurityControls() { + for (const [toggle, children] of [ + ["global-country-mode", ["global-country-list"]], + ["global-ua-block-enabled", ["global-ua-block-patterns"]], + ["global-ip-enabled", ["global-ip-default", "global-ip-rules"]], + ["global-method-mode", ["global-method-list"]], + ["global-content-type-enabled", ["global-content-type-list"]], + ["global-rate-enabled", ["global-rate-requests", "global-rate-window", "global-rate-burst"]], + ["global-hsts-enabled", ["global-hsts-age", "global-hsts-sub", "global-hsts-preload"]] + ]) { + const source = $(toggle); + const enabled = source.type === "checkbox" ? source.checked : source.value !== "off"; + for (const id of children) $(id).disabled = !enabled; + } +} +for (const id of ["global-country-mode", "global-ua-block-enabled", "global-ip-enabled", "global-method-mode", "global-content-type-enabled", "global-rate-enabled", "global-hsts-enabled"]) { + $(id)?.addEventListener("change", syncGlobalSecurityControls); +} +for (const id of ["global-security-form", "global-security-headers-form"]) { + for (const type of ["input", "change"]) $(id)?.addEventListener(type, () => setSettingsDirty(id)); } function effectiveCacheMemory() { const d = latestStatus?.system || {}, host = Number(d.memory_total_bytes), cgroup = Number(d.cgroup_memory_max_bytes); diff --git a/src/admin/web/css/10-base.css b/src/admin/web/css/10-base.css index f1f7d02..366026b 100644 --- a/src/admin/web/css/10-base.css +++ b/src/admin/web/css/10-base.css @@ -1504,4 +1504,9 @@ html[data-table-density=ultra] :is(table th, table td) { display: grid; gap: 6px; max-width: 260px; -} \ No newline at end of file +} +/* Permission guidance belongs to its generated command block, not the path inputs. */ +#certificate-external-acl-example.path-permissions { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 15px; } +.path-permissions-commands { margin-bottom: 0; } +.path-permissions-commands + .path-permissions-description { margin: 7px 0 0; } +.path-permissions-actions { margin-top: 10px; } diff --git a/src/admin/web/css/50-settings.css b/src/admin/web/css/50-settings.css index 5db5def..f24f654 100644 --- a/src/admin/web/css/50-settings.css +++ b/src/admin/web/css/50-settings.css @@ -1204,4 +1204,28 @@ html[data-dashboard-density=normal] #panel-overview .stat>span, .refresh-settings-grid select { width: 100%; min-width: 0; -} \ No newline at end of file +} +/* 0.34.13: global security policy forms, grouped with clear control boundaries. */ +.global-policy-form .global-policy-section { + display: grid; + gap: 10px; + border-top: 1px solid var(--line); + margin-top: 18px; + padding-top: 17px; + min-width: 0; +} +.global-policy-form .global-policy-section h3 { margin: 0; font-size: 14px; } +.global-policy-heading { display: grid; gap: 5px; } +.global-policy-heading p { margin: 0; max-width: 85ch; } +.global-policy-fields { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr); gap: 14px; align-items: start; } +.global-rate-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: end; } +.global-policy-fields label, .global-rate-fields label, .global-policy-section > label:not(.check) { display: grid; gap: 6px; min-width: 0; } +.global-policy-section textarea { width: 100%; box-sizing: border-box; } +.global-policy-form .form-actions { margin-top: 20px; } +.cache-static-guidance { margin: 9px 0 0; max-width: 88ch; } +.cache-header-settings-heading { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 24px; margin-bottom: 13px; } +.transform-operations-title { border-top: 1px solid var(--line); padding-top: 18px; margin-top: 22px; margin-bottom: 14px; } +.transform-operations-grid { margin-top: 3px; row-gap: 12px; } +@media (max-width: 760px) { + .global-policy-fields, .global-rate-fields { grid-template-columns: 1fr; } +} diff --git a/src/admin/web/host404.js b/src/admin/web/host404.js index e94c00c..2a0337c 100644 --- a/src/admin/web/host404.js +++ b/src/admin/web/host404.js @@ -80,13 +80,13 @@ list.replaceChildren(...entries.map(item => { const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line"); title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity); - const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not_found_hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head); + const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => { window.rustProxyDrafts.prepare(); load(structuredClone(item)); }), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not-found-hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head); const detail = el("div", undefined, "vhost-detail-strip"); for (const [key, text] of [["notfoundStatus", String(item.page?.status ?? 404)], ["notfoundMode", t(`notfoundMode${(item.page?.kind || "welcome").replace(/^./, c => c.toUpperCase())}`)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(key), "small-label"), el("b", text)); detail.append(node); } card.append(detail); return card; })); if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine")); } form.addEventListener("input", changed); form.addEventListener("change", changed); $("notfound-mode").addEventListener("change", syncMode); $("notfound-status").addEventListener("change", syncMode); - form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not_found_hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } }); + form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not-found-hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } }); $("notfound-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("notfound-search").addEventListener("input", render); $("notfound-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); }); $("notfound-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); render(); }); $("notfound-close").addEventListener("click", closeEditor); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); }); window.rustProxyDrafts.register("notfound", { labelKey: "notfound", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } }); window.rustProxyNotFound = { render, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; listPrefs = { sort: "id", descending: false }; list.replaceChildren(); $("notfound-search").value = ""; if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); } }; diff --git a/src/admin/web/index.html b/src/admin/web/index.html index a854592..cbc068b 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -2088,40 +2088,93 @@ -
-
+
+
-
USER-AGENT - BLOCKING -

Global User-Agent blocklist

-

Blocks matching User-Agent - values on public proxy vhosts before an upstream is opened. Per-vhost - lists are configured in Vhosts → Security.

-
+
PUBLIC HTTP LISTENERS +

Global block / allow

+
LIVE APPLY
- - - -

Matching is case-insensitive. - Plain text matches a substring; * is a wildcard. Requests without User-Agent - are not blocked by this rule.

-
-
-
+

These rules apply before routing to the backend on all public HTTP vhosts, redirects and 404 hosts. Per-vhost restrictions remain active; a local allow cannot bypass a global block. Administration and TCP/UDP Streams are excluded.

+
+

Country access

Uses the verified client IP and GeoIP database. Unknown countries are denied in allow mode, but allowed in block mode.

+
+ + +
+
+
+

User-Agent blocklist

Case-insensitive substring matching; * is a wildcard. A missing User-Agent is not blocked.

+ + +
+
+

IP / CIDR access

Each line: allow or deny followed by IPv4/IPv6 address, CIDR or allow-only DNS name. Default allow with no rules leaves access unrestricted.

+ +
+ + +
+
+
+

HTTP methods

Block selected methods or allow only those listed. Applies before redirects and cache invalidation; denied requests receive 405.

+
+ + +
+
+
+

Request Content-Type

Reject declared MIME types (exact or type/*) with 403. This is a header rule, not body inspection.

+ + +
+
+

Shared rate limit

Optional per-client-IP limit shared by all public vhosts. Runs in addition to per-vhost limits; use conservative values to avoid blocking legitimate shared clients.

+ +
+ + + +
+
+
+ +
+