This commit is contained in:
Mateusz Gruszczyński committed 2026-10-19 14:05:13 +02:00
1 parent 28e39f7ce4
commit b57beb83c7
34 files changed
+1033 -310

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy source and documentation maintenance
Current source version: **0.34.1**. The Cargo package and binary are named `rust-proxy`.
Current source version: **0.34.2**. 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.
+10 -5
View File
@@ -1,8 +1,16 @@
# 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.1.
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.2.
## 0.34.1 — development build: explicit configuration, hot reload and API tokens
## 0.34.2 — configuration storage visibility and public HTTP error responses
- **Settings → Manual config** now reports whether SQLite or an explicit JSON file is the authoritative configuration source, shows its resolved path and distinguishes the SQLite history/audit database from the file-backed active configuration. The authenticated `GET /api/config` response supplies this read-only `storage` metadata without changing the editable JSON schema or write responses; no extra endpoint is needed.
- Added an optional `systemctl edit rustproxy` example using `--config /etc/rustproxy/config.json` with the required ACLs, owner/mode setup, systemd filesystem allowance, validation and safe reload instructions. The original SQLite configuration mode remains the default.
- Normalized built-in Web Server errors and proxy-generated non-template errors to ordinary HTTP status bodies (such as `404 Not Found`), without exposing RustProxy diagnostic strings. Preserved status codes, headers such as `Allow`/`Retry-After`, custom error pages, backend-origin responses and authentication behavior.
- Removed an unused forwarding accessor (`peer_header`) to avoid a Rust `dead_code` warning without changing forwarded header behavior.
## 0.34.1 — explicit configuration, hot reload and API tokens
- `--config /absolute/file.json` / `RUST_PROXY_CONFIG`: an existing JSON file becomes the authoritative configuration source. Panel edits and restored revisions are written atomically to that file, with revision history in SQLite. Manual edits are imported by an explicit reload. Fingerprints reject unexpected external changes.
- `rust-proxy reload` and `rust-proxy config reload` run through the authenticated local admin control plane. The example systemd unit provides `ExecReload` for `systemctl reload rustproxy`; frontends requiring new binds/ALPN still require a service restart.
@@ -11,7 +19,6 @@ Release history focused on **features, settings, default behavior, compatibility
- Settings supports generate/rotate/revoke for a standalone Prometheus scrape Bearer token. Only its hash is stored. An explicit revocation record overrides legacy environment credentials.
- Moved RO/RW API token management from **Settings → Administration** to **Settings → Security**; Administration now links directly to the token card.
**Verification:** Development source only. Preflight/static validation is not a Rust build. Native compilation and runtime/security/integration tests are required prior to any production release.
## 0.34.0 — cache consistency, static delivery and proxy optimization
@@ -34,8 +41,6 @@ Release history focused on **features, settings, default behavior, compatibility
- Preserved protected `X-Request-ID` formatting as **64 lowercase hexadecimal characters**; request IDs now use cryptographically secure 256-bit randomness combined with the process sequence.
- Preserved response-compression settings, cache exclusions, existing configuration formats and database compatibility. No new mandatory migration or load-balancer control was introduced for these optimizations.
**Validation:** Several later 0.34.0 changes were provided as source changes and require final Rust compilation, regression tests and comparable performance measurements. Earlier candidates passed **151 live HTTP checks** with one optional Zstandard check skipped, but these results do not validate the latest source. No measured RPS advantage over 0.33.8 or nginx is claimed.
## 0.33.8 — safer file responses and request handling
- Reduced work for requests where caching cannot apply, while retaining independently configured security checks, response transformations and monitoring.
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.34.1"
version = "0.34.2"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.34.1"
version = "0.34.2"
edition = "2021"
rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration"
+9 -1
View File
@@ -1,4 +1,4 @@
# RustProxy 0.34.1
# RustProxy 0.34.2
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.
@@ -16,6 +16,12 @@ RustProxy combines a reverse proxy, static/PHP web server, certificate managemen
Features and default settings vary by component; configure only the capabilities you need. The [documentation index](docs/README.md) covers each workflow, including prerequisites, security boundaries, troubleshooting and API access.
## 0.34.2
- **Settings → Manual config** displays the active configuration storage mode, its path and the SQLite history location for file-backed mode.
- Documented optional systemd `--config /etc/rustproxy/config.json` deployment with file/directory ACLs, write access for atomic panel saves and reload instructions.
- Public Web Server and proxy-generated errors use standard HTTP status pages/text instead of internal `RustProxy:` descriptions. Origin error bodies and custom error pages remain configurable.
## 0.34.1
- An explicit `--config /absolute/path/config.json` (or `RUST_PROXY_CONFIG`) makes an existing JSON document the active configuration source. Panel saves and configuration-history restores write back to that file, with SQLite retaining revision/audit data. Out-of-band file changes require an explicit reload; concurrent edits are rejected.
@@ -53,3 +59,5 @@ Typical managed Linux defaults: HTTP on `:80`, HTTPS on `:443`, local administra
The authenticated **`/docs`** page includes the guides and a separate interactive **OpenAPI/Swagger** tab.
## Status of this source archive
Version **0.34.2** is delivered as a **source package**. This archive includes regression tests for the configuration and public HTTP changes, but native compilation, full Rust tests and target-system acceptance are required before production use. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
-24
View File
@@ -1,27 +1,3 @@
# 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).
## 0.34.1 — outstanding development acceptance
- Build with the project's required Rust toolchain and run the full unit and integration test suite.
- Verify `--config` concurrent edit rejection, file permissions, atomic writes, migration and restore, plus systemd reload on a running install.
- Exercise RO/RW API token allowlists and IP/HTTPS enforcement, token revocation, Prometheus rotation/revocation (including legacy environment-token fallback), and unauthorized requests.
## 0.34.0 — outstanding release verification
- Compile the exact 0.34.0 source candidate on the supported target environments and run the current Rust regression suite.
- Exercise full HTTP/1.1, TLS, HTTP/2, HTTP/3, WebSocket, frontend/backend routing, retry, cache/privacy, certificate and administration scenarios.
- Test selective HOT/COLD invalidation through configuration edits, restart and persistent cache recovery; verify related regression cases and migration behavior.
- Verify optional Linux `sendfile=auto` with eligible static files and that all ineligible requests use safe normal delivery.
- Perform backup/restore and managed install/update acceptance on supported systems, including the container deployment where applicable.
## Longer-term directions
Future work must be tracked separately from shipped features. Potential areas include better operational verification, additional diagnostics/alerting, clearer configuration and migration UX, and compatibility work as HTTP/ACME/runtime dependencies evolve. These are **not commitments or implemented features** without a separately tested release.
## Documentation policy
- Update existing guides when code behavior changes; remove obsolete instructions and avoid version-by-version implementation notes inside functional guides.
- Keep the changelog technical from an operator's perspective (features, settings, compatibility, defaults, fixes) without Rust allocation/lock/microbenchmark details.
- Preserve old audit/development evidence in a separate archival package if an engineering investigation requires it, not in the live product Reader.
+42 -42
View File
@@ -1,17 +1,17 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
bf8a1b24bced501c3c146dde31007a54506200143024e80e14ace20f143a61b8 AGENTS.md
129945a9495650da2aaa85473bf53ca085b310364c2990c15fed32f6d215de06 CHANGELOG.md
0818fb47be0eb6ac358b9a1a30ed2ff40c022d011e25aa26a66b670c5ee6fd4a Cargo.lock
9708fc8ed4724aae4fd86d446b2ce7f6f2dedcecf39832c449d50fe46769c8b9 Cargo.toml
65441f53fe87a21b1b960ddd7b76a6346a7ab97d6c75ba71c04a1d28504a7ee0 AGENTS.md
9141d37b0acd820916bb0157f2c206d4c20ac56e72454cecf3baea34ab1051b5 CHANGELOG.md
b8d1eceeb2e8aa506da7d5675307a3ca7f101bc7a78a8cc00bb3b441a4dd85a9 Cargo.lock
55788c48310d81442f3a937de1a42e67cae7482f88ec4f1699b50e37f6f56acb Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
ff691d334d393059ea118b50b2e019042b7771e8c269275522d182b8fba04584 README.md
9e15070abc49528babf370241c52602bf131d8af6636b7b9124e66e82041b425 ROADMAP.md
25c622f1c49edea413432091b4100ff6542e76d0145480feba91bc16172e05c3 README.md
447fe8a026b819f326d27b941aa35caea2bcfd493c4de46e3fde77f8f13997c8 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
4c4e96b7ff1aa37776d3bcd272fb95bbe363510e34302a39406ad7c43abf8612 deploy/README.md
e554b8372b642427031fcaea0cf56851ad229c0c5edfcf822bc36ce3986dd337 deploy/rustproxy.service
99ac985e49fc0bf7ddfb0b157c241261ecb69f24f92e5dcc99e4f72d58ce6cea deploy/README.md
4fdba6349bbec3b8bc18411287b80b065aff59b52fe87ded1d9bdf7dcd79e593 deploy/rustproxy.service
c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
@@ -27,29 +27,29 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
b0eaa2cfe4f6e86582f67891e457591ae650d15145e025d85cab43e37c883ac8 docs/ACCESS.md
64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md
99beeee5abc34d8ff57d9e41d9abcf8fbd762e9f09b40ec88d9e958e86f23956 docs/API.md
adb6062ae518c64ba4eb3a358cb1bb147e41ef46f0e42cdc80e27833eddcbf39 docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
7e6a7b9421541c94181a712d17f6166a328e7e28c5a1d629e7d335c0ae5aa1de docs/CACHE.md
2603e83f55f4ab3d6f91003ba61572bcb573d0fc67d26c59fe979fccd271304c docs/CONFIGURATION.md
17a749004daf1f9ac68e98faf22678ecd17e04dd7368749195ba7465b2f27f73 docs/CONTAINER.md
ede69043675c78151bb199a393ae20041047571f7f4bc5e1a2c2c3b8d8c01727 docs/CONFIGURATION.md
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
eb45ea07cc74df4530ce4a34d55ae5872611089e63ca80b80e6493dc6c211ce3 docs/DASHBOARD.md
990153083a982ba820f5b9f475a5fc657b3613d8f1af11c2d34f6483dc7449cf docs/DASHBOARD.md
71fcb8e67135e2197207bd8b979805a9b0ae5df9c6a8594de9b7fadb72fa15e2 docs/DOCKER_DISCOVERY.md
4238ba7e0e6071cd3b30816f29f87d8c9f8f264d1dbe53e4073d606806c5a50f docs/INSTALL.md
d73d0ff31f20f8ca96377bde5cb05753c5c29e417d25b908baac4e51a628ea48 docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
7da5d58a02fe2ebc76366bf8b5517a3536e1c184408b01859a7dec3441819f3d docs/MONITORING.md
1e2796dcf723b74035bc8f31c38bc8ed5e00de2150e6e73379d7b52c26142c46 docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
7a962b62dc4c811b31dfbeb8e8460613fdaeb8b124a8296ee744dd73782a728b docs/README.md
5c0cd602645b500d9c52130b81911b2fc34d464566366fb7e9caa2e56e9f064a docs/README.md
7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md
f2f8916e5782bc9d4deef3405c36eeddcc1c1dde5198f9b415407c0351bf364e docs/TRANSPORT.md
f4c23ee07723a78e1e104991c99bac6be61d72b7b912141e69cffffddf7232fe docs/TROUBLESHOOTING.md
23765b0d3e32766966e922aa79951b8bc94fc93c0ffa38824e9dfc7e6cd4a1aa docs/VHOSTS.md
d8f30b1aaaa6a3a4679dbc4bfdbddb85b065b2fb5bf7c2760e7f92d2fbbf417b docs/WEB_SERVER.md
a0e1a93a3bbd0f38b6f06e6f7d7768946c0751146879e79f1115b8542ddcb0bb docs/WEB_SERVER.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json
@@ -74,8 +74,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
cd85659e8f812e0529295173c24c9cb1531fb82fc1fa2a5298a89f294b237f74 langs/en_US.json
0551e2bb462218e6293de525025106aa901c24bac36d334650b693fbdd997109 langs/pl_pl.json
c928fa3466bb66ec312790a72fafa617cbe443577825410d2f7e3852f5ec32f8 langs/en_US.json
1627109d6dace0c35cd89fecc832aaa7d046acda6aa02b116e3c2190e74a1d84 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -103,7 +103,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
a7d6e4ab507cc74448948406dff7b677cd4bcdf11974d16941a221d34097c053 scripts/preflight.py
26abaf315e087d2c14d477c80baef82453068dd2d258d091aaa2d7b238fa9428 scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
@@ -125,11 +125,11 @@ d1dcb7bdfb7cfdc9c3b88cfb6908201c58532bc888744a13f45853079f214659 src/acme/provi
f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs
e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs
b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs
03c6a3b47f59fa04e673b8e2e3bb6e3b75f8c1ec093e26d6410de416cec1a24a src/admin/api_token_access.rs
bbb7871bda41cde0142e57c6dee92d85537ce58c3a5f2f2e4cf7cbc11a853c9e src/admin/api_tokens.rs
3aa582c79f67952a0c9267c52a01447b13af5813193d2f6ab7ff7b80362ae142 src/admin/api_token_access.rs
0a8a6fb22b90b11ab8ed329779aa3813f6a63a39edac7fe8d821402821073635 src/admin/api_tokens.rs
b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs
51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs
b29c62ac3cd509b58bc62f5f4c6b1af0831b1c86a233ae40d580a7fab8fa6f54 src/admin/config_reload.rs
521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs
b5f6d49f1add583cfc8cd4aa36ffe4db56dac4de3b151fe8239fe42f507019cc src/admin/dashboard_presets.rs
5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs
ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/documentation/documents.rs
@@ -140,7 +140,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
ad8cfe007260fbfbfd9509bb7890a148e5995503ad3c910428ecf90470a9bf0e src/admin/openapi.json
508ed1890154ed109297b35cae652c24c4c7dc46633dcd9ebf170a5ea2dab53d src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -152,17 +152,17 @@ d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/oper
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
8c56885198c27cf83c2464b41db32689cf0ba95d180e82c96669a1ae6e18ff8a src/admin/router.rs
0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs
77fe317c15bf2e03301b22e0552518fe2d7e1ad8d80afaba4e16cd2bf8ddf10c src/admin/status.rs
71fabd06da516df1680cf323336f81c782a87aaf860b86aa2caf437fdeabe0a5 src/admin/status.rs
4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
4a49ec5b515034e839e1a38e9950d0915538340d1871987578b8a10c2b0a8042 src/admin/tests.rs
1a8048ae2f8aa84545d2a42eece2bc7c0bcbbd631641028cc8d770778f537081 src/admin/tests.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
c2fdb6223e889f5b45e1ccc4aac6ed3ae83abc43bf2b6bc5356598c93afb5f95 src/admin/web/api-tokens.js
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
a085ee336e6669229f3ad2f74536f19bd088412ca79bcaff30dff293f1e1baf9 src/admin/web/app/chart-scales.js
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
f83f945897d61c38392f82d266024326ac2aa5e6eef5d5cc9612ac51f4202646 src/admin/web/app/core.js
ebe4cb5b4fbea9c01456589b5c8805bf5838bac78abdb349391af85b1b1cb6e1 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
@@ -203,7 +203,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
6c7ed07ceb1234e89590bd40ccdf0bd2126db3d541ff31e5fa942fa6ce44aa57 src/admin/web/index.html
1c72ceae6c39e29fe9472dc34fa82f9237c5c90e6bfa0d6992be450ac17a5848 src/admin/web/index.html
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -216,7 +216,7 @@ c1b831617bf3cd03dfa416b43791777c8e71f0711c78f9d467fd0f1930ca2a1e src/admin/web/
17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
28fbc7c31f668668aadfff5f2c7abf7ee1cc4f209815629269304e1532515a55 src/admin/web/style.css
5ee0fa4b19f14a3e49283f1ec1e0dbde6b780da205bba35817bc6774f00e532b src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
@@ -246,7 +246,7 @@ aba0f4ae46f05b07bfc434b7174553ce20e5187e1b40a6cf946eb2aefbe1840b src/balance/mo
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
8abb6048303ee8c022546a528d5693167766e7450efd8e20f37ec6fd0fa9ef5e src/balance/pool_view.rs
814be4263c35b84de2b3c3bb3c60d7ead9f593154ac85aa6c8ae35700ee591c7 src/balance/tests.rs
5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs
2b1d2da46b132c8f5e75784bc1d458e231a08342b76dc2aebfbe8124a5ddc959 src/basic_auth.rs
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs
a9eeb93519aae2de7a1282296ced91d2eb8307cc966f8fbf6085af0fd21b0ad2 src/cache/cold/cleanup.rs
@@ -311,8 +311,8 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
5f4610c59b2d4ff6a205a2d9ae11bc84be689b2a68302459f546b3f43e53593c src/docker/reconcile.rs
a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs
a9fc66e9fb3a620b0fc0942e1c1392822d9389b99d020b7bfef6724b7ad1dbb4 src/forwarding/peer_formatting.rs
0259def26930308047a248548ff911f64638b43bdfe7f765ae8c82a87a6aab21 src/forwarding/tests.rs
f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs
f3598f4e71dfa0171a3b3fc74f0229f34a3d2a5a668fc2daa724e557243754ef src/forwarding/tests.rs
af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs
80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs
06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs
@@ -353,14 +353,14 @@ da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/test
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs
b0757461af751bfb9b1552af7f0448eef4f5f19381000a9841a5bdb496132d9b src/prometheus/model.rs
fefc761639392f7be68e6ccc161f47a7a7cb608374977f27ad4d5a9f5e46d8b4 src/prometheus/model.rs
eb92fc3c9606788e58e1f4015be1d3fb2287b20c8dd7007655101106944f54e5 src/prometheus/tests.rs
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs
2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs
66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs
7586fc88e10381eb97f3be6a66f355d16088cc9481f50c193eee8fe169c4fc8e src/proxy/cache_helpers.rs
6bbf2b1f71c7845f9c077cef5af1e2ff1dfe01836e928a039ca7abb0dbfc2e65 src/proxy/cache_helpers.rs
aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs
676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs
3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs
@@ -381,7 +381,7 @@ cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/head
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs
b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs
977a28e0a90a72ac902591996fe97236240f0e05c5e668e7154f8f64b1b47ce0 src/proxy/tests/authority_tests.rs
d0e2b1f4dd3691c8293b8135a5a44a988ff502ffa4694bd2d416194f38b2b106 src/proxy/tests/authority_tests.rs
1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs
@@ -402,10 +402,10 @@ d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.r
8d0d7b01871c8c588c40a54330a1053c85024655fbe38516ca6c94c3c19a8956 src/runtime/certificates.rs
02c598ffcf6af5aea95cd6bdd1a173bc63db8afdeb9153ec87bf0964b7244714 src/runtime/constructors.rs
92b4b284f1b69dca58e4e3f76199f0aecc397eed26d10123b427af1bb8a4a57e src/runtime/docker.rs
5ea426fab83a15e26f9780f6d77c0b4ca6524d51a85c5dce45632723209ea5dc src/runtime/external_config.rs
9015732f5592cb8d5503aee9199a0ead6c42ae20687d506e8752bd1065eafd82 src/runtime/external_config.rs
ee2050b9f8efb45a209c91d76043a1ba29c4289978896aaf7e7d344d48fdc369 src/runtime/tests.rs
7730baa5f02e8ae96498b6c3a4cfe1e7df9075fe10fbcecec71677a0d2709f67 src/runtime/types.rs
8733775f9e15649de8e967fb23192db608904c4dd2f862117de0f1f15aa07e80 src/runtime/updates.rs
54e9bd06230f726f7aa4e96153e00ea79a54bc47c0008d6b8b05f9ec33821e8e src/runtime/types.rs
78b734fc804bb3d673f6cc0ab0bc8862612072ca7f4423d7c9bdacbe78223fc2 src/runtime/updates.rs
208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs
6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs
6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs
@@ -431,7 +431,7 @@ de138a4ac68bcddba3a4f17ceba008bab643359d76a68726a755069f19334bed src/stream/poo
d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs
751d59582fb92c84e0c0cc7c0855c9d0a545805c9ba3be61826e50fb96c297ed src/system_stats/mod.rs
050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs
7467d83066bb6986d8c9103597f7a7617ac677579ed46d573c573ff6ca57bbd9 src/tinyauth.rs
53d40d0e169dffe18c802f07764cc0b4bfdf61d758eadb8f0d12e55d7438a7cd src/tinyauth.rs
0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs
1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs
4972a436f8878dec5f9eb8ff17eb82670b0bfd9e94e821c516575479f5fc1919 src/tls/certificates.rs
@@ -492,11 +492,11 @@ d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server
833c9fea744b9649046e1a64d6fc6972fbf6cea640e1943b0db52b7ccfa9faad src/web_server/response/file.rs
ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs
c2e54fd803a10f74490f183e8c030fd0e3e72f5de4e79c55bc393ab57d90ab9a src/web_server/response/file_body/tests.rs
20509f9302b79d3a7be76dda6edaf5711826d3ddf47d19ebe07cb289427ca328 src/web_server/response/headers.rs
5e574d1e369e80f7164d8aaeadf3b3780df4ef988dca73928e92fed0357a09d7 src/web_server/response/headers.rs
305c0109b4f869a99381a142853f9e2205f5c7c4f904b2dfa4643274bfc542af src/web_server/routing.rs
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
54cf72a82e023fe46513b9cf3130adfef1cd96c40f790c533016b4825d542e06 src/web_server/serve.rs
23904fc0ca6d5e816b332303b148a2ec5ea3b226b5ff8f68db05c42954472a3e src/web_server/tests.rs
161fb2c88dd1d90f3c6d8f1f1cf9f3a060f8e06846f34d1f4995491eac6fcf49 src/web_server/serve.rs
8550770a40db5db72f068399b241b6c29c299acbb02f8ae7ada245722b40dbc6 src/web_server/tests.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
+27 -1
View File
@@ -1,7 +1,7 @@
# Example Linux service
`rustproxy.service` is an operator-reviewed deployment example, not an installed or
tested service. With RustProxy 0.34.1, the example includes `ExecReload` so `systemctl reload rustproxy` uses the authenticated CLI soft reload. If using `RUST_PROXY_CONFIG`, configure it in the service EnvironmentFile so both `ExecStart` and `ExecReload` share the same file source. It runs the compiled executable as a dedicated `rustproxy` account,
tested service. With RustProxy 0.34.2, the example includes `ExecReload` so `systemctl reload rustproxy` uses the authenticated CLI soft reload. If using `RUST_PROXY_CONFIG`, configure it in the service EnvironmentFile so both `ExecStart` and `ExecReload` share the same file source. It runs the compiled executable as a dedicated `rustproxy` account,
uses the public administration profile shown below and grants only the capability needed
for low-port binding. It does not run the proxy as root or grant Docker socket access.
@@ -47,6 +47,32 @@ bind can prevent startup; preserve a pre-change database backup and consult
production deployment. This packaging environment did not run systemd or bind
privileged ports through this unit.
## Optional JSON backing file (`--config`)
Use `sudo systemctl edit rustproxy` to override the startup command, **not** to edit the packaged unit directly. For the `/usr/bin` installation requested below (switch to `/opt/rustproxy/current/rust-proxy` on the managed deployment), configure:
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin --config /etc/rustproxy/config.json
ReadWritePaths=/etc/rustproxy
```
Only activate after exporting a complete, valid JSON config. The directory and initial file may remain `root:root`, restricted to `0700` and `0600`; use:
```bash
sudo chown root:root /etc/rustproxy /etc/rustproxy/config.json
sudo chmod 0700 /etc/rustproxy
sudo chmod 0600 /etc/rustproxy/config.json
sudo setfacl -m u:rustproxy:rwx /etc/rustproxy
sudo setfacl -m u:rustproxy:rw- /etc/rustproxy/config.json
sudo getfacl /etc/rustproxy /etc/rustproxy/config.json
```
**Atomic panel saves replace the file**: the service needs directory traversal/create/rename permission (`rwx`), not just file write. New config files are written `0600`, normally owned by `rustproxy`; do not assume named file ACLs survive replacement. `ProtectSystem=strict` additionally requires `ReadWritePaths=/etc/rustproxy` in the unit; ACLs do not override the systemd sandbox. Keep the original private data directory and SQLite state. Check the active source in **Settings → Manual config**, inspect the unit with `systemctl cat rustproxy`, then `systemctl restart rustproxy` and verify a save. An external JSON edit needs `systemctl reload rustproxy`; there is no automatic file watching.
**Security:** the shown `0.0.0.0:9090` requires administration HTTPS and network access controls before remote exposure; use loopback administration otherwise. The complete export, privilege commands, validation and rollback guidance live in [Installation](../docs/INSTALL.md#optional-systemd-json-configuration).
External certificate files under `/etc/letsencrypt` or `/etc/ssl` remain read-only under the systemd sandbox. `ReadOnlyPaths=` does not grant Unix permissions; use narrow POSIX ACLs for the `rustproxy` user. For traversal of a protected root, for example:
```bash
+2 -2
View File
@@ -14,8 +14,8 @@ Environment=RUST_LOG=rust_proxy=info,warn
Environment=MALLOC_ARENA_MAX=2
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=-/etc/rustproxy/environment
ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 127.0.0.1:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin
ExecReload=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 127.0.0.1:9090 reload
ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin
ExecReload=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 reload
Restart=on-failure
RestartSec=3
KillSignal=SIGINT
+2 -2
View File
@@ -12,7 +12,7 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot
| `GET /api/auth/status` | Whether first-time administrator setup is required. |
| `POST /api/auth/login` · `POST /api/auth/logout` | Start/end a browser admin session. |
| `GET /api/auth/session` | Validate current admin session. |
| `GET /api/config` · `PUT /api/config` | Read or replace validated, revisioned configuration. |
| `GET /api/config` · `PUT /api/config` | Read or replace validated, revisioned configuration. GET also returns read-only `storage` metadata (SQLite/JSON source and paths). |
| `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). |
@@ -36,7 +36,7 @@ Some paths have additional DELETE/PATCH operations, job-status resources, query
- 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.
- 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. Prefer section updates over replacing the entire document when practical.
- 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.
- `/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.
## Local CLI alternatives
+8 -2
View File
@@ -2,7 +2,11 @@
Most settings are managed in the authenticated panel. Save changes after validation; use **Configuration history** to compare or restore an earlier revision. Some listeners are process-owned and require restart to change. Configuration updates are not a replacement for encrypted full backups.
## JSON file as authoritative configuration (0.34.1 DEV)
## Configuration storage: SQLite or JSON file
**Settings → Manual config** shows the active storage mode and path, sourced from the read-only `storage` member of the authenticated `GET /api/config` response (`revision`, `config`, `storage`). With no `--config`, the authoritative configuration is in `<data-dir>/config.sqlite3` (normally `/var/lib/rustproxy/config.sqlite3`). With `--config`, that JSON file is authoritative, while SQLite still stores revision history, accounts and audit records. Switching modes requires a service restart; the panel does not change the startup argument.
### Optional JSON configuration source
Start the process with an **existing**, absolute JSON config filename, for example:
@@ -12,7 +16,9 @@ rust-proxy --data-dir /var/lib/rustproxy --config /etc/rustproxy/config.json ser
`RUST_PROXY_CONFIG=/etc/rustproxy/config.json` is an equivalent global setting. The file must be a regular file (not a symlink) within the configuration size limit and readable/writable by the service account; its parent directory must permit creation/renaming for atomic updates. The file is the canonical desired configuration. Panel and API edits, including history restore, rewrite it; SQLite retains revisions and audit. RustProxy rejects panel saves after an unimported external file edit rather than clobbering it. Use `rust-proxy reload` or `rust-proxy config reload` to import such edits after validation. No `--config` flag keeps the original SQLite-owned configuration behavior.
For a systemd deployment, put `RUST_PROXY_CONFIG=/etc/rustproxy/config.json` into the private EnvironmentFile referenced by the unit, or add `--config ...` to `ExecStart`. Use `systemctl reload rustproxy` after manual edits; the example `ExecReload` calls the CLI. Keep write access to the file and directory narrowly restricted. Hot reload cannot change a process-owned listener's bind/address or ALPN without restart.
For a systemd deployment, use `systemctl edit rustproxy` to add `--config /etc/rustproxy/config.json` to `ExecStart` (with a reset line `ExecStart=`), or supply `RUST_PROXY_CONFIG` through the unit's private EnvironmentFile. **Use an exported, complete JSON document before enabling the flag; an absent/empty file prevents startup.** The example below is in [Installation — optional systemd JSON file](INSTALL.md#optional-systemd-json-configuration). It includes `chown`, POSIX ACLs on both file and parent directory, and `ReadWritePaths=` for hardened services: RustProxy atomically replaces the JSON and therefore needs **write plus traversal permissions on the directory**, not merely a writable file. The resulting file will normally be owned by `rustproxy` with mode `0600` after its first panel save.
Check the effective unit with `systemctl cat rustproxy`, paths/ACLs with `getfacl`, and storage mode under **Settings → Manual config**. Manual file edits require `systemctl reload rustproxy` to import changes; a successful panel save already updates the file. Listener binds/ALPN still require a restart. Back up both the JSON source **and** the SQLite database/other private state; history is not a complete backup.
## Configuration map
+3 -3
View File
@@ -7,12 +7,12 @@ The included `Dockerfile` compiles RustProxy with a **Rust 1.99** build image an
Run from the repository root:
```bash
docker build -t rustproxy:0.34.1 .
docker build -t rustproxy:0.34.2 .
docker volume create rustproxy-data
docker run -d --name rustproxy \
-p 8080:8080 \
-v rustproxy-data:/var/lib/rustproxy \
rustproxy:0.34.1
rustproxy:0.34.2
```
In the image, the default HTTP frontend binds `0.0.0.0:8080`. The admin listener defaults to `127.0.0.1:9090` **inside the container**. Simply publishing container port 9090 does not make that loopback listener reachable from a remote host. Initialize the administrator from a local container shell:
@@ -35,4 +35,4 @@ For Docker-based application discovery, install the **separate Docker agent** on
## Upgrade and validate
Build a reviewed new image, preserve the volume, make an encrypted application backup and deploy with a maintenance window. Verify configuration compatibility, listener reachability, frontend certificates, backup/restore and live traffic. The supplied 0.34.1 development source candidate is not itself a production-tested OCI image.
Build a reviewed new image, preserve the volume, make an encrypted application backup and deploy with a maintenance window. Verify configuration compatibility, listener reachability, frontend certificates, backup/restore and live traffic. The supplied 0.34.2 development source candidate is not itself a production-tested OCI image.
+57 -1
View File
@@ -86,6 +86,62 @@ Then visit `http://127.0.0.1:9090` on your workstation. For a directly reachable
- Listener bindings supplied explicitly via service arguments/systemd are locked against panel changes. Network bind, administration TLS and some monitoring changes take effect after a restart.
- Ports below 1024 need the appropriate OS privileges/capability; use the installed service model rather than running the whole application as root.
## Optional systemd JSON configuration
This is **optional**. Without `--config`, RustProxy keeps its active configuration in `/var/lib/rustproxy/config.sqlite3` (SQLite). With `--config`, panel/API saves and history restores write `/etc/rustproxy/config.json` atomically; SQLite still contains history, administrator state and audit entries. In **Settings → Manual config** you can see which mode and path are active.
1. First export your existing, validated **full configuration** (do not start with `{}` on an established host). On the running instance with administration reachable at `127.0.0.1:9090`, use a privileged shell with `set -o pipefail`:
```bash
sudo install -d -o root -g root -m 0700 /etc/rustproxy
sudo install -o root -g root -m 0600 /dev/null /etc/rustproxy/config.json
sudo -u rustproxy /usr/bin/rust-proxy --data-dir /var/lib/rustproxy \
--admin-listen 127.0.0.1:9090 config get | sudo tee /etc/rustproxy/config.json >/dev/null
```
If the installed binary is at `/opt/rustproxy/current/rust-proxy`, use that path in **both** commands and the override. The export requires an existing initialized administrator. You may instead use **Settings → Manual config → Export JSON**, then install that file securely with `sudo install -o root -g root -m 0600 exported.json /etc/rustproxy/config.json`.
2. Keep the directory and initial file owned by `root`, allow only the RustProxy service identity through **named POSIX ACLs** (install the `acl` package first, e.g. `sudo apt-get install acl` on Debian, to obtain `setfacl`/`getfacl`). The directory needs `rwx` for atomic create/rename; the original JSON needs `rw` for startup and backup tools. `root` retains ownership until the first atomic panel save, after which the new `0600` file is normally owned by `rustproxy`:
```bash
sudo chown root:root /etc/rustproxy /etc/rustproxy/config.json
sudo chmod 0700 /etc/rustproxy
sudo chmod 0600 /etc/rustproxy/config.json
sudo setfacl -m u:rustproxy:rwx /etc/rustproxy
sudo setfacl -m u:rustproxy:rw- /etc/rustproxy/config.json
sudo getfacl /etc/rustproxy /etc/rustproxy/config.json
sudo -u rustproxy test -r /etc/rustproxy/config.json
sudo -u rustproxy test -w /etc/rustproxy
```
Never make `/etc/rustproxy` world-writable. RustProxy checks that `--config` is an existing absolute **regular non-symlink** file. The parent directory must be on a filesystem that supports atomic rename. Restoring a file as root may require reapplying its named ACL; atomic saves generate a new owner-only `0600` file.
3. Edit the systemd drop-in with `sudo systemctl edit rustproxy` and add the following example (the binary path must match your installation):
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin --config /etc/rustproxy/config.json
ReadWritePaths=/etc/rustproxy
```
`ReadWritePaths=` is needed when the unit has `ProtectSystem=strict`; it does **not** grant Unix file permissions, hence the ACLs above. An existing `ExecReload` can remain; it reloads through the local control API. Keep the existing unit's sandboxing, state/log writable paths, network capabilities and runtime user. Do not expose the example's `0.0.0.0:9090` admin listener over plaintext on an untrusted network: enable administration HTTPS and strict network controls first, or use the safer `127.0.0.1:9090` binding without `--allow-remote-admin`.
4. Verify and restart during a maintenance window:
```bash
sudo systemctl daemon-reload
sudo systemctl cat rustproxy
sudo systemd-analyze verify rustproxy.service
sudo systemctl restart rustproxy
sudo systemctl status rustproxy
sudo journalctl -u rustproxy -n 60 --no-pager
```
Confirm in **Settings → Manual config** that the source is **JSON file** and the path is `/etc/rustproxy/config.json`. Save a harmless edit and confirm that the file is rewritten and readable by `rustproxy`; check that a revision appears in SQLite. To import a deliberate external file edit without restarting, run `sudo systemctl reload rustproxy` (if the unit has `ExecReload`) or `rust-proxy --data-dir /var/lib/rustproxy --admin-listen 127.0.0.1:9090 config reload`. The file is not watched automatically. An incompatible external edit must fail validation rather than silently overwrite the live configuration.
For the alternate environment-variable method, set `RUST_PROXY_CONFIG=/etc/rustproxy/config.json` in the service's protected EnvironmentFile. Do not set **both** unrelated config paths. See [Configuration](CONFIGURATION.md) and the [deployment example](../deploy/README.md).
## Managed update
Test the new build first. Then execute:
@@ -105,4 +161,4 @@ The managed certificate service can create a private Python/Certbot environment
Verify `systemctl status rustproxy` and `journalctl -u rustproxy` (managed systemd), the panel login, frontend certificates, first Vhost, real backend response, access policies, optional QUIC, current logs and a full backup/restore test. For error diagnosis, consult [Troubleshooting](TROUBLESHOOTING.md).
**Release verification:** supplied 0.34.0 source changes still require target-platform compilation and functional acceptance before Internet-facing production use.
**Release verification:** RustProxy 0.34.2 is a source package; complete a Rust 1.99+ build and target-platform HTTP/systemd/security acceptance tests before Internet-facing production use.
+3 -3
View File
@@ -1,12 +1,12 @@
# RustProxy documentation
Practical guides for **RustProxy 0.34.1**. 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.2**. 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
- [Installation and updates](INSTALL.md) — packages, build, first administrator, systemd and updates.
- [Installation and updates](INSTALL.md) — packages, build, first administrator, optional JSON/systemd permissions and updates.
- [Container deployment](CONTAINER.md) — image, persistent state, ports and isolation.
- [Settings and configuration](CONFIGURATION.md) — what to configure and which changes need restart.
- [Settings and configuration](CONFIGURATION.md) — active SQLite/file storage source and which changes need restart.
- [Troubleshooting](TROUBLESHOOTING.md) — symptom-based checks and release validation.
## Publishing services
+3 -1
View File
@@ -44,7 +44,9 @@ An optional bounded Apache-style `.htaccess` compatibility layer supports a **su
A Web Service may enable PHP via a shared PHP-FPM profile or its own configuration. PHP is executed by the FPM service, not RustProxy, and must not be exposed as a raw Internet port. Use dedicated pools for different applications. See [PHP-FPM](PHP_FPM.md).
Vhosts can intercept selected application error bodies in **Pages** and return a configured HTML error page while preserving the status. Internal location `error_fallback` is different: it routes the error to a named internal handler. Error-page replacements are marked uncacheable.
Missing static files return normal **HTTP 404 Not Found**, unreadable/blocked paths **403 Forbidden**, unsupported static methods **405 Method Not Allowed** (with `Allow: GET, HEAD`), and internal failures **500 Internal Server Error**. Generated response bodies contain only the standard status, not a `RustProxy: Web Server ...` diagnostic. `HEAD` errors have no response body. Internal server errors can be diagnosed through service logs; generated responses are not cacheable by default.
Vhosts can intercept selected application error bodies in **Pages** and return a configured HTML error page while preserving the status. Internal location `error_fallback` is different: it routes the error to a named internal handler. Error-page replacements are marked uncacheable. Origin-supplied and explicitly configured custom error bodies are preserved unless interception is enabled.
## Monitoring and issues
+9 -1
View File
@@ -20,6 +20,15 @@
"mainMenu": "Menu",
"routing": "Legacy routes",
"configuration": "Configuration",
"configStorageLabel": "CONFIGURATION SOURCE",
"configStorageLoading": "Checking storage...",
"configStorageUnavailable": "Storage information unavailable",
"configStorageFile": "JSON file (active configuration)",
"configStorageSqlite": "SQLite database (active configuration)",
"configStoragePath": "Path",
"configStorageFileHelp": "Panel saves and history restores update the JSON file. SQLite keeps revisions and audit records.",
"configStorageSqliteHelp": "The active configuration and revision history are saved in SQLite. No external config file is selected.",
"configStorageHistory": "SQLite history database",
"manualConfig": "Manual config",
"runtime": "RUNTIME",
"localOnly": "ADMINISTRATION",
@@ -75,7 +84,6 @@
"configHelp": "A valid save is applied atomically. Invalid or stale changes leave the running configuration untouched.",
"apply": "Validate, save and apply",
"noRestart": "Routing and response policies apply live; listener changes require restart.",
"roadmapHelp": "Release details and planned features are documented in ROADMAP.md.",
"overviewTitle": "Traffic, in focus.",
"overviewDescription": "Traffic, cache and operating-system measurements in one compact dashboard.",
"routingTitle": "Connect the right dots.",
+9 -1
View File
@@ -20,6 +20,15 @@
"mainMenu": "Menu",
"routing": "Starsze trasy",
"configuration": "Konfiguracja",
"configStorageLabel": "ŹRÓDŁO KONFIGURACJI",
"configStorageLoading": "Sprawdzanie miejsca zapisu...",
"configStorageUnavailable": "Nie można odczytać miejsca zapisu konfiguracji",
"configStorageFile": "Plik JSON (aktywna konfiguracja)",
"configStorageSqlite": "Baza SQLite (aktywna konfiguracja)",
"configStoragePath": "Ścieżka",
"configStorageFileHelp": "Zapis w panelu i przywracanie rewizji aktualizują plik JSON. SQLite przechowuje historię i audyt.",
"configStorageSqliteHelp": "Aktywna konfiguracja i historia rewizji znajdują się w SQLite. Nie wybrano zewnętrznego pliku.",
"configStorageHistory": "Baza historii SQLite",
"manualConfig": "Konfiguracja ręczna",
"runtime": "ŚRODOWISKO",
"localOnly": "ADMINISTRACJA",
@@ -75,7 +84,6 @@
"configHelp": "Poprawny zapis jest stosowany atomowo. Błędne lub nieaktualne zmiany nie zastępują działającej konfiguracji.",
"apply": "Sprawdź, zapisz i zastosuj",
"noRestart": "Routing i polityki odpowiedzi działają od razu; zmiana portów wymaga restartu.",
"roadmapHelp": "Zmiany w wydaniach i planowane funkcje opisuje plik ROADMAP.md.",
"overviewTitle": "Ruch pod kontrolą.",
"overviewDescription": "Ruch, cache i pomiary systemu operacyjnego w jednym zwartym panelu.",
"routingTitle": "Połącz właściwe adresy.",
+2 -2
View File
@@ -76,13 +76,13 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.34.0 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
# The Reader uses compile-time include_str! for its bundled Markdown. A
# dangling registration is a real compiler error, unlike an omitted old
# release audit. Match both reader_doc! and the expanded include_str! form.
registry = root / "src/admin/documentation/documents.rs"
if not registry.is_file():
raise ValueError("Incomplete RustProxy 0.34.0 source tree: missing src/admin/documentation/documents.rs")
raise ValueError("Incomplete RustProxy 0.34.2 source tree: missing src/admin/documentation/documents.rs")
embedded_docs = set(re.findall(
r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8")
))
+5 -1
View File
@@ -26,7 +26,11 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
&& ((method == Method::PUT
&& (path == "/api/config"
|| path.strip_prefix("/api/config/").is_some_and(|part| {
!part.is_empty() && part != "history" && part != "reload" && !part.contains('/')
!part.is_empty()
&& part != "history"
&& part != "reload"
&& part != "storage"
&& !part.contains('/')
})))
|| (method == Method::POST
&& matches!(
+56 -4
View File
@@ -2,8 +2,8 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.1",
"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. Version 0.34.1 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."
"version": "0.34.2",
"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. Version 0.34.2 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": [
{
@@ -954,6 +954,28 @@
"config"
]
},
"ConfigurationRead": {
"type": "object",
"description": "Authenticated GET /api/config response; storage is read-only runtime metadata, excluded from configuration updates and revisioned snapshots.",
"properties": {
"revision": {
"type": "integer",
"format": "int64"
},
"config": {
"$ref": "#/components/schemas/Config"
},
"storage": {
"$ref": "#/components/schemas/ConfigStorage"
}
},
"additionalProperties": false,
"required": [
"revision",
"config",
"storage"
]
},
"Update": {
"type": "object",
"properties": {
@@ -6750,6 +6772,35 @@
]
}
]
},
"ConfigStorage": {
"type": "object",
"description": "Read-only metadata in the GET /api/config response. Null paths indicate that a path is not applicable or unavailable.",
"properties": {
"mode": {
"type": "string",
"enum": [
"sqlite",
"file"
]
},
"config_path": {
"type": "string",
"nullable": true,
"description": "Absolute JSON path only in file mode."
},
"database_path": {
"type": "string",
"nullable": true,
"description": "Private SQLite config/history database path."
}
},
"required": [
"mode",
"config_path",
"database_path"
],
"additionalProperties": false
}
}
},
@@ -7761,7 +7812,8 @@
},
"/api/config": {
"get": {
"summary": "Read the revisioned configuration",
"summary": "Read the revisioned configuration and storage source",
"description": "The storage member reports runtime SQLite/JSON paths and is not part of the writable config or revisioned snapshots.",
"tags": [
"Configuration"
],
@@ -7772,7 +7824,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Envelope"
"$ref": "#/components/schemas/ConfigurationRead"
}
}
}
+41 -2
View File
@@ -312,8 +312,47 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
)
}
async fn configuration(State(state): State<Arc<App>>) -> Json<crate::config::Envelope> {
Json(state.config.load().envelope.clone())
// Storage metadata belongs to GET /api/config, not to the editable `config`
// document or the revisioned envelopes returned by update/restore endpoints.
#[derive(Serialize)]
struct ConfigurationRead {
#[serde(flatten)]
envelope: crate::config::Envelope,
storage: ConfigStorage,
}
#[derive(Serialize)]
struct ConfigStorage {
mode: &'static str,
config_path: Option<String>,
database_path: Option<String>,
}
async fn configuration(State(state): State<Arc<App>>) -> Json<ConfigurationRead> {
let envelope = state.config.load().envelope.clone();
let config_path = state
.config_file
.lock()
.await
.as_ref()
.map(|(path, _)| path.to_string_lossy().into_owned());
let database_path = state
.policy
.data_dir
.as_ref()
.map(|dir| dir.join("config.sqlite3").to_string_lossy().into_owned());
Json(ConfigurationRead {
envelope,
storage: ConfigStorage {
mode: if config_path.is_some() {
"file"
} else {
"sqlite"
},
config_path,
database_path,
},
})
}
#[derive(Deserialize)]
+258 -56
View File
@@ -1664,72 +1664,133 @@ mod tests {
overview_samples: 600,
};
let body = serde_json::to_string(&preset).unwrap();
let anon = router.clone().oneshot(
request("/api/dashboard-presets").body(Body::empty()).unwrap()
).await.unwrap();
let anon = router
.clone()
.oneshot(
request("/api/dashboard-presets")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(anon.status(), StatusCode::UNAUTHORIZED);
let forbidden = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
let forbidden = router
.clone()
.oneshot(
request("/api/dashboard-presets/Daily")
.method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
let saved = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.clone())).unwrap()
).await.unwrap();
let saved = router
.clone()
.oneshot(
request("/api/dashboard-presets/Daily")
.method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(saved.status(), StatusCode::NO_CONTENT);
let listing = router.clone().oneshot(
request("/api/dashboard-presets")
.header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
let listing = router
.clone()
.oneshot(
request("/api/dashboard-presets")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(listing.status(), StatusCode::OK);
let entries = json_body(listing).await;
assert_eq!(entries[0]["name"], "Daily");
assert_eq!(entries[0]["overview_samples"], 600);
assert_eq!(entries[0]["dashboard_widget_sizes"], Value::Null);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap().len(), 1);
assert!(store.list_admin_dashboard_presets("someone-else").await.unwrap().is_empty());
let rejected = router.clone().oneshot(
request("/api/dashboard-presets/Invalid").method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.replace("\"overview_samples\":600", "\"overview_samples\":999"))).unwrap()
).await.unwrap();
assert_eq!(
store
.list_admin_dashboard_presets("owner")
.await
.unwrap()
.len(),
1
);
assert!(store
.list_admin_dashboard_presets("someone-else")
.await
.unwrap()
.is_empty());
let rejected = router
.clone()
.oneshot(
request("/api/dashboard-presets/Invalid")
.method("PUT")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.replace(
"\"overview_samples\":600",
"\"overview_samples\":999",
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let renamed = router.clone().oneshot(
request("/api/dashboard-presets/Daily").method("PATCH")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"Nightly"}"#)).unwrap()
).await.unwrap();
let renamed = router
.clone()
.oneshot(
request("/api/dashboard-presets/Daily")
.method("PATCH")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"Nightly"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
assert_eq!(store.list_admin_dashboard_presets("owner").await.unwrap()[0].0, "Nightly");
let deleted = router.oneshot(
request("/api/dashboard-presets/Nightly").method("DELETE")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(
store.list_admin_dashboard_presets("owner").await.unwrap()[0].0,
"Nightly"
);
let deleted = router
.oneshot(
request("/api/dashboard-presets/Nightly")
.method("DELETE")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert!(store.list_admin_dashboard_presets("owner").await.unwrap().is_empty());
assert!(store
.list_admin_dashboard_presets("owner")
.await
.unwrap()
.is_empty());
store.close().await;
}
@@ -1896,7 +1957,11 @@ mod tests {
let mut upgraded = normalized.clone();
for widget in DASHBOARD_WIDGETS.iter() {
let id = json!(widget.id);
if selected["dashboard_widgets"].as_array().unwrap().contains(&id) {
if selected["dashboard_widgets"]
.as_array()
.unwrap()
.contains(&id)
{
continue;
}
upgraded["dashboard_widgets"]
@@ -2346,6 +2411,12 @@ mod tests {
"cookie"
);
assert!(spec["paths"].get("/api/config").is_some());
assert!(spec["paths"].get("/api/config/storage").is_none());
assert_eq!(
spec["paths"]["/api/config"]["get"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/ConfigurationRead"
);
assert!(spec["paths"].get("/api/config/history/restore").is_some());
let response = router
.clone()
@@ -3483,5 +3554,136 @@ mod tests {
assert_eq!(result.status(), StatusCode::FORBIDDEN);
store.close().await;
}
#[tokio::test]
async fn configuration_get_requires_authentication_and_reports_sqlite_storage() {
let (router, store, _dir, secret, _) = fixture().await;
let unauthenticated = router
.clone()
.oneshot(request("/api/config").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
let response = router
.clone()
.oneshot(
request("/api/config")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let payload = json_body(response).await;
assert!(payload["revision"].is_number());
assert!(payload["config"].is_object());
assert!(payload["config"].get("storage").is_none());
assert_eq!(payload["storage"]["mode"], "sqlite");
assert!(payload["storage"]["config_path"].is_null());
assert!(payload["storage"]["database_path"].is_null()); // fixture omits data_dir
// The old GET endpoint is gone. A dynamic PUT-only section route may
// match the path, but it must not respond to GET.
let old_route = router
.oneshot(
request("/api/config/storage")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(old_route.status(), StatusCode::METHOD_NOT_ALLOWED);
store.close().await;
}
#[tokio::test]
async fn configuration_get_reports_explicit_json_and_private_sqlite_paths() {
let dir = tempfile::tempdir().unwrap();
let config = Config::default();
let policy = ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: Some(dir.path().to_path_buf()),
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
};
let store = Store::open(&dir.path().join("config.sqlite3"), &config)
.await
.unwrap();
store
.initialize_admin("owner", "unused-test-hash", "test")
.await
.unwrap();
let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf())
.await
.unwrap();
let issued = store
.create_session(1, "browser", None)
.await
.unwrap()
.unwrap();
let snapshot = config::compile(config, 0, &policy).unwrap();
let state = App::new(
store.clone(),
snapshot,
policy,
auth,
10,
Duration::from_secs(30),
Duration::from_secs(30),
Duration::from_secs(300),
);
let file = dir.path().join("config.json");
state.set_config_file(file.clone(), [0u8; 32]).await;
let response = router(state)
.oneshot(
request("/api/config")
.header("cookie", format!("{}={}", auth::COOKIE_NAME, issued.secret))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let payload = json_body(response).await;
assert!(payload["revision"].is_number());
assert!(payload["config"].is_object());
assert!(payload["config"].get("storage").is_none());
assert_eq!(payload["storage"]["mode"], "file");
assert_eq!(
payload["storage"]["config_path"],
file.to_string_lossy().as_ref()
);
assert_eq!(
payload["storage"]["database_path"],
dir.path().join("config.sqlite3").to_string_lossy().as_ref()
);
store.close().await;
}
#[test]
fn storage_metadata_is_exposed_only_through_configuration_get_to_api_tokens() {
assert!(general_api_token_allowed(&Method::GET, "/api/config", "ro"));
assert!(general_api_token_allowed(&Method::GET, "/api/config", "rw"));
assert!(!general_api_token_allowed(
&Method::GET,
"/api/config/storage",
"ro"
));
assert!(!general_api_token_allowed(
&Method::PUT,
"/api/config/storage",
"rw"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/config/storage",
"rw"
));
}
include!("preferences_tests.rs");
}
+25 -4
View File
@@ -22,7 +22,7 @@ function clearInvalidOriginRecovery() {
}
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let envelope = null, configStorage = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
let lastApiLatencyMs = null, sessionCheckPromise = null;
@@ -57,7 +57,7 @@ function translate() {
window.rustProxyDrafts?.refresh();
window.rustProxyPreferences?.refreshAppearanceUi?.();
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators();
}
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
@@ -335,7 +335,28 @@ async function refreshCertificateInventory() {
if (e.status !== 401 && authenticated && epoch === authEpoch) certificateInventory = new Map();
}
}
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
function renderConfigStorage() {
const mode = $("config-storage-mode"), location = $("config-storage-location");
const description = $("config-storage-description"), history = $("config-storage-history");
if (!configStorage) {
mode.textContent = t("configStorageLoading");
location.textContent = ""; description.textContent = ""; history.textContent = "";
return;
}
if (configStorage.error) {
mode.textContent = t("configStorageUnavailable");
location.textContent = ""; description.textContent = ""; history.textContent = "";
return;
}
const file = configStorage.mode === "file";
mode.textContent = t(file ? "configStorageFile" : "configStorageSqlite");
const source = file ? configStorage.config_path : configStorage.database_path;
location.textContent = source ? `${t("configStoragePath")}: ${source}` : "";
description.textContent = t(file ? "configStorageFileHelp" : "configStorageSqliteHelp");
history.textContent = file && configStorage.database_path
? `${t("configStorageHistory")}: ${configStorage.database_path}` : "";
}
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; configStorage = value.storage || { error: true }; renderConfigStorage(); await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
function rulesText(acl) { return (acl?.rules || []).map(r => `${r.action} ${r.network}`).join("\n"); }
function normalizeAclRuleText(value) {
return String(value ?? "").normalize("NFKC")
@@ -376,7 +397,7 @@ function lock(notify = true) {
window.rustProxyInlineLayout?.cancel?.();
window.rustProxyRefresh?.stop();
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
+60 -24
View File
@@ -2769,14 +2769,23 @@
id="prom-path" maxlength="128" required="" value="/metrics" /></label></div>
<label><span data-i18n="prometheusCidrs"></span><textarea id="prom-cidrs" required=""
rows="3" spellcheck="false"></textarea></label>
<div class="card-heading"><h3 data-i18n="promTokenTitle">Prometheus bearer token</h3></div>
<p class="fine" data-i18n="promTokenHelp">Generate a token directly in the panel. Only its SHA-256 hash is retained; rotating invalidates the previous token immediately.</p>
<div class="card-heading">
<h3 data-i18n="promTokenTitle">Prometheus bearer token</h3>
</div>
<p class="fine" data-i18n="promTokenHelp">Generate a token directly in the panel. Only
its SHA-256 hash is retained; rotating invalidates the previous token immediately.
</p>
<p class="fine" id="prom-token-status" role="status"></p>
<div class="form-actions">
<button class="outline" id="prom-token-generate" type="button" data-i18n="promTokenGenerate">Generate / rotate token</button>
<button class="quiet danger" id="prom-token-revoke" type="button" data-i18n="promTokenRevoke">Revoke token</button>
<button class="outline" id="prom-token-generate" type="button"
data-i18n="promTokenGenerate">Generate / rotate token</button>
<button class="quiet danger" id="prom-token-revoke" type="button"
data-i18n="promTokenRevoke">Revoke token</button>
</div>
<div hidden id="prom-token-secret-wrap"><label data-i18n="apiTokenCopyNow">Copy the
token now — it will not be displayed again</label>
<pre class="metrics-example" id="prom-token-secret"></pre>
</div>
<div hidden id="prom-token-secret-wrap"><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre class="metrics-example" id="prom-token-secret"></pre></div>
<label class="check"><input id="prom-trusted-no-token" type="checkbox" /><span
data-i18n="prometheusTrustedNoToken"></span></label>
<p class="fine" data-i18n="prometheusTrustedNoTokenHelp"></p>
@@ -2869,9 +2878,14 @@
type="submit">Validate, save and apply</button></div>
</form>
<article class="card edit-form" id="api-tokens-shortcut">
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
<p class="fine" data-i18n="apiTokensMovedHelp">API token management has moved to Settings → Security.</p>
<div class="form-actions"><a class="outline" id="api-tokens-security-link" href="/settings?tab=security#api-tokens-card" data-i18n="apiTokensOpenSecurity">Open API tokens in Security</a></div>
<div class="card-heading">
<h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2>
</div>
<p class="fine" data-i18n="apiTokensMovedHelp">API token management has moved to
Settings → Security.</p>
<div class="form-actions"><a class="outline" id="api-tokens-security-link"
href="/settings?tab=security#api-tokens-card"
data-i18n="apiTokensOpenSecurity">Open API tokens in Security</a></div>
</article>
</div>
<div aria-labelledby="settings-tab-system-info" class="settings-page"
@@ -3039,7 +3053,7 @@
role="tabpanel">
<article class="card edit-form">
<div class="card-heading">
<div><span class="small-label">DESIRED STATE / SQLITE</span>
<div><span class="small-label" data-i18n="configStorageLabel">CONFIGURATION SOURCE</span>
<h2 data-i18n="manualConfig">Manual config</h2>
</div>
<div class="manual-config-tools"><button class="quiet" data-i18n="configHistory"
@@ -3047,6 +3061,12 @@
class="outline" data-i18n="export" id="export-config" type="button">Export
JSON</button></div>
</div>
<div class="manual-config-source" role="status" aria-live="polite">
<strong id="config-storage-mode" data-i18n="configStorageLoading">Checking storage...</strong>
<p id="config-storage-location" class="manual-config-path"></p>
<p id="config-storage-description" class="fine"></p>
<p id="config-storage-history" class="fine"></p>
</div>
<p class="fine" data-i18n="configHelp">A valid save creates one new revision. Invalid or
stale changes leave the running configuration untouched.</p>
<label class="sr-only" for="config-editor">JSON</label><textarea id="config-editor"
@@ -3055,8 +3075,6 @@
id="save-json">Validate, save and apply</button><span class="fine"
data-i18n="noRestart">Listener changes require restart; other settings apply
live.</span></div>
<p class="fine" data-i18n="roadmapHelp">Release details and planned features are
documented in ROADMAP.md.</p>
</article>
</div>
<section aria-labelledby="settings-tab-security" class="settings-page"
@@ -3071,19 +3089,37 @@
type="button">Administration audit</button>
</div>
<div class="security-stack">
<article class="card edit-form" id="api-tokens-card" tabindex="-1">
<div class="card-heading"><h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2></div>
<p class="fine" data-i18n="generalApiTokensHelp">Generate independent API tokens with read-only or read-write access, restricted to your client IP/CIDR. Existing browser login remains available. Non-loopback requests require HTTPS.</p>
<form id="api-tokens-form">
<label><span data-i18n="apiTokenComment">Description</span><input id="api-token-comment" maxlength="128" placeholder="external-automation" /></label>
<label><span data-i18n="apiTokenPermission">Permissions</span><select id="api-token-permission"><option value="ro" data-i18n="apiTokenReadOnly">Read only (RO)</option><option value="rw" data-i18n="apiTokenReadWrite">Read and write (RW)</option></select></label>
<label><span data-i18n="apiTokenIpAllow">Allowed source IP/CIDR (one per line)</span><textarea id="api-token-cidrs" rows="3" required placeholder="192.0.2.10/32"></textarea></label>
<div class="form-actions"><button class="primary" type="submit" data-i18n="apiTokenCreate">Generate API token</button></div>
</form>
<div id="api-token-secret-wrap" hidden><label data-i18n="apiTokenCopyNow">Copy the token now — it will not be displayed again</label><pre id="api-token-secret" class="metrics-example"></pre></div>
<div id="api-token-list" class="certificate-api-credentials"></div>
<pre class="metrics-example">curl -H 'Authorization: Bearer $RUSTPROXY_API_TOKEN' https://panel.example.com/api/config</pre>
</article>
<article class="card edit-form" id="api-tokens-card" tabindex="-1">
<div class="card-heading">
<h2 data-i18n="generalApiTokensTitle">API tokens (RO/RW)</h2>
</div>
<p class="fine" data-i18n="generalApiTokensHelp">Generate independent API tokens
with read-only or read-write access, restricted to your client IP/CIDR. Existing
browser login remains available. Non-loopback requests require HTTPS.</p>
<form id="api-tokens-form">
<label><span data-i18n="apiTokenComment">Description</span><input
id="api-token-comment" maxlength="128"
placeholder="external-automation" /></label>
<label><span data-i18n="apiTokenPermission">Permissions</span><select
id="api-token-permission">
<option value="ro" data-i18n="apiTokenReadOnly">Read only (RO)</option>
<option value="rw" data-i18n="apiTokenReadWrite">Read and write (RW)
</option>
</select></label>
<label><span data-i18n="apiTokenIpAllow">Allowed source IP/CIDR (one per
line)</span><textarea id="api-token-cidrs" rows="3" required
placeholder="192.0.2.10/32"></textarea></label>
<div class="form-actions"><button class="primary" type="submit"
data-i18n="apiTokenCreate">Generate API token</button></div>
</form>
<div id="api-token-secret-wrap" hidden><label data-i18n="apiTokenCopyNow">Copy the
token now — it will not be displayed again</label>
<pre id="api-token-secret" class="metrics-example"></pre>
</div>
<div id="api-token-list" class="certificate-api-credentials"></div>
<pre
class="metrics-example">curl -H 'Authorization: Bearer $RUSTPROXY_API_TOKEN' https://panel.example.com/api/config</pre>
</article>
<article class="card security-account-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="currentSession">CURRENT SESSION</span>
+17
View File
@@ -1033,6 +1033,23 @@ button.is-saving {
margin-top: 20px
}
.manual-config-source {
padding: 12px 16px;
margin-top: 14px;
border: 1px solid var(--line);
border-radius: var(--radius, 8px);
overflow-wrap: anywhere;
}
.manual-config-source p {
margin: 6px 0 0;
}
.manual-config-path {
font: 12px/1.6 var(--mono);
user-select: text;
}
.page-footer {
display: flex;
justify-content: space-between;
+6 -1
View File
@@ -137,7 +137,12 @@ impl Limits {
}
}
fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
let mut response = (status, "RustProxy: authentication required\n").into_response();
let body = format!(
"{} {}\n",
status.as_u16(),
status.canonical_reason().unwrap_or("Error")
);
let mut response = (status, body).into_response();
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("private, no-store"),
-4
View File
@@ -39,10 +39,6 @@ impl PeerFormatting {
untrusted_headers: std::sync::Mutex::new(None),
}
}
fn peer_header(&self) -> &HeaderValue {
&self.peer_header
}
fn cached_untrusted(&self, authority: &HeaderValue, tls: bool) -> Option<HeaderMap> {
self.untrusted_headers
.lock()
+128 -54
View File
@@ -19,7 +19,11 @@ mod tests {
for (peer, local, networks) in [
("192.0.2.51:32000", "127.0.0.1:8080", Vec::<String>::new()),
("[2001:db8::8]:32100", "[2001:db8::1]:8443", Vec::new()),
("10.2.3.4:4123", "10.0.0.1:8443", vec!["10.0.0.0/8".to_owned()]),
(
"10.2.3.4:4123",
"10.0.0.1:8443",
vec!["10.0.0.0/8".to_owned()],
),
] {
let peer: SocketAddr = peer.parse().unwrap();
let local: SocketAddr = local.parse().unwrap();
@@ -27,25 +31,38 @@ mod tests {
let compiled = Settings {
trusted_proxies: networks,
trust_forwarded_proto: true,
}.compile().unwrap();
}
.compile()
.unwrap();
let mut incoming = HeaderMap::new();
incoming.insert("x-forwarded-for", "198.51.100.8, 10.3.2.1".parse().unwrap());
incoming.insert("x-forwarded-proto", "https".parse().unwrap());
let authority = HeaderValue::from_static("app.example:8443");
let uri: Uri = "/some%20file?q=4".parse().unwrap();
let plain = compiled.context_with_original_url(
&incoming, peer, local, true, &authority, &uri, false
).unwrap();
let cached = compiled.context_with_peer(
&incoming, peer, local, true, &authority, &uri, false,
Some(&formatter), true,
).unwrap();
let plain = compiled
.context_with_original_url(&incoming, peer, local, true, &authority, &uri, false)
.unwrap();
let cached = compiled
.context_with_peer(
&incoming,
peer,
local,
true,
&authority,
&uri,
false,
Some(&formatter),
true,
)
.unwrap();
assert_eq!(cached.client_ip, plain.client_ip);
assert_eq!(cached.scheme, plain.scheme);
assert_eq!(cached.original_url, plain.original_url);
assert_eq!(cached.values, plain.values);
assert_eq!(formatter.peer_header().to_str().unwrap(),
crate::vhost::canonical_ip(peer.ip()).to_string());
assert_eq!(
formatter.peer_header.to_str().unwrap(),
crate::vhost::canonical_ip(peer.ip()).to_string()
);
for include_ip in [false, true] {
let mut a = incoming.clone();
let mut b = incoming.clone();
@@ -87,13 +104,30 @@ mod tests {
let host = HeaderValue::from_str(host).unwrap();
let uri: Uri = path.parse().unwrap();
for original_url in [false, true] {
let direct = compiled.context_with_original_url(
&incoming, peer, local, tls, &host, &uri, original_url,
).unwrap();
let cached = compiled.context_with_peer(
&incoming, peer, local, tls, &host, &uri, original_url,
Some(&formatter), false,
).unwrap();
let direct = compiled
.context_with_original_url(
&incoming,
peer,
local,
tls,
&host,
&uri,
original_url,
)
.unwrap();
let cached = compiled
.context_with_peer(
&incoming,
peer,
local,
tls,
&host,
&uri,
original_url,
Some(&formatter),
false,
)
.unwrap();
assert_eq!(cached.client_ip, direct.client_ip);
assert_eq!(cached.scheme, direct.scheme);
assert_eq!(cached.original_url, direct.original_url);
@@ -111,19 +145,30 @@ mod tests {
let trusted = Settings {
trusted_proxies: vec!["192.0.2.0/24".to_owned()],
trust_forwarded_proto: true,
}.compile().unwrap();
}
.compile()
.unwrap();
let mut forwarded = HeaderMap::new();
forwarded.insert("x-forwarded-for", "198.51.100.9".parse().unwrap());
forwarded.insert("x-forwarded-proto", "https".parse().unwrap());
let host = HeaderValue::from_static("app.example");
let uri: Uri = "/trusted".parse().unwrap();
let direct = trusted.context_with_original_url(
&forwarded, peer, local, false, &host, &uri, false,
).unwrap();
let cached = trusted.context_with_peer(
&forwarded, peer, local, false, &host, &uri, false,
Some(&formatter), false,
).unwrap();
let direct = trusted
.context_with_original_url(&forwarded, peer, local, false, &host, &uri, false)
.unwrap();
let cached = trusted
.context_with_peer(
&forwarded,
peer,
local,
false,
&host,
&uri,
false,
Some(&formatter),
false,
)
.unwrap();
assert_eq!(cached.client_ip, "198.51.100.9".parse::<IpAddr>().unwrap());
assert_eq!(cached.scheme, "https");
assert_eq!(cached.values, direct.values);
@@ -136,12 +181,19 @@ mod tests {
let compiled = Settings::default().compile().unwrap();
let peer: SocketAddr = "192.0.2.9:4000".parse().unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let value = compiled.context_with_peer(
&HeaderMap::new(), peer, local, false,
&HeaderValue::from_static("app.example"),
&"/".parse().unwrap(), false,
Some(&PeerFormatting::new(peer, local)), false,
).unwrap();
let value = compiled
.context_with_peer(
&HeaderMap::new(),
peer,
local,
false,
&HeaderValue::from_static("app.example"),
&"/".parse().unwrap(),
false,
Some(&PeerFormatting::new(peer, local)),
false,
)
.unwrap();
let mut headers = HeaderMap::new();
for (name, val) in [
("forwarded", "for=203.0.113.1"),
@@ -173,10 +225,19 @@ mod tests {
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let authority = HeaderValue::from_static("app.example");
let uri: Uri = "/safe".parse().unwrap();
let value = compiled.context_with_peer(
&HeaderMap::new(), actual, local, false,
&authority, &uri, false, Some(&different), false,
).unwrap();
let value = compiled
.context_with_peer(
&HeaderMap::new(),
actual,
local,
false,
&authority,
&uri,
false,
Some(&different),
false,
)
.unwrap();
assert_eq!(value.client_ip, actual.ip());
assert_eq!(value.values["x-forwarded-for"], "198.51.100.6");
}
@@ -373,23 +434,29 @@ mod tests {
let source = Settings {
trusted_proxies: vec!["10.0.0.0/8".into()],
trust_forwarded_proto: true,
}.compile().unwrap();
}
.compile()
.unwrap();
let mut supplied = HeaderMap::new();
supplied.insert("forwarded", "for=203.0.113.8".parse().unwrap());
supplied.insert("x-forwarded-proto", "http".parse().unwrap());
let context = source.context(
&supplied,
"[2001:db8::5]:5050".parse().unwrap(),
"[2001:db8::10]:8080".parse().unwrap(),
true,
&"app.example:443".parse().unwrap(),
&"/s?q=1".parse().unwrap(),
).unwrap();
let context = source
.context(
&supplied,
"[2001:db8::5]:5050".parse().unwrap(),
"[2001:db8::10]:8080".parse().unwrap(),
true,
&"app.example:443".parse().unwrap(),
&"/s?q=1".parse().unwrap(),
)
.unwrap();
assert_eq!(context.client_ip, "2001:db8::5".parse::<IpAddr>().unwrap());
assert_eq!(context.scheme, "https");
assert_eq!(context.original_url, "https://app.example:443/s?q=1");
assert_eq!(context.values["forwarded"],
"for=\"[2001:db8::5]\";by=\"[2001:db8::10]\";proto=https;host=\"app.example:443\"");
assert_eq!(
context.values["forwarded"],
"for=\"[2001:db8::5]\";by=\"[2001:db8::10]\";proto=https;host=\"app.example:443\""
);
assert_eq!(context.values["x-forwarded-for"], "2001:db8::5");
assert_eq!(context.values["x-real-ip"], "2001:db8::5");
assert_eq!(context.values["x-forwarded-proto"], "https");
@@ -404,9 +471,15 @@ mod tests {
(false, "[2001:db8::a]:5010", "[2001:db8::b]:8080"),
] {
let settings = Settings {
trusted_proxies: if trusted { vec!["10.0.0.0/8".into()] } else { vec![] },
trusted_proxies: if trusted {
vec!["10.0.0.0/8".into()]
} else {
vec![]
},
trust_forwarded_proto: true,
}.compile().unwrap();
}
.compile()
.unwrap();
let mut incoming = HeaderMap::new();
incoming.insert("x-forwarded-for", "192.0.2.15".parse().unwrap());
incoming.insert("x-forwarded-proto", "http".parse().unwrap());
@@ -414,10 +487,12 @@ mod tests {
let local: SocketAddr = local.parse().unwrap();
let host: HeaderValue = "app.example:8443".parse().unwrap();
let uri: Uri = "/route?q=hello".parse().unwrap();
let full = settings.context(&incoming, peer, local, true, &host, &uri).unwrap();
let compact = settings.context_with_original_url(
&incoming, peer, local, true, &host, &uri, false,
).unwrap();
let full = settings
.context(&incoming, peer, local, true, &host, &uri)
.unwrap();
let compact = settings
.context_with_original_url(&incoming, peer, local, true, &host, &uri, false)
.unwrap();
assert!(!full.original_url.is_empty());
assert!(compact.original_url.is_empty());
assert_eq!(full.client_ip, compact.client_ip);
@@ -430,5 +505,4 @@ mod tests {
assert_eq!(full_headers, compact_headers);
}
}
}
+30 -10
View File
@@ -96,7 +96,12 @@ fn enforce_local_page_no_cache(response: &mut Response) {
.insert("cache-control", HeaderValue::from_static("no-store"));
}
fn error(status: StatusCode, message: &'static str) -> Response {
fn error(status: StatusCode, detail: &'static str) -> Response {
tracing::debug!(
status = status.as_u16(),
detail = detail.trim_end(),
"proxy generated error"
);
let mut response = if let Some(html) = crate::pages::builtin_gateway_error(status) {
let mut response = (status, html).into_response();
response.headers_mut().insert(
@@ -109,11 +114,22 @@ fn error(status: StatusCode, message: &'static str) -> Response {
);
response
} else {
(status, message).into_response()
// For statuses without a built-in error page (405, 421, 501, ...),
// emit the HTTP status instead of implementation-specific diagnostics.
let body = format!(
"{} {}\n",
status.as_u16(),
status.canonical_reason().unwrap_or("Error")
);
(status, body).into_response()
};
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
response.extensions_mut().insert(LocalGeneratedResponse);
response
}
@@ -154,10 +170,7 @@ fn encoded_origin_path(path: &str) -> Option<String> {
url.set_path(path);
Some(url.path().to_owned())
}
fn response_context(
snapshot: &crate::config::Snapshot,
request: &Request,
) -> SelectedRouteContext {
fn response_context(snapshot: &crate::config::Snapshot, request: &Request) -> SelectedRouteContext {
let tls = request
.extensions()
.get::<Transport>()
@@ -171,7 +184,9 @@ fn response_context(
let frontend = snapshot.frontend(&host, local, tls).cloned();
let path = crate::acl::routing_path_view(request.uri().path()).ok();
let route = path.as_ref().and_then(|path| {
snapshot.select_on_arc(&host, path.as_ref(), local, tls).cloned()
snapshot
.select_on_arc(&host, path.as_ref(), local, tls)
.cloned()
});
// An ordinary path borrows the original URI in forward_request. Only
// a decoded path needs to be moved between middleware and forwarding.
@@ -368,8 +383,11 @@ async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
for _ in 0..8 {
let next = if bounded_h3_navigation {
match tokio::time::timeout(
std::time::Duration::from_millis(35), request.body_mut().frame(),
).await {
std::time::Duration::from_millis(35),
request.body_mut().frame(),
)
.await
{
Ok(frame) => frame,
Err(_) => return Ok(false),
}
@@ -437,7 +455,9 @@ async fn normalize_h3_h1_unknown_body(
match tokio::time::timeout(
std::time::Duration::from_millis(35),
request.body_mut().frame(),
).await {
)
.await
{
Ok(frame) => frame,
Err(_) => return Ok(()),
}
+28
View File
@@ -713,3 +713,31 @@ mod graceful_h2_tests {
assert_eq!(backend.origin, "https://127.0.0.1:9000");
}
}
#[tokio::test]
async fn proxy_generated_error_responses_use_generic_statuses() {
use http_body_util::BodyExt;
let unsupported = error(
StatusCode::NOT_IMPLEMENTED,
"RustProxy: internal CONNECT implementation detail\n",
);
assert_eq!(unsupported.status(), StatusCode::NOT_IMPLEMENTED);
assert_eq!(unsupported.headers()["cache-control"], "no-store");
assert_eq!(
unsupported.into_body().collect().await.unwrap().to_bytes(),
"501 Not Implemented\n"
);
let not_found = error(
StatusCode::NOT_FOUND,
"RustProxy: internal routing detail\n",
);
assert_eq!(not_found.status(), StatusCode::NOT_FOUND);
assert_eq!(
not_found.headers()["content-type"],
"text/html; charset=utf-8"
);
let bytes = not_found.into_body().collect().await.unwrap().to_bytes();
let page = std::str::from_utf8(&bytes).unwrap();
assert!(page.contains("404"));
assert!(!page.contains("RustProxy: internal"));
}
+6 -5
View File
@@ -210,11 +210,12 @@ impl Settings {
}
}
fn failure(status: StatusCode) -> Response {
let mut response = (
status,
"RustProxy: authentication required or authentication service unavailable\n",
)
.into_response();
let body = format!(
"{} {}\n",
status.as_u16(),
status.canonical_reason().unwrap_or("Error")
);
let mut response = (status, body).into_response();
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("private, no-store"),
+52 -25
View File
@@ -1,15 +1,14 @@
use super::{
config::CompiledCacheMatcher,
filesystem::{DirectoryEntry, Opened},
htaccess,
CompiledService,
htaccess, CompiledService,
};
use axum::{body::Body, response::Response};
use http::{
header::{
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING,
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH,
IF_RANGE, LAST_MODIFIED, LOCATION, RANGE, VARY,
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE,
LAST_MODIFIED, LOCATION, RANGE, VARY,
},
HeaderMap, HeaderValue, Method, StatusCode, Uri,
};
@@ -21,13 +20,37 @@ use std::{
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;
pub(super) fn status_response(status: StatusCode, message: &'static str) -> Response {
let mut response = Response::new(Body::from(message));
pub(super) fn status_response(status: StatusCode, detail: &'static str) -> Response {
// Keep internal diagnostics in server logs, not in public HTTP bodies.
if status.is_server_error() {
tracing::warn!(
status = status.as_u16(),
detail = detail.trim_end(),
"Web Server error"
);
}
let message = format!(
"{} {}\n",
status.as_u16(),
status.canonical_reason().unwrap_or("Error")
);
let mut response = Response::new(Body::from(message.clone()));
*response.status_mut() = status;
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response.headers_mut().insert(
CONTENT_LENGTH,
HeaderValue::from_str(&message.len().to_string()).unwrap(),
);
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
response
}
@@ -60,15 +83,20 @@ pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Res
pub(super) fn io_response(error: &io::Error) -> Response {
#[cfg(unix)]
if matches!(error.raw_os_error(), Some(libc::ELOOP)) {
return status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n");
return status_response(
StatusCode::FORBIDDEN,
"RustProxy: Web Server path is not allowed\n",
);
}
match error.kind() {
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => {
status_response(StatusCode::NOT_FOUND, "RustProxy: Web Server resource not found\n")
}
io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => {
status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n")
}
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => status_response(
StatusCode::NOT_FOUND,
"RustProxy: Web Server resource not found\n",
),
io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => status_response(
StatusCode::FORBIDDEN,
"RustProxy: Web Server path is not allowed\n",
),
_ => status_response(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: Web Server filesystem error\n",
@@ -172,10 +200,7 @@ fn cache_control_for(
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let mime = content_type
.to_str()
.unwrap_or("")
.to_ascii_lowercase();
let mime = content_type.to_str().unwrap_or("").to_ascii_lowercase();
for rule in &service.cache_rules {
let matches = match &rule.matcher {
CompiledCacheMatcher::Extension(value) => extension == *value,
@@ -216,8 +241,14 @@ fn add_download_header(response: &mut Response, service: &CompiledService, path:
if !download_extension(service, path) {
return;
}
let filename = path.file_name().and_then(|value| value.to_str()).unwrap_or("download");
let value = format!("attachment; filename*=UTF-8''{}", percent_encode_segment(filename));
let filename = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("download");
let value = format!(
"attachment; filename*=UTF-8''{}",
percent_encode_segment(filename)
);
if let Ok(value) = HeaderValue::from_str(&value) {
response.headers_mut().insert(CONTENT_DISPOSITION, value);
}
@@ -372,11 +403,7 @@ fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option<Sys
.is_some_and(|date| modified <= date)
}
fn add_validators(
response: &mut Response,
etag: Option<&str>,
modified: Option<SystemTime>,
) {
fn add_validators(response: &mut Response, etag: Option<&str>, modified: Option<SystemTime>) {
if let Some(etag) = etag.and_then(|value| HeaderValue::from_str(value).ok()) {
response.headers_mut().insert(ETAG, etag);
}
+18 -14
View File
@@ -347,20 +347,19 @@ async fn serve_inner(
}
let root = FilesystemRoot::for_service(service);
let components_for_list = components.clone();
let mut entries = match tokio::task::spawn_blocking(move || {
list_directory_sync(&root, &components_for_list)
})
.await
{
Ok(Ok(entries)) => entries,
Ok(Err(error)) => return io_response(&error),
Err(_) => {
return status_response(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: Web Server directory task failed\n",
)
}
};
let mut entries =
match tokio::task::spawn_blocking(move || list_directory_sync(&root, &components_for_list))
.await
{
Ok(Ok(entries)) => entries,
Ok(Err(error)) => return io_response(&error),
Err(_) => {
return status_response(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: Web Server directory task failed\n",
)
}
};
// Filtering in place avoids a second Vec allocation for large directories
// while retaining exactly the same visibility and hardening checks.
entries.retain(|entry| {
@@ -419,6 +418,11 @@ pub async fn serve(
crate::web_server::metrics::HeadersInFlight::new(&service.web_metrics.in_flight_headers);
let request_method = method.clone();
let mut response = serve_inner(service, method, headers, uri, path, body, context).await;
// HEAD carries the same metadata/status as GET, but no response body,
// including the generic filesystem and rewrite error paths.
if request_method == Method::HEAD {
*response.body_mut() = Body::empty();
}
add_service_headers(&mut response, service);
htaccess::apply_response(service.htaccess.as_ref(), &mut response, path);
strip_unnecessary_headers(&mut response, service);
+114 -12
View File
@@ -118,6 +118,28 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
assert_eq!(missing.headers()[CACHE_CONTROL], "no-store");
assert_eq!(missing.headers()[CONTENT_TYPE], "text/plain; charset=utf-8");
assert_eq!(
missing.into_body().collect().await.unwrap().to_bytes(),
"404 Not Found\n"
);
let missing_head = serve_request(
&service,
&request(Method::HEAD, "/missing.txt"),
"/missing.txt",
)
.await;
assert_eq!(missing_head.status(), StatusCode::NOT_FOUND);
assert_eq!(missing_head.headers()[CONTENT_LENGTH], "14");
assert!(missing_head
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await;
assert_eq!(directory.status(), StatusCode::FORBIDDEN);
@@ -149,6 +171,40 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
.await;
assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(method.headers()[ALLOW], "GET, HEAD");
assert_eq!(
method.into_body().collect().await.unwrap().to_bytes(),
"405 Method Not Allowed\n"
);
}
#[tokio::test]
async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses() {
use std::io::ErrorKind;
for (kind, expected_status, expected_text) in [
(
ErrorKind::NotFound,
StatusCode::NOT_FOUND,
"404 Not Found\n",
),
(
ErrorKind::PermissionDenied,
StatusCode::FORBIDDEN,
"403 Forbidden\n",
),
(
ErrorKind::Other,
StatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error\n",
),
] {
let error = std::io::Error::from(kind);
let response = super::response::io_response(&error);
assert_eq!(response.status(), expected_status);
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), expected_text.as_bytes());
assert!(!body.starts_with(b"RustProxy:"));
}
}
#[tokio::test]
@@ -2238,41 +2294,87 @@ async fn sendfile_auto_without_tcp_bridge_preserves_full_head_range_and_validato
document_root: tmp.path().to_path_buf(),
sendfile: SendfileMode::Auto,
..Default::default()
}.compile().unwrap();
}
.compile()
.unwrap();
let get = serve_request(&service, &request(Method::GET, "/large.bin"), "/large.bin").await;
assert_eq!(get.status(), StatusCode::OK);
assert_eq!(get.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string());
assert_eq!(
get.headers()[CONTENT_LENGTH].to_str().unwrap(),
original.len().to_string()
);
// No transport adapter here. Even if a candidate extension was produced,
// its fallback Body must still stream the exact original representation.
let etag = get.headers()[ETAG].clone();
assert_eq!(get.into_body().collect().await.unwrap().to_bytes().to_vec(), original);
assert_eq!(
get.into_body().collect().await.unwrap().to_bytes().to_vec(),
original
);
let head = serve_request(&service, &request(Method::HEAD, "/large.bin"), "/large.bin").await;
assert_eq!(head.status(), StatusCode::OK);
assert_eq!(head.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string());
assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty());
assert_eq!(
head.headers()[CONTENT_LENGTH].to_str().unwrap(),
original.len().to_string()
);
assert!(head
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
let mut range = request(Method::GET, "/large.bin");
range.headers_mut().insert(RANGE, HeaderValue::from_static("bytes=100-199"));
range
.headers_mut()
.insert(RANGE, HeaderValue::from_static("bytes=100-199"));
let ranged = serve_request(&service, &range, "/large.bin").await;
assert_eq!(ranged.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(ranged.headers()[CONTENT_RANGE].to_str().unwrap(), format!("bytes 100-199/{}", original.len()));
assert_eq!(ranged.into_body().collect().await.unwrap().to_bytes().to_vec(), original[100..200]);
assert_eq!(
ranged.headers()[CONTENT_RANGE].to_str().unwrap(),
format!("bytes 100-199/{}", original.len())
);
assert_eq!(
ranged
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
original[100..200]
);
let mut conditional = request(Method::GET, "/large.bin");
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
let unchanged = serve_request(&service, &conditional, "/large.bin").await;
assert_eq!(unchanged.status(), StatusCode::NOT_MODIFIED);
assert!(unchanged.into_body().collect().await.unwrap().to_bytes().is_empty());
assert!(unchanged
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
}
#[test]
fn sendfile_mode_is_backward_compatible_and_rejects_unknown_values() {
assert_eq!(SendfileMode::default(), SendfileMode::Off);
assert_eq!(serde_json::from_str::<SendfileMode>("\"auto\"").unwrap(), SendfileMode::Auto);
assert_eq!(serde_json::from_str::<SendfileMode>("\"off\"").unwrap(), SendfileMode::Off);
assert_eq!(
serde_json::from_str::<SendfileMode>("\"auto\"").unwrap(),
SendfileMode::Auto
);
assert_eq!(
serde_json::from_str::<SendfileMode>("\"off\"").unwrap(),
SendfileMode::Off
);
assert!(serde_json::from_str::<SendfileMode>("\"unsafe\"").is_err());
let legacy = serde_json::json!({"id":"legacy", "name":"Legacy", "document_root":"/tmp"});
assert_eq!(serde_json::from_value::<Service>(legacy).unwrap().sendfile, SendfileMode::Off);
assert_eq!(
serde_json::from_value::<Service>(legacy).unwrap().sendfile,
SendfileMode::Off
);
}