v0.29.2
This commit is contained in:
1 parent
76cb4acef0
commit
68678e98be
84 files changed
+1494
-300
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
|
||||
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
|
||||
compatibility. Current source version: 0.29.0.
|
||||
compatibility. Current source version: 0.29.2.
|
||||
|
||||
## Delivery
|
||||
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
> test-only changes and packaging implementation notes were moved to
|
||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||
|
||||
## 0.29.2
|
||||
|
||||
- Debian install/update now deploys managed logrotate policy (14 daily rotations, 100 MB max, compressed, copytruncate) and repairs the shell PATH profile on upgrade.
|
||||
|
||||
- Fix Web Server vhosts returning 503 because backend `transient_unavailable` was applied to the main filesystem/PHP route. The main Web Server source does not consult or compile unused backends; enabled reverse-proxy Custom Locations still use the configured backend pool.
|
||||
- Remove the three error-fallback Prometheus metrics without changing internal fallback routing.
|
||||
|
||||
- Add validated, operator-managed `PHP_ADMIN_VALUE` support for shared trusted PHP-FPM endpoints.
|
||||
- Expose service-scoped PHP response counts, response-header latency and concurrency gauges in Prometheus, alongside Web Server status and latency counters.
|
||||
- Keep FastCGI reserved parameter protections and PHP `PATH_INFO` support.
|
||||
|
||||
## 0.29.1
|
||||
|
||||
- Add Nginx-style internal error fallback routing for vhost sources and Custom locations, including internal-only named locations, configurable 403/404/500/502/503/504 interception, original request target/context preservation, validation-time cycle rejection and a runtime depth guard.
|
||||
- Intercept configured upstream/local errors before finalization or cache storage, then process fallback targets through normal ACL, header, compression, cache and response handling.
|
||||
- Add streaming multipart `Range` support with at most 16 byte ranges per request while retaining existing single-range behavior.
|
||||
- Add optional PHP-FPM `PATH_INFO` support that securely separates an existing script path from trailing path information.
|
||||
- Keep SQLite schema 9, saved-configuration compatibility and Cold Cache format 3 unchanged.
|
||||
|
||||
## 0.29.0
|
||||
- Group vhost hostnames and WWW redirect controls in Frontends. Each exact domain shows both redirect directions as selectable source → destination cards, even while redirects are off. Implicit `www.` targets are supported without adding an extra hostname. Other aliases continue serving the same application without cross-domain redirects; wildcard targets are excluded.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1688,7 +1688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.29.0"
|
||||
version = "0.29.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.29.0"
|
||||
version = "0.29.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# RustProxy 0.29.0
|
||||
# RustProxy 0.29.2
|
||||
|
||||
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration,
|
||||
streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
|
||||
|
||||
**0.29.0 release source.**
|
||||
**0.29.2 release source.**
|
||||
|
||||
## Layer-4 streams
|
||||
|
||||
@@ -11,17 +11,15 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a
|
||||
|
||||
Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
|
||||
|
||||
## What changed in 0.29.0
|
||||
## What changed in 0.29.2
|
||||
|
||||
- Add a per-vhost **www/non-www redirect** switch. The Frontends tab groups hostname chips, www controls and direction cards. For each exact hostname, the operator can select either www to bare or bare to www, even if the target variant was not explicitly added to the vhost; the runtime owns both names. Options stay visible while disabled for preview. Redirect `308` keeps path/query, Force SSL collapses into one hop, and other hostnames such as `suba.local` and `subb.local` keep serving the same application without cross-domain redirects.
|
||||
- Add a first-class **Redirect** action to Custom locations with `301/302/303/307/308`, an absolute HTTP(S) target, optional capture rewrite regex (`$1`, `${name}`) and optional query preservation. This covers Nginx-style `location` + `rewrite ... permanent` workflows without opening a backend connection.
|
||||
- Make HTTP backend health checks automatically send the first exact vhost DNS hostname as `Host` when the health-check Host field is empty. An explicitly configured health-check Host remains the override.
|
||||
- Add an explicitly dangerous, **per-vhost-only forced-cache URL list**. Matching GET/HEAD targets can bypass request-side query/Cookie/Authorization/client-no-cache exclusions, while response-side privacy guards such as `Set-Cookie`, `private` and `no-store` remain mandatory; Cookie and Authorization are included in variant identity when this mode is used.
|
||||
- Let Stored Logs discover and read logrotate-style `.gz` rotations with strict compressed/decompressed byte limits and the same bounded tail/search behavior as active logs.
|
||||
- Retain the existing local/static page status override and cover the common maintenance-page case where HTML is served with HTTP `503 Service Unavailable`.
|
||||
- Add regression coverage for nested URL query parameters such as `/convert?url=https%3A//...&ip=...`. Literal/encoded backslashes remain rejected as ambiguous routing input; quoted curl URLs must not contain shell backslashes before `?`, `=` or `&`.
|
||||
- Add Nginx-style **internal error fallbacks** for the vhost source and Custom locations. Configured upstream/Web Server statuses can internally hand the original method, URI, query string and request context to an `internal` Custom location without an HTTP redirect. Supported bounded statuses are `403/404/500/502/503/504`; configuration cycles are rejected and runtime dispatch has a depth limit.
|
||||
- Execute error fallback before an intercepted response is finalized or cached. The target location re-enters the normal vhost/location pipeline, including ACL, headers, compression, cache policy and response processing. Request bodies are replayed only when safely bounded and replayable; otherwise the original error is retained.
|
||||
- The main Web Server source ignores backend health/unavailability; Custom Locations configured for Reverse Proxy continue to use their backend pool. Error-fallback-specific Prometheus counters have been removed.
|
||||
- Add streaming **multipart byte-range** responses for requests such as `Range: bytes=0-99,200-299`, with a maximum of 16 ranges and identity representation for multipart responses.
|
||||
- Add opt-in PHP-FPM **PATH_INFO** splitting. With `path_info: true`, `/index.php/foo/bar` securely resolves the existing `index.php` script, keeps `SCRIPT_FILENAME` on that file and sends `/foo/bar` as `PATH_INFO`.
|
||||
|
||||
0.29.0 keeps SQLite schema 9 and Cold Cache format 3. Existing 0.28.2 configuration remains compatible; new fields use safe/default-off serde defaults. Historical details are in [CHANGELOG.md](CHANGELOG.md).
|
||||
0.29.2 keeps SQLite schema 9 and Cold Cache format 3. Existing 0.29.0 configuration remains compatible; new fields use safe/default-off serde defaults. Historical details are in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
### Historical 0.26.7 release notes
|
||||
|
||||
|
||||
+22
@@ -1,5 +1,27 @@
|
||||
# RustProxy roadmap
|
||||
|
||||
## 0.29.2 - PHP-FPM operator controls and observability
|
||||
|
||||
- [x] Add bounded, operator-only PHP_ADMIN_VALUE INI overrides per Web Service.
|
||||
- [x] Show admin overrides in the Web Service UI/API, reusing existing location-to-service routing.
|
||||
- [x] Track PHP responses by status class, response-header latency and free concurrency slots in Prometheus.
|
||||
- [ ] Verify FastCGI integration and full Rust test suite on a host with a compiler and PHP-FPM.
|
||||
- [ ] Consider per-location PHP override profiles and PHP-FPM connect/write/read phase metrics.
|
||||
|
||||
## 0.29.1 - Internal error routing, multipart ranges and PHP PATH_INFO
|
||||
|
||||
Current direction: 0.29.0 -> 0.29.1
|
||||
|
||||
- [x] Add configurable internal error fallbacks for the main vhost source and Custom locations.
|
||||
- [x] Preserve original method, URI, query string and request context during internal fallback dispatch.
|
||||
- [x] Reject fallback cycles at validation time and cap runtime fallback depth.
|
||||
- [x] Intercept configured errors before final response/cache handling and run the target through normal route processing.
|
||||
- [x] Add bounded Prometheus counters for triggered, successful and failed error fallbacks.
|
||||
- [x] Support fixed fallback status codes 403/404/500/502/503/504.
|
||||
- [x] Add streaming multipart byte-range responses with a bounded range count.
|
||||
- [x] Add opt-in secure PHP PATH_INFO splitting for PHP-FPM.
|
||||
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
|
||||
|
||||
## 0.29.0 - Redirect, health-host, forced-cache and rotated-log controls
|
||||
|
||||
Current direction: 0.28.2 -> 0.29.0
|
||||
|
||||
+83
-75
@@ -1,18 +1,18 @@
|
||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
ee028ed38129f0e76a2b818066428ad74a3d3f1ee61b0b25ea36e77b65f4f0a8 AGENTS.md
|
||||
36736e8d99e58e9bbc43f84d81a15df1a2794d483e11f218c21d9429f135e7c3 CHANGELOG.md
|
||||
1116b5deea3ec4a2a46692b3a4eb2a5bfc31081dd1bdab96b6244c6a6e22c933 Cargo.lock
|
||||
2d9dc00622ab42c7fb29f324528a5b190e906a37c5fe8d0fdd142fc35589972c Cargo.toml
|
||||
6168eae7ed217707898df7041be628b3d24b7dfa51f4aa5b3c5d7f7238e33c37 AGENTS.md
|
||||
db6548b334df4476717cc4fbae4d61b4e20ca05733cb66f56f06e68d2306cf61 CHANGELOG.md
|
||||
d81f6b20d330b8b67202f9736b5dc8f6314522c3f013895065452bfbb49809fa Cargo.lock
|
||||
005ba4b3bd3bf5555b74448065a7a6012d7b944534a8bca04c1658031d978bbe Cargo.toml
|
||||
75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile
|
||||
02954764624debfb8f630bd14060194469ffe84a571c4b2107ef09144c8c6829 README.md
|
||||
ac958a06396e1b8af0c46e903e1eb23f38706fa694aac380654abeed9299dbad ROADMAP.md
|
||||
46005f2fb18f4b737a9db696ad9e68175a53e006a7bac590a47bee9658d010f2 README.md
|
||||
e63a65964f5c8bb244afafcaaed4dcce2d8d9e161cfb0730f399c4e786988ab5 ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
|
||||
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
|
||||
5f950935ca5098a1b6ba500cee86c8f06b8827f7cb30b9294548efd53a92a031 dev.sh
|
||||
1d63a1e957413e7c47bd51fcf9a75b89c4f7c92aff96c606f5111d4c1a766870 dev.sh
|
||||
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
||||
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
||||
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
||||
@@ -21,25 +21,25 @@ bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/d
|
||||
9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh
|
||||
b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go
|
||||
3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go
|
||||
dc716943644f98db80213efdb3409d0c8c0a180298eefc7de924777606a09ef8 docker-agent/model.go
|
||||
ee4f99b6256713b346453789c78a40dd8eea267fc3179d72e2a3c834476b9749 docker-agent/model.go
|
||||
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
|
||||
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
|
||||
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
|
||||
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
|
||||
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
|
||||
67a1e0cbe5d1139251852401d876273ec7ea8cd1c595ecd63af097921b0d21b3 docs/API.md
|
||||
464d514c54f9dd1efd7c1b7a0de9cf1dc91ff26bebb79432f177ca686e0a0113 docs/API_ACCESS.md
|
||||
f054206a09bed81018708608cdeecbfd09018ee7cec9d3abc038f8a37d47b815 docs/API_ACCESS.md
|
||||
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
|
||||
57471965bbbb8bebfe675a36ac9e7b8bf8fb56d7d60537c8724fd5d42a0ffe94 docs/CACHE.md
|
||||
6266163e17d2921468abdae92c1620266af8351321530e2c9b7dca6f3dd89c88 docs/CACHE_DIAGNOSTICS.md
|
||||
c6003dcbc0b95e2cfa9523b7017186cc8b4826248b588af77111ac5eb15174f9 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
|
||||
3bf0f1d06fa0a13f6733aa6c3f1b7d269d7a95e81138c5db6df6fb49f5beb13e docs/CONTAINER.md
|
||||
6794d1ca3e06a5c04c3e514f4d23f7de917f9f5429ff659fc90dbbae68abccc6 docs/CUSTOM_LOCATIONS.md
|
||||
c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md
|
||||
7a88824e1d84383ebf69fbef992d6d6c9975902a9170502eac800bf38fd97712 docs/CUSTOM_LOCATIONS.md
|
||||
16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md
|
||||
9ea8db4d034ecb573976dd323da29f044538525c230df9f43a47be1ac867b24c docs/ERROR_LOGGING.md
|
||||
c23b9e7d6e4df4134aa39a697d67ec22948133ecce1d4a36774d2a2a344aae75 docs/FILE_LOGS.md
|
||||
7dacf2129f2575823d26104ce12fd48a951906c8b2cfd40a7981b8e14813b154 docs/FILE_LOGS.md
|
||||
8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md
|
||||
ed17ee90719ff6211ce8974a00c39d1c299f7d58b538dbd4f2024b3ab47d05f2 docs/INSTALL.md
|
||||
a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md
|
||||
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
|
||||
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
|
||||
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
|
||||
@@ -48,9 +48,9 @@ fea1ff4055d0930edeffb0c8501a6ba1ac55ec7a7064dcd0e300a43915e8432e docs/MIGRATION
|
||||
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
|
||||
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
|
||||
89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md
|
||||
712cb1cbf84515384b081fd2929e86e40af7c10f65bc69919b78b7b3e2fd12b5 docs/PHP_FPM.md
|
||||
c6be860cad76833f7bda35f371e335a3304893a50b839953329fed3fb96a8af8 docs/PHP_FPM.md
|
||||
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
|
||||
c2a1f118c07c59fb25f0f0e13015fcb9b65bd04ae1570df06ec06ba591a73f4c docs/README.md
|
||||
7046079140db7c152dc9caa536259d3c175c8893bda3a23d6ca4e4acb8d0b4c0 docs/README.md
|
||||
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
|
||||
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
|
||||
d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
|
||||
@@ -60,7 +60,7 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H
|
||||
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
||||
d5c5c59f6e63b8d83dad5d159657c4d4039dfeb4cc1d8af210169b576adbf7e2 docs/SETTINGS.md
|
||||
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
|
||||
d03f3ec73a8a58d830712f3adaf9de61f11b93f3aa735f6584b07e724c0e0440 docs/TEST_REPORT.md
|
||||
bbf21108ebf9e9b73f346383cae2cf33807ad250a1e1086d273e8b997e173d78 docs/TEST_REPORT.md
|
||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||
8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md
|
||||
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
|
||||
@@ -103,9 +103,10 @@ efd5935852d4cfd09758bf67c70e437490cfce2baade46c8733201e506dbcc42 docs/V0.27.0_I
|
||||
8e1b45415a2bffcf26fe113bbc25627daf5d8758eb9e44c38abb060a4a402a46 docs/V0.28.1_IMPLEMENTATION.md
|
||||
097a4ff47634e5b09180012135b49a8ed16193fadc36113cad0c8dffb9e47cca docs/V0.28.2_IMPLEMENTATION.md
|
||||
bfe9b83d2292bae1e579c1841a396173258496063dbd0f3953780dedeebc2c40 docs/V0.29.0_IMPLEMENTATION.md
|
||||
7fe75e824160570c7ebd3b04e92d3493d74bfda64732c1dacfff59d90ed74398 docs/VHOSTS.md
|
||||
1bf34805a56508bb7e60745be8e872ebdc458f03bd0f56941af9c18d94ad87ce docs/V0.29.1_IMPLEMENTATION.md
|
||||
c7c6c237f77b8fbc9601d2fbe30216d51394a392ef1705af74d5ca79930e9d61 docs/VHOSTS.md
|
||||
09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md
|
||||
4026a4adeee70f98a96cc7aa8d8be16cda42ffb2e5890d38f1ba638134ab0675 docs/WEB_SERVER.md
|
||||
10bfd138ae4f9892f51c3ddcb72762ec57f6f8d12a1c371feff18abed4d23cb5 docs/WEB_SERVER.md
|
||||
c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md
|
||||
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
|
||||
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
|
||||
@@ -117,9 +118,9 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock-
|
||||
5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json
|
||||
d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhosts-balanced.json
|
||||
af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhosts-private.json
|
||||
84a24c85d2986f4de509e3e816880b051e4eece00a0efa47945102a0c010fb52 grafana/README.md
|
||||
f15db15a269a1c4ec9c7ff758f418a402475e4712f821599cd20e85ade2f42a8 grafana/rustproxy-overview.json
|
||||
a51d911526d2631282a358be4152ef1ad6ed4034e34f961a5290b1b90fffaf32 install-debian13.sh
|
||||
4068b79ddd898af43b85617d8de9e9f48cfee09a4fb9ed26bf5b692571a30196 grafana/README.md
|
||||
0d97fbf31f60563bb41f2ea8d798bccebc3d4c57f10bad8b7faa17296722e36b grafana/rustproxy-overview.json
|
||||
fde541cd76bc3fa2b6765822bf04baf9f9ea3e4b6e185e76ff467270ddbd945e install-debian13.sh
|
||||
909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh
|
||||
42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md
|
||||
2d4c859aae5ba357a6d30024c8487ae8f59f01b192711f13f9c2f8fee0f48b50 integrations/opnsense-acme/README.md
|
||||
@@ -131,9 +132,9 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
|
||||
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
||||
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
||||
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
|
||||
636ba7cf04e94629f04c65f30abb3e3eef4af4557d80f4edf4efad9377308589 langs/en_US.json
|
||||
000f6706dcf22d74b26f4f0aa7f04cab072fed86909a4f7754ee3b767c60fd7b langs/pl_pl.json
|
||||
1eaeed54289f6c2fc659714c258fbe2848682962ea4b1f2802a491fea0ef23ce make_zip.py
|
||||
0fc9ecf808a296768c41eb4b44ca90446e24b5728492b2fe4efc9adc3382a7ca langs/en_US.json
|
||||
3e8c87e778d696b9062f70a941160a9b60e348ee75ced5bf353e91daa16051aa langs/pl_pl.json
|
||||
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
||||
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
||||
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
||||
5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql
|
||||
@@ -150,16 +151,17 @@ ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_r
|
||||
6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues
|
||||
7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml
|
||||
2145ad17ae7a823756371ceab6bdbeb332211df93193ffb4e486f7a80c4f29ae scripts/README.md
|
||||
466d313c77b76e4756cab175f019e4b91c4685766da26c8fe698534bed2c93db scripts/build/swagger_assets.py
|
||||
187ee92c8029cdd59e89b4447cf4d170e856017a2091ceb85ae7c22dd1a79c72 scripts/deploy/deploy.py
|
||||
dc2cbeecb183308cb7ade81e2f88583fdd12df8d5f85f373424150edffb428d0 scripts/deploy/deploy_core.py
|
||||
2eec7ff7c689416135984e38a90c177a8b06bdf176da69294cce25e5571847d5 scripts/deploy/deploy_runtime.py
|
||||
83d11d9b21358c698089f0a8ca3404c6b9b5bf1988040675ac5e90edfc18ed2f scripts/deploy/upgrade.py
|
||||
2044e6d979712457d6ebc3483efa029cc6de22416ee85898d950300015bd815d scripts/build/swagger_assets.py
|
||||
e4d15aa6aa864d04b9b30aca33826b833632af02ca643f8b23e219a5ea1298dd scripts/deploy/deploy.py
|
||||
cc268d9d3dd125c9cbff2522fe3137a561fb4fcbd5ee461d70ef2e3a8241cdb1 scripts/deploy/deploy_core.py
|
||||
8c7da13e95bcddd2bd1b4f1b22ade1c5ac1ecf54448601daa8416905d4239751 scripts/deploy/deploy_runtime.py
|
||||
a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy/upgrade.py
|
||||
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
|
||||
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
|
||||
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
|
||||
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
||||
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
||||
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
||||
ad62f73bb42906d617cc1b479f02d4648c7324cb89fc11d2c6aa6bf13774eec9 src/acl.rs
|
||||
ea272433e7f92e983509a8ab1aa54242440abef21df526c2f99944ef279e09b9 src/acl/tests.rs
|
||||
6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs
|
||||
@@ -173,17 +175,17 @@ c8941a929dfef9e8875adf534394ca01a1532e567d5099fefe3657826886ee25 src/acme/runti
|
||||
bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs
|
||||
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
|
||||
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
|
||||
2518cf03b99c2ffb784cb3a68afbeb40565326743c8df91560046ac5a831e13c src/admin/documentation/documents.rs
|
||||
2270b77cc006de95b3d616aae07d4cb0b45abf3f9301abea5b5dad8ab05e4c56 src/admin/documentation/documents.rs
|
||||
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
|
||||
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
|
||||
e9f72e65c9ceb255248f460cd7a003fff33e4b258160e248353c3e1fb4e952fb src/admin/file_logs.rs
|
||||
1596561bb57976268b02e36e8ace426091045ee9d3f8832ce775eccdbc30bc81 src/admin/file_logs_io.rs
|
||||
de331ab75c31df65a4f0f1607dbfb26972799bff5d9f232d8e914c38225cffc7 src/admin/logging_api.rs
|
||||
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
|
||||
4f11cd49f01f00a30918fc759d2a7bfe96ed0f69c9c5017652964a559a90d24a src/admin/openapi.json
|
||||
1bdda508bfcc5cb1fa88ea0ce342808f71c1929baf783cc1fac8a738147c9ff9 src/admin/openapi.json
|
||||
6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs
|
||||
5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs
|
||||
b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs
|
||||
31bd90ee5f5ec2984920225ff6b57e07db3e0758092fb55d7c230e3590c1b015 src/admin/public.rs
|
||||
d73ea424a2795b6f44248abce459da770be179369a59345cb8f7514040b21071 src/admin/router.rs
|
||||
8708427263cd4caf8454b9115e89acb09172483a090c6b0bd43811dd9e52c7f6 src/admin/session.rs
|
||||
bebf42cd1808a99a0e669d483951a441ffa22c8d5a0a289c64491adf13c8841a src/admin/status.rs
|
||||
@@ -218,7 +220,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||
af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js
|
||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||
4ab416af339213f4774e358855aeaf890fa6a9ae33190bc44cc668fe53d25e87 src/admin/web/index.html
|
||||
4a30f473b3e764d7703be41636396aef47522ada31caa054d364cd387eec7d8c src/admin/web/index.html
|
||||
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
|
||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||
af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html
|
||||
@@ -230,10 +232,10 @@ bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/
|
||||
82b3d9b270c0adfe7a44925c8dd1a93b39f2936f40f4b260d36dec0689701595 src/admin/web/style.css
|
||||
a8a73a4fa849ae49391fcd6143e900c515c8b577f71b55cbae9701fa9f5bbe47 src/admin/web/traffic-panel.js
|
||||
e686d68fd6a368110a85dc4c5dc64e8093950d20d63e9aa44790c03dea6fc193 src/admin/web/transform-panel.js
|
||||
c6f78facd9e34266b8b651c45bca46db2cf2fc7a1a76a16f31fcce787f60aa9b src/admin/web/vhosts/core.js
|
||||
47f4f8faa64df6d515f553365aab86df4278eeeb2206fdb383f9a38fc46e31c3 src/admin/web/vhosts/editor.js
|
||||
7ee39a069155dc2650d688f14f8ea51e1a7bdad95ab4cc62769b4565c52fd37b src/admin/web/vhosts/core.js
|
||||
38d1f4bee4eae192fc977747241b76ed164bfae174d0377744d0a2ef06f85306 src/admin/web/vhosts/editor.js
|
||||
a155dd33eb802d4f2906f581b8f0d4b40260cb917766c31d4e887cdf51f2e940 src/admin/web/vhosts/render.js
|
||||
319b1f0b8b9591d54da7a9be86633b33520a259ba96e7eb32fa2a28817e768da src/admin/web/web-server.js
|
||||
7fa7d243714236f145b9df486fbb96712a667e7296837cc9c6c6b9ade2f5d0de src/admin/web/web-server.js
|
||||
98c00d99822f9be54fc58afb6d4b577901712effd2c5eddce014312681864625 src/auth/files.rs
|
||||
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
|
||||
97b5a060f24a102bbaa2f406426e985236c0d719b8038db210c25b34fcd8e923 src/auth/mod.rs
|
||||
@@ -302,12 +304,13 @@ cdc41a7241739b89297494323f29e0230fa06abbd74e219fedfda11df1db4334 src/cache/test
|
||||
a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tickets.rs
|
||||
3fc79b09474fe23b5b6fc98e4a376469afb02a41d6cc2dbbba59dc6091c790c8 src/compression.rs
|
||||
e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs
|
||||
7921ae451ccd65d647165b02919bd97399399da471454a6a1a1f63e682b76fc8 src/config.rs
|
||||
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs
|
||||
7ba52d3de4d77d1f3cad1b10de418953adc33901727a0791980847ae223929aa src/config/backend.rs
|
||||
ae30522f84db31cd77e7324cca1f88212a16e567eefcba45ffb655918a921d30 src/config/compile.rs
|
||||
63e636b2693950a48f7b559f9e7afdcdbe43644753cc259cc2ae219f766383c6 src/config/model.rs
|
||||
c8bbb5d6356911accceca128d2cd14cf5ed8cc6cb1a789e17d8187074997cb69 src/config/routing.rs
|
||||
8ffac4b67fffa8a6d0b96b2f0aa171dc71d5b8b525a9da0f4b51c5b26af5d1f2 src/config/snapshot.rs
|
||||
dd1632face27e53c6aa19f9be338df907700aae1d506667556cf79e4b86fdfbd src/config/compile.rs
|
||||
490d05d84b2fe349ddde54eae44880993fab70811652484dd9ba38b5069d603b src/config/internal_routing.rs
|
||||
875c107eb8c3b08e5ac4216e8600580397753fe599c4b665b40fa33022b8e169 src/config/model.rs
|
||||
1bf510a70805e6269ff39c4029016cbce7985535e52da1b915f6f971a12c2e63 src/config/routing.rs
|
||||
818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs
|
||||
7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/config/tests.rs
|
||||
7592bdeaad9950a720e2d841fcb1a4edb2a68af5333a6163e672d0392616103c src/config/tests/part_01.rs
|
||||
fb6e42d969b207e3e204d5892303740cf6f780be5a195cbee1f894b703994b60 src/config/tests/part_02.rs
|
||||
@@ -345,11 +348,11 @@ a16613114273c54b6d2a16a94fa8748550e91468585469642622bd2c86087baa src/logging/fo
|
||||
17425ebbca6bbbb0dcaeb65ebc44b1e51488a4092b7b6add556e764c6d486d6a src/logging/tests.rs
|
||||
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
|
||||
2f6b5a9517453541bf43580a9d963e64e36338575ee9ad295ca3718803a59ed6 src/main/admin_runtime.rs
|
||||
3b6a523d5b4e46576d97ae5816ed7b669ed97463afb90d326aa5a9ffb375353c src/main/bootstrap.rs
|
||||
b4e9a3fae5df972df3747352383beb16524056a6aeb95d9d609414f790a586ce src/main/bootstrap.rs
|
||||
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
|
||||
4dd7c1084a6214c0f2513ebf86fe8ef144470695557772bfe25979edd64491ab src/main/shutdown.rs
|
||||
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
|
||||
1b127f28d53c082614dec1163b6b6ce24bbff0db6cbef9c9873fc44c45443566 src/main/tasks.rs
|
||||
e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs
|
||||
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs
|
||||
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
|
||||
49b11df65b4fb0b5b727f9f867436584680b45e3f4fbd53f74bc0cab8b9a88ec src/metrics/vhost.rs
|
||||
@@ -358,30 +361,32 @@ a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.
|
||||
668840e7bd70590e2e13f59b6d5b9e43ca7c877f3d07c71907baea91b5bba6cb src/not_found/tests.rs
|
||||
6aab4f97d72ec6352c940bc89e911b8e50c580821da8202744e9832621458784 src/pages.rs
|
||||
49c17c5084b3d2f52a7d3473e55311dc4daafb1a47805f8821a5a4090dd477af src/pages/tests.rs
|
||||
4198844bc70c59ca3f8da812928391e2ca3d4eebc84bde432a5724c0c63d414b src/prometheus.rs
|
||||
de67f5ef52289506ee3b4b4ceb72923eb16b8f53ae55373c6c9e7a01900dadcd src/prometheus/core.rs
|
||||
5da9771bbd9843febe80cc9a722a9ada9d20cfb8d22addde7fef2c4edb66f327 src/prometheus/docker_misc.rs
|
||||
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
|
||||
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
|
||||
5ed3d03f9b805413544b932d0c266b0e32b830c783d01900af7988bca6bc48a3 src/prometheus/docker_misc.rs
|
||||
d3c6b08088df0003144544936a55e2a3661aac85292821b594f00adfd19a2951 src/prometheus/model.rs
|
||||
064b2be7fed2f6acf526b1cce070c1bb1b943d36f08fa50bbfcbdf3cf1c9318c src/prometheus/tests.rs
|
||||
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
|
||||
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
|
||||
59095a04707c2e4714358f4462dcf21c41952474b9b815c42bbd14e96c573846 src/prometheus/web_php.rs
|
||||
b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.rs
|
||||
16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs
|
||||
f0cb26ed34c35bacc74b914457acac8a611390de14cf46e2e6d3bcc848e914e8 src/proxy/cache_helpers.rs
|
||||
7bb29e31861757415c9c4d34d3b0b933c67d86a5758e9bdbcde6b54b307b689a src/proxy/error_fallback.rs
|
||||
cfb0910af8cd868bde3f6c9992319e46d5328d6d8f69c50701df0ea12bef2108 src/proxy/forward/cache_lookup.rs
|
||||
212de22f407dd96800afcf65fd1fc7c40fede1b88f80d04850eb8d1227b08a5b src/proxy/forward/response.rs
|
||||
59665df93430930f146d331f360189bad6ef03979a9cfe991453c5ba259b351b src/proxy/forward/route_cache.rs
|
||||
e5eec11349cda99d08b27f730202a57457fb2f48f856537b07dbda669168b5bd src/proxy/forward/response.rs
|
||||
8ad9a8dee80a7c16a60f1dd0126790daf3cde1a13e28a2595a0ef64f3fd63792 src/proxy/forward/route_cache.rs
|
||||
f239c1d5ea7a6efa83f59eb12cbeec310bef00f1360f41b5c64783af30e8469a src/proxy/forward/success_finish.rs
|
||||
e58c03f95b4ee8150665b20cbd76c3c6e21a1096b92b42dedc52c764123176cb src/proxy/forward/success_prepare.rs
|
||||
23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs
|
||||
b604e221d7e0d26b6eedfb5f10e2584c8bda99899fcf28ea740f4046bba3da39 src/proxy/forward_entry.rs
|
||||
c39090511e8057d43f2620175958c3dbd9b686036306a0060bb64eaab4bf6c93 src/proxy/forward_entry.rs
|
||||
c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs
|
||||
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
|
||||
9fbe0578c56f5d428ef07086c3c26cd40780d82d5aa21361ff58d946a395fed7 src/proxy/mod.rs
|
||||
f202c0fe0bc4e786a906d7337d368cd81bcac15ffdbbfd35d5b0d49126d9895a src/proxy/mod.rs
|
||||
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
|
||||
3c6ffd4b8578af0836976707f918d73b9516ab37d97e68744d3d608fbaa30438 src/proxy/tests/authority_tests.rs
|
||||
9a4c13efb218a5fe8689db5bd6b66029ca6ec270724d948651f3b18e78c80fba src/proxy/types.rs
|
||||
1b980d68f8b06b5d745b4556343b7990fa20660825953b9d0037c056e6991fc3 src/proxy/upstream_helpers.rs
|
||||
e80c7fe44abb7607c64ea02d56542753dc25290b3bcf4c4d45b75f2db6ca90d0 src/proxy/types.rs
|
||||
ed1a4dfb68054252c3fb0b64de4dcd9ce00d394e008b79cb169012ef4075297f src/proxy/upstream_helpers.rs
|
||||
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
|
||||
016a60b2471edd3df9bbe9f92a9cebea11047178b83ff0e3b2351c12814a4939 src/proxy/websocket/handshake.rs
|
||||
7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs
|
||||
@@ -450,35 +455,37 @@ a67e58a597a5eeb7efe605ae6e438a8f84ff6a1cedf8cd228141cb95551872a8 src/upstream.r
|
||||
225e3078e6775ffaa6dc6f12bc87503a123983cbb347edf38cae4c37ff02a5db src/upstream/client.rs
|
||||
4aa2420a92d33c50f7027f57ac4eccdc050bdb7e9ec8fa6e0eaa809228b71cd8 src/upstream/client_tests.rs
|
||||
6f932b8e59de8a5c89d3b29b838eb2a30f6e3ddd342fec29e39af1ce1b1c7d11 src/upstream/tests.rs
|
||||
251b5d1cf0c24d644e13f85ae473f630a76ca361dbb4b4ea971194c66dcab9f0 src/vhost.rs
|
||||
2152423877010c45328e8c78305669c3b9cb86aff5abf769eb074736c0b80f9b src/vhost/model.rs
|
||||
3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs
|
||||
6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs
|
||||
2d3a9d0321edc910d38afe6349d17d81bc090d36964486c8b87b64037311a83e src/vhost/model.rs
|
||||
35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs
|
||||
7127e081d884863b21c06f700b54884f90ec04efdfce835591b930f2d210faf6 src/vhost/tests.rs
|
||||
be63aaf2ca2a3efe10afc863216c38b0e12a114cefba96781e50c997e98f0434 src/vhost/validation.rs
|
||||
9b16b5307e140db97363f56c9fac8319e56df846bb1c919d3bc06091ab6ce69c src/vhost/tests.rs
|
||||
b61a40bb0ead18b3e348f03a258bab6a68d5c8f46968430f692458aa0cfd5bce src/vhost/validation.rs
|
||||
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
|
||||
35471c7ba376365d4044c4cc042f675491453d6e59211255802ce4b386172209 src/web_server/config/compile.rs
|
||||
c1fef6dfb79275479c15097b211cd5f063ad7e0852a0767b0145cf4f32f751da src/web_server/config/model.rs
|
||||
f1d3e427c53f8b5be5768d85e69ead746d83647fcd3b355acc98e3f9d5e217e0 src/web_server/config/validation.rs
|
||||
f1c9028fe40ee54e797dd2747c95ecffca87a77eb9de2e1cfde930b8bc8ad9c4 src/web_server/config/compile.rs
|
||||
53def5e7ddea307d6a8e8969af532a225636fa56a981ca4de3ec6fc41e59e5e4 src/web_server/config/model.rs
|
||||
42a613234af7015a0b14f125bb88be1dcc30a47e9064b12e3fbb5b71ce6f7caf src/web_server/config/validation.rs
|
||||
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
|
||||
62f0b6dfad23dbe99a06f1dc2e998946af64f2754323c306ccb9fbdf2138ee5a src/web_server/fastcgi/protocol.rs
|
||||
134c4998f6a0aa27e121af1ae3355b7e335488b2ae867ac0a934c58b4eb77191 src/web_server/fastcgi/response.rs
|
||||
4b2d1b59283d8ac841b3ea013c1e9d94af6f1b939735081750c019c2ecb1dbb8 src/web_server/fastcgi/protocol.rs
|
||||
6a2fff148bf5a6485c2017a8b0cb3d4214cfc247197dd3c77619bf0d19331a19 src/web_server/fastcgi/response.rs
|
||||
8186363315bd998919c6c783e6f7501528f257a5c0da77c5d60f423e8670db5e src/web_server/filesystem.rs
|
||||
0aafd7f37c4fe784b047646e4ce5ddcb5fe821231a41ed0a9f519529385288a8 src/web_server/htaccess.rs
|
||||
013830a79a78ce803ce776a9b333de406367751e0cea7e33c3f6a98e6600c243 src/web_server/htaccess/model.rs
|
||||
76de1d11df75f4a64556b2821fd0a1774d26c9f0ca0e5f2b5fcfad0722d43d47 src/web_server/htaccess/parse.rs
|
||||
66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs
|
||||
80f946570c14c8915fd21bfdb1a84dfb6a14c633ab6060acb828fab4c7c21daa src/web_server/mod.rs
|
||||
ddc329309a3c75c095aede58495b7f698929237e1d08467921a971577ca8405d src/web_server/metrics.rs
|
||||
d855dbc3a379a43a41e59998d9e93d069c09d562502630436447d692cce3100a src/web_server/mod.rs
|
||||
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
|
||||
c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs
|
||||
5f5d0a1bcf8fe200d9883146786841c6b40b48776a8ffb8ecf397a0c3a58c0b7 src/web_server/response/file.rs
|
||||
9aa68251a5f549f4ce3b1bda88d4abb9efc629caf444989381b4117c9488e37b src/web_server/response/headers.rs
|
||||
2268a6aeed2f565f9791af33e1cd21c56c228c8873b031725b39a6c57184f2d1 src/web_server/routing.rs
|
||||
87a0dd815d92688bbc173ca1b2a28487c4dc580cf04318273ac1d460791f6788 src/web_server/response/file.rs
|
||||
20509f9302b79d3a7be76dda6edaf5711826d3ddf47d19ebe07cb289427ca328 src/web_server/response/headers.rs
|
||||
9b58d6bb3ff1520060fc5aa86a3594f99192b1a8f8183255be1fb3ff35b4bb35 src/web_server/routing.rs
|
||||
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
|
||||
3e0b4e92600a7d5abcec92efed532b3a01e0781cb4f2681daac6d6ae13b0a6e9 src/web_server/serve.rs
|
||||
84868853bfe2d895ec4051c7c8ce9ccdc7c429fdd9718be460a81e78fb7391f1 src/web_server/serve.rs
|
||||
cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs
|
||||
c62752fe2fa6b74ddc9d94d36f8d191376b1575b15d21fc434c5a5da4c932692 src/web_server/tests/part_01.rs
|
||||
6b41487f46b9b249d141980a350e8501ce61e571ff0605bf3561e1a88201f4cf src/web_server/tests/part_02.rs
|
||||
e54f44f6ca1ed2c41e620de9195d32c4f8bb6936c3881745316a07160f87b4fa src/web_server/tests/part_03.rs
|
||||
f5254272e3588e89e8417932244c136cedd769a5286342332be57800579d8390 src/web_server/tests/part_01.rs
|
||||
874c228e03d11b33b5db7e58279569b35820d813ad6a9f45ee73b630f7bdad03 src/web_server/tests/part_02.rs
|
||||
8e52125bdde56484a9bf3880392de8e10bbab05e2ed0faae6056410296761d64 src/web_server/tests/part_03.rs
|
||||
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
|
||||
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
|
||||
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
|
||||
@@ -486,21 +493,22 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies
|
||||
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
|
||||
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
|
||||
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
|
||||
a699c0366c1b9329083602b2731fa5687839dfe265e5a60ff60a3b5796a493fb tools/checks/check_source.py
|
||||
5e2382a4f4094cf0a7cbc5870c78a33dfea65682dd81625b4fc12eee000dd6c8 tools/checks/docs_check.py
|
||||
fa6565e6edca33225b86a4589f9b3f0a823369069a9634825b1e0a4b2d37af31 tools/checks/check_source.py
|
||||
0c01cc6980d2ed09f9dab9f2985980375981bbc930e53c7179fb9016d31aea67 tools/checks/docs_check.py
|
||||
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
||||
cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py
|
||||
f0cef60dfb1180fdb495cc6a617c033db92b92d279b5fd9cd3b1343b130d1e40 tools/checks/preflight.py
|
||||
7187ba9799fda295698f56b1f89b2ac66c0abf3fa155f73c71726cbc14b91ad9 tools/checks/release_smoke.py
|
||||
c8b6981af08a78b06f7eaaa251514cec7f807a3e8508c1991049eb9d781c007a tools/checks/preflight.py
|
||||
cfa24e9792d9e151b84937ec274b23f1e266ebaee63e62056e1bdfa2fee02af1 tools/checks/release_smoke.py
|
||||
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
|
||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
||||
2309c41e769e797c838d4f40d50bb3c6b9ad9ad0f9ed07fb93e07f95d66563f9 tools/checks/source_module_contracts.py
|
||||
987b4c593b51a7c340d8f01f9a2eea1387043cdd997eeb932c8341bbb8c0bd64 tools/checks/version_contracts.py
|
||||
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
|
||||
0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py
|
||||
ba8e92ec5dde6e210fdc034a4871115acccf03288cf39b32a82ab2d4dcf390ed tools/checks/web_server_contracts.py
|
||||
c245f8f832bd2c36a3fbd5e66fe98e7367c70a8db840c79e6b640f1d9d74f2ac tools/checks/web_service_ui_test.mjs
|
||||
3c6fbb7fb96007d759e4bfef1424104f7ee14e22439c69ad955ed17fcfc53484 tools/checks/web_server_contracts.py
|
||||
e99cade378ef648d3f20cc9abed4da83d05c3b7322a69018467e8af37129c541 tools/checks/web_service_ui_test.mjs
|
||||
adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs
|
||||
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
||||
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
|
||||
|
||||
@@ -13,12 +13,13 @@ fi
|
||||
if ! command -v cargo >/dev/null 2>&1 && [[ -x "$HOME/.cargo/bin/cargo" ]]; then
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
fi
|
||||
RUSTPROXY_VERSION="$(python3 scripts/version.py)"
|
||||
mode="${1:-run}"
|
||||
if [[ $# -gt 0 ]]; then shift; fi
|
||||
case "$mode" in
|
||||
help|-h|--help)
|
||||
cat <<'HELP'
|
||||
RustProxy 0.29.0
|
||||
cat <<HELP
|
||||
RustProxy ${RUSTPROXY_VERSION}
|
||||
./dev.sh Build and start proxy + local panel
|
||||
./dev.sh run [serve flags] Same, with extra server options
|
||||
./dev.sh admin setup-token Generate/print a one-time setup capability before first login
|
||||
@@ -90,6 +91,7 @@ if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi
|
||||
if [[ "$mode" == "test" ]]; then
|
||||
python3 tools/checks/rust_module_contracts.py
|
||||
python3 tools/checks/source_module_contracts.py
|
||||
python3 tools/checks/version_contracts.py
|
||||
python3 tools/checks/check_source.py
|
||||
python3 tools/checks/release_smoke.py
|
||||
python3 tools/checks/memory_contracts.py
|
||||
@@ -141,7 +143,7 @@ if [[ "$mode" == "test" ]]; then
|
||||
printf 'Checking all Rust targets before tests...\n' >&2
|
||||
cargo check --locked --all-targets
|
||||
fi
|
||||
printf 'Building RustProxy 0.29.0...\n' >&2
|
||||
printf "Building RustProxy %s...\n" "$RUSTPROXY_VERSION" >&2
|
||||
cargo build --locked
|
||||
BIN="$CARGO_TARGET_DIR/debug/rust-proxy"
|
||||
case "$mode" in
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
const version = "0.29.0"
|
||||
const version = "0.29.2"
|
||||
|
||||
type config struct {
|
||||
Listen string
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Administration API access - 0.29.0
|
||||
# Administration API access - 0.29.1
|
||||
|
||||
## Sign-in boundary
|
||||
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ each Docker host, so the proxy container never needs Docker-socket access. See
|
||||
Build locally with a normal OCI builder:
|
||||
|
||||
```bash
|
||||
docker build -t rustproxy:0.29.0 .
|
||||
docker build -t rustproxy:0.29.1 .
|
||||
```
|
||||
|
||||
Persist `/var/lib/rustproxy`; it contains the SQLite database, certificates and the
|
||||
@@ -22,7 +22,7 @@ docker volume create rustproxy-data
|
||||
docker run --rm -d --name rustproxy \
|
||||
-p 8080:8080 \
|
||||
-v rustproxy-data:/var/lib/rustproxy \
|
||||
rustproxy:0.29.0
|
||||
rustproxy:0.29.1
|
||||
|
||||
docker exec rustproxy rust-proxy admin setup-token
|
||||
```
|
||||
|
||||
@@ -110,6 +110,12 @@ All access layers are additive. The request must pass the vhost method policy, l
|
||||
|
||||
Authentication, vhost rate limits, security headers, compression, custom error pages and WebSocket policy still apply through the normal vhost pipeline.
|
||||
|
||||
## Internal locations and error fallbacks
|
||||
|
||||
RustProxy 0.29.1 can mark a Custom location as `internal`. An internal location is addressed by its stable location `id` and is never selected by a public URI match. The vhost main source or another Custom location can use `error_fallback` to internally dispatch configured `403/404/500/502/503/504` responses to that target. The default intercepted status is `404`.
|
||||
|
||||
This is an internal request handoff, not an HTTP redirect. The original method, URI/query and request context are retained. The fallback target re-enters normal access/header/cache/compression/response processing. Fallback targets must be enabled internal locations in the same vhost; validation rejects cycles and runtime dispatch is depth-limited. See [0.29.1 implementation note](V0.29.1_IMPLEMENTATION.md).
|
||||
|
||||
## What is intentionally not an Nginx parser
|
||||
|
||||
Custom locations do **not** execute arbitrary Nginx directives or configuration snippets. RustProxy does not currently implement location-level `try_files`, per-location filesystem aliases/roots, embedded scripting, arbitrary `if` expressions or arbitrary module directives. Static roots are defined only by reusable Web Services. Use the explicit UI controls and the documented JSON schema instead; unknown fields are rejected on save.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Stored log explorer
|
||||
|
||||
RustProxy 0.29.0 provides an authenticated, read-only **Logs** workspace at `/logs`. The top bar has one **Logs** menu with separate **Stored logs** and **Live Logs** actions; the two data paths remain independent.
|
||||
RustProxy 0.29.1 provides an authenticated, read-only **Logs** workspace at `/logs`. The top bar has one **Logs** menu with separate **Stored logs** and **Live Logs** actions; the two data paths remain independent.
|
||||
|
||||
## Separation from Live Logs
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Installing and updating RustProxy 0.29.0
|
||||
# Installing and updating RustProxy 0.29.1
|
||||
|
||||
The scripts prepare a Linux/systemd installation. This source archive is **not a
|
||||
verified production build**: Rust/Cargo, real CA operations, live systemd startup
|
||||
|
||||
+39
-1
@@ -63,6 +63,7 @@ The normal ordered `index` list handles `index.php`; there is no separate PHP-in
|
||||
|
||||
### Additional PHP/FastCGI options
|
||||
|
||||
- `path_info`: default-off secure script/path splitting. When enabled, `/index.php/foo/bar` may resolve the existing `index.php` script and pass `/foo/bar` as `PATH_INFO`.
|
||||
- `pass_authorization`: forwards the inbound `Authorization` header as `HTTP_AUTHORIZATION`. Disable it when the application does not need that header.
|
||||
- `max_request_body_bytes`: streaming request-body limit enforced before/during FastCGI forwarding. `0` means no additional RustProxy-side limit. A request exceeding the configured value receives `413 Payload Too Large`.
|
||||
- `max_response_header_bytes`: maximum bytes accepted before the CGI response-header terminator. The allowed range is 4096..1048576 bytes.
|
||||
@@ -137,6 +138,7 @@ RustProxy supplies a controlled CGI/FastCGI parameter set including:
|
||||
- `REQUEST_METHOD`
|
||||
- `SCRIPT_FILENAME`
|
||||
- `SCRIPT_NAME`
|
||||
- `PATH_INFO` when `path_info` is enabled and a trailing path was securely split from an existing PHP script
|
||||
- `QUERY_STRING`
|
||||
- `REQUEST_URI`
|
||||
- `DOCUMENT_ROOT`
|
||||
@@ -157,7 +159,7 @@ Request headers cannot become FastCGI `PHP_VALUE` or `PHP_ADMIN_VALUE` parameter
|
||||
|
||||
An inbound `Proxy` header is **never** translated into FastCGI `HTTP_PROXY` (httpoxy mitigation), regardless of the optional Web Service hardening switch. The **Block common attack probes** option additionally blocks high-confidence sensitive/probe paths before PHP-FPM dispatch. FastCGI output cannot control downstream `Content-Length`, transfer framing, connection headers or `Proxy-*` transport headers; RustProxy owns downstream response framing.
|
||||
|
||||
`PATH_INFO` script/path splitting is not implemented in the current 0.25 development stage; applications should route explicitly to a real script, normally through `try_files`.
|
||||
`PATH_INFO` splitting is optional and disabled by default. When enabled, RustProxy searches for an existing configured PHP script prefix inside the document root, keeps `SCRIPT_FILENAME`/`SCRIPT_NAME` on that script, and supplies only the remaining suffix as `PATH_INFO`. `REQUEST_URI` remains the original request target.
|
||||
|
||||
## Response parsing
|
||||
|
||||
@@ -223,3 +225,39 @@ The implementation includes source/unit coverage for:
|
||||
- bounded endpoint/time/concurrency configuration validation.
|
||||
|
||||
Release-host acceptance should additionally exercise a real PHP-FPM runtime over Unix socket and loopback TCP and verify ACL/auth/custom errors/headers plus H1/H2/H3 client transports.
|
||||
|
||||
|
||||
## Per-Web-Service PHP administrative INI overrides (0.29.2)
|
||||
|
||||
`php.php_admin_values` is a validated operator-managed string map sent in a single
|
||||
`PHP_ADMIN_VALUE` FastCGI parameter. Example:
|
||||
|
||||
```json
|
||||
"php": {
|
||||
"enabled": true,
|
||||
"path_info": true,
|
||||
"endpoint": "unix:/run/php/php-fpm-mail.sock",
|
||||
"php_admin_values": {
|
||||
"open_basedir": "/var/www/webmail/:/tmp/",
|
||||
"display_errors": "Off"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use separate Web Services for distinct per-location PHP settings, then assign those
|
||||
services to the corresponding Custom Locations. Each Web Service can refer to the
|
||||
same trusted PHP-FPM socket. The setting does **not** isolate worker process
|
||||
identities or file permissions. Never expose PHP-FPM FastCGI to untrusted clients.
|
||||
HTTP headers cannot supply `PHP_ADMIN_VALUE`, and `php_values` must not repeat
|
||||
an admin directive. Control characters and excessive values are rejected.
|
||||
|
||||
Prometheus metrics use only configured Web Service IDs, never script paths,
|
||||
URLs, PHP values, or dynamic user labels. `rust_proxy_php_requests_total` and
|
||||
`rust_proxy_php_responses_total{class=...}` count responses after headers have
|
||||
been received (including proxy-generated failures); the cumulative header
|
||||
latency metric intentionally excludes streamed body time.
|
||||
|
||||
The same snapshot also exports `rust_proxy_web_server_requests_total`,
|
||||
`rust_proxy_web_server_responses_total{class=...}` and
|
||||
`rust_proxy_web_server_response_header_duration_microseconds_total` for all
|
||||
Web Server requests (PHP and non-PHP).
|
||||
+2
-1
@@ -15,6 +15,7 @@ This is the canonical index for the current project documentation. The authentic
|
||||
- [0.25.1 release audit](V0.25.1_AUDIT.md) - historical Traffic/module-split release scope and compiler-regression guards.
|
||||
- [0.25.0 specification audit](V0.25.0_AUDIT.md) - Web Server foundation point-by-point implementation status.
|
||||
- [0.25.0 pre-production security audit](V0.25.0_SECURITY_PREPROD_AUDIT.md) - threat-oriented source/configuration review, fixes made in-session and mandatory runtime security gates.
|
||||
- [0.29.1 implementation note](V0.29.1_IMPLEMENTATION.md) - internal error fallbacks, multipart byte ranges and PHP PATH_INFO.
|
||||
- [0.29.0 implementation note](V0.29.0_IMPLEMENTATION.md) - canonical host/custom-location redirects, health Host defaults, forced cache and gzip Stored Logs.
|
||||
- [0.28.2 implementation note](V0.28.2_IMPLEMENTATION.md) - external certificate ACL/path hardening and least-privilege Certificate API grants.
|
||||
- [0.28.1 implementation note](V0.28.1_IMPLEMENTATION.md) - external certificate paths, Certbot symlink reloads, backup semantics and certificate-source UX.
|
||||
@@ -75,7 +76,7 @@ This is the canonical index for the current project documentation. The authentic
|
||||
|
||||
## Historical or version-labelled context
|
||||
|
||||
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md); [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) is retained as the prior certificate-hardening milestone; [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability.
|
||||
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md); [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md) is retained as the prior redirect/health/cache/log milestone; [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) is retained as the prior certificate-hardening milestone; [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability.
|
||||
|
||||
Older context documents such as [INTEGRATIONS_040](INTEGRATIONS_040.md), [MANAGEMENT](MANAGEMENT.md), [SECURITY_AUDIT](SECURITY_AUDIT.md), [ACME_RFC2136](ACME_RFC2136.md) and older version audits remain available for traceability but should not override the current functional/technical documents above.
|
||||
|
||||
|
||||
+23
-75
@@ -1,87 +1,35 @@
|
||||
# Verification report - RustProxy 0.29.0
|
||||
# Verification report - RustProxy 0.29.2
|
||||
|
||||
## Scope
|
||||
## Changes
|
||||
|
||||
This report records verification of the 0.29.0 release source in the packaging environment. The release adds canonical www/non-www redirects, first-class Custom location redirects, automatic HTTP health-check Host selection, per-vhost forced-cache targets, bounded gzip log reading, and regression coverage for 503 maintenance pages and nested URL query strings. SQLite remains schema 9 and Cold Cache remains format 3.
|
||||
Same-version 0.29.2 correction: Web Server main routes no longer inherit `transient_unavailable` from the vhost backend configuration. When the Web Server source has no enabled Reverse Proxy Custom Locations, backend definitions are not compiled. An enabled reverse-proxy Custom Location still compiles and uses the backend pool. Removed fallback-only Prometheus metrics, retained PHP/Web Server metrics.
|
||||
|
||||
## Source and release checks
|
||||
Validated operator-controlled PHP_ADMIN_VALUE FastCGI configuration per Web Service,
|
||||
with distinct PHP_VALUE/ADMIN directive sets, explicit panel controls and OpenAPI
|
||||
schema. Prometheus PHP Web Service request/status counters, response header
|
||||
latency and available concurrency permit gauge, plus Web Server
|
||||
service-scoped response statuses and header latency.
|
||||
|
||||
The repository release gates are executed with:
|
||||
## Acceptance limitations
|
||||
|
||||
```bash
|
||||
./dev.sh test
|
||||
./dev.sh check
|
||||
```
|
||||
The build environment has no Rust compiler (`cargo`/`rustc`). Rust compilation,
|
||||
unit tests, FastCGI integration with a live PHP-FPM pool and runtime load tests
|
||||
have not been performed here. Rust-independent checks are recorded separately.
|
||||
This source package is not certified for production deployment.
|
||||
|
||||
`./dev.sh test` runs the Rust-independent source/module, release-smoke, memory, Web Server, security, dependency, browser-UI, documentation and translation checks before attempting Cargo. The 0.29.0 source checks cover the new configuration/UI/runtime contracts as well as retained release behavior.
|
||||
## Checks performed
|
||||
|
||||
Additional syntax checks used for the edited browser assets are:
|
||||
- Same-version correction: `python3 tools/checks/check_source.py` and `python3 tools/checks/release_smoke.py` passed. `bash ./dev.sh test` passed the Rust-independent checks and stopped at missing Rust/Cargo (exit 127). No live 503 regression test or Rust compilation could be performed.
|
||||
|
||||
```bash
|
||||
node --check src/admin/web/vhosts/core.js
|
||||
node --check src/admin/web/vhosts/editor.js
|
||||
node --check src/admin/web/vhosts/render.js
|
||||
```
|
||||
- `./dev.sh test`: all Rust-independent release, upgrade, security, JS, UI, documentation and source checks passed; exited 127 at the missing `cargo` toolchain guard.
|
||||
- `node --check src/admin/web/web-server.js`: passed.
|
||||
- `python -m json.tool src/admin/openapi.json`: passed.
|
||||
- `./dev.sh check`: exited 127 because Rust/Cargo is unavailable.
|
||||
|
||||
## Environment limitation
|
||||
## Debian deployment maintenance (same 0.29.2)
|
||||
|
||||
Rust/Cargo is not installed or not available on PATH in this packaging environment. Therefore Rust parsing, type checking, linking and execution of `cargo test --locked --all-targets` cannot be claimed here. `./dev.sh test` was executed and all Rust-independent gates printed PASS before the script stopped at the explicit Rust/Cargo guard with exit 127. `./dev.sh check` was also executed and stopped at the same guard with exit 127.
|
||||
Installer/update now installs /etc/logrotate.d/rustproxy using the recorded service user/group. Updates repair /etc/profile.d/rustproxy-path.sh rather than warning. logrotate uses copytruncate because RustProxy holds open log descriptors; some log records may be lost during truncate. Rust build and live Debian/systemd acceptance remain unverified in this environment.
|
||||
|
||||
## Version identity (0.29.2 source maintenance)
|
||||
|
||||
Observed auxiliary checks:
|
||||
|
||||
```text
|
||||
node --check src/admin/web/vhosts/core.js PASS
|
||||
node --check src/admin/web/vhosts/editor.js PASS
|
||||
node --check src/admin/web/vhosts/render.js PASS
|
||||
(cd docker-agent && go test ./...) PASS
|
||||
```
|
||||
|
||||
An attempt to fetch a user-local Rust toolchain was unavailable from this environment, so no compiler result is inferred from the source/static checks.
|
||||
|
||||
A Rust-enabled release host must still run:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo check --locked --all-targets
|
||||
cargo test --locked --all-targets
|
||||
```
|
||||
|
||||
before promoting the binary package.
|
||||
|
||||
## Functional regressions included in source
|
||||
|
||||
The Rust test source includes coverage for:
|
||||
|
||||
- www/non-www companion routing and canonical redirect behavior;
|
||||
- default and explicitly overridden HTTP health-check Host selection;
|
||||
- Custom location redirects with a separate capture regex and query preservation;
|
||||
- per-vhost forced-cache target matching and retained protocol-safety exclusions;
|
||||
- bounded discovery/read of rotated `.gz` Stored Logs;
|
||||
- a static/maintenance HTML response with HTTP 503;
|
||||
- preservation of a valid nested-URL query string in the upstream URI.
|
||||
|
||||
Literal/encoded backslashes remain intentionally rejected by the ambiguous-routing guard. For the reported curl case, use normal query separators inside a quoted URL rather than `\?`, `\=` or `\&`.
|
||||
|
||||
## Post-release source revision: multi-host vhost and canonical selector
|
||||
|
||||
The vhost editor now selects a canonical hostname from the configured host list, with a legacy first-host fallback. An additional Rust regression test verifies independent `suba.local` and `subb.local` aliases and a selected redirect target; invalid foreign redirect targets are rejected. Compiler/runtime tests remain unverified in this environment.
|
||||
|
||||
## Frontends WWW-routing UX revision
|
||||
|
||||
The editor groups host chips and WWW redirect controls in the same Frontends tab. The legacy select is replaced by accessible paired radio choices showing both `www -> bare` and `bare -> www` for each exact host (also while the feature is off), plus a list of non-redirected aliases and the number of configured names. A `www.` destination can be implicit: routing claims both hosts even if only the bare name was listed in the vhost. Wildcards are not offered as redirect targets. The selected target survives unrelated edits and automatically falls back to a valid hostname when removed; wildcard-only configurations disable the redirect control and show guidance. The persisted fields (`www_redirect` and `www_redirect_target`) are unchanged.
|
||||
|
||||
`node tools/checks/vhost_www_ui_test.mjs` runs the actual WWW-routing editor component in a DOM stand-in and covers its switch, selected-target restore, alias visibility, both redirect directions, implicit `www.` target, wildcard handling, removal fallback, and serialization. Rust source includes a complementary runtime regression for the implicit www routing pair. These checks do not substitute for executing Rust proxy runtime tests.
|
||||
|
||||
## WWW UX correction (source revision)
|
||||
|
||||
The Frontends tab now shows the WWW target choices next to its hostname editor, grouped by domain and available for preview even when the feature is disabled. Both www to bare and bare to www destinations are available. A WWW destination may be implicit (`www.subb.local` with only `subb.local` configured); the runtime claims both hostnames for the selected pair, while a second domain (`suba.local`) remains an independent alias of the same vhost and backend. A Rust regression test covers the implicit WWW direction; static checks and JS syntax tests are described above. Full Rust compiler/runtime verification still requires Cargo.
|
||||
|
||||
### WWW UX packaging verification
|
||||
|
||||
In this source revision, `node tools/checks/www_redirect_ui_test.mjs` and
|
||||
`node tools/checks/vhost_www_ui_test.mjs` both pass their DOM-stand-in selection
|
||||
and synchronization checks. `./dev.sh test` passed all pre-Cargo checks and
|
||||
stopped at the missing Cargo guard with exit 127. Syntax checks for the edited
|
||||
JavaScript and the two language JSON packs also passed. This does not establish
|
||||
Rust compile/test success or end-to-end browser behavior.
|
||||
`Cargo.toml` package version is the canonical release identity. Rust uses `CARGO_PKG_VERSION` at build time; `build.rs` substitutes the admin HTML placeholder. Packaging, upgrade and dev scripts read the manifest with `scripts/version.py`. Run `python3 tools/checks/version_contracts.py` to check that executable paths do not hardcode a version. Historical documentation and external dashboards may retain manually managed versions. Rust compilation/runtime verification is still pending.
|
||||
@@ -0,0 +1,127 @@
|
||||
# RustProxy 0.29.1 implementation note
|
||||
|
||||
RustProxy 0.29.1 adds explicit Nginx-style internal error routing without parsing Nginx configuration, plus multipart byte ranges and optional PHP `PATH_INFO` support. SQLite remains schema 9 and Cold Cache remains format 3.
|
||||
|
||||
## Internal error fallback
|
||||
|
||||
A vhost main source and each Custom location can define:
|
||||
|
||||
```json
|
||||
"error_fallback": {
|
||||
"target": "redirect-check",
|
||||
"statuses": [404]
|
||||
}
|
||||
```
|
||||
|
||||
`target` is the stable `id` of an enabled Custom location in the same vhost with `"internal": true`. Internal locations are retained in the compiled route table but never participate in normal public URI matching. The supported intercepted status set is deliberately bounded to `403`, `404`, `500`, `502`, `503` and `504`; omitting `statuses` defaults to `404`.
|
||||
|
||||
The dispatch is internal, not a `3xx` client redirect. RustProxy rebuilds the fallback request with the original HTTP method, URI, query string, HTTP version, request headers and the existing transport/request context. The target location then executes through the normal vhost/location path, including ACL/authentication, header rules, cache policy, compression and response processing. Vhost rate limiting is not charged a second time for the same client request.
|
||||
|
||||
For requests with a body, RustProxy can replay a body only when its exact size is known and no larger than 8 MiB. Such a request is buffered once so the same bytes can be sent to the fallback target. Unknown-size or larger streaming bodies continue to the original target, but an error fallback cannot replay them; the original error response is retained.
|
||||
|
||||
Configuration validation rejects missing/public fallback targets and recursive fallback cycles. A runtime depth limit of 8 internal dispatches remains as a second guard against loops after hot-reload/state inconsistencies.
|
||||
|
||||
Configured upstream response statuses are intercepted before normal response finalization and cache publication. RustProxy-generated reverse-proxy failures also participate where applicable: missing backend (`500`), unavailable/capacity (`503`), connect failure (`503`), invalid upstream response (`502`) and response timeout (`504`). A configured stale cached response still wins before transport-error fallback.
|
||||
|
||||
## Example: static files -> application -> redirector
|
||||
|
||||
The following vhost fragment expresses the same request flow as an Nginx `try_files`/`error_page` chain, without an HTTP redirect between stages:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"type": "web_server",
|
||||
"web_service_id": "www-linuxiarz"
|
||||
},
|
||||
"error_fallback": {
|
||||
"target": "to8000",
|
||||
"statuses": [404]
|
||||
},
|
||||
"backends": [
|
||||
{
|
||||
"id": "app",
|
||||
"url": "http://127.0.0.1:8000",
|
||||
"enabled": true,
|
||||
"allow_private": true,
|
||||
"address_allowlist": ["127.0.0.1/32"]
|
||||
},
|
||||
{
|
||||
"id": "redirector",
|
||||
"url": "http://127.0.0.1:9000",
|
||||
"enabled": true,
|
||||
"allow_private": true,
|
||||
"address_allowlist": ["127.0.0.1/32"]
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "to8000",
|
||||
"internal": true,
|
||||
"backend_id": "app",
|
||||
"source": {"type": "reverse_proxy"},
|
||||
"error_fallback": {
|
||||
"target": "redirect-check",
|
||||
"statuses": [404]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "redirect-check",
|
||||
"internal": true,
|
||||
"backend_id": "redirector",
|
||||
"source": {"type": "reverse_proxy"},
|
||||
"cache": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The effective flow is:
|
||||
|
||||
```text
|
||||
public request
|
||||
-> Web Server
|
||||
2xx/3xx -> return normally
|
||||
404 -> @to8000 (internal)
|
||||
-> backend app
|
||||
non-404 -> return normally
|
||||
404 -> @redirect-check (internal)
|
||||
-> redirector backend
|
||||
```
|
||||
|
||||
Normal public Custom locations can still route `/stats`, `/user-info`, `/health`, RBL files or other paths directly before the main source is used. An internal location's `path_prefix` is not used for public matching; its `id` is the fallback name.
|
||||
|
||||
## Multipart byte ranges
|
||||
|
||||
The Web Server now accepts comma-separated byte ranges such as:
|
||||
|
||||
```http
|
||||
Range: bytes=0-99,200-299
|
||||
```
|
||||
|
||||
One satisfiable range retains the existing single-part `206` response. Multiple satisfiable ranges produce a streaming `multipart/byteranges` response with per-part `Content-Type` and `Content-Range`. At most 16 ranges are accepted. Malformed or entirely unsatisfiable range sets return `416`. Multipart responses use the identity file representation rather than `.br`/`.gz` sidecars so the multipart MIME framing and `Content-Encoding` semantics cannot conflict.
|
||||
|
||||
## PHP PATH_INFO
|
||||
|
||||
Web Service PHP configuration gains the default-off field:
|
||||
|
||||
```json
|
||||
"php": {
|
||||
"enabled": true,
|
||||
"path_info": true
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, RustProxy may resolve a request such as `/index.php/foo/bar` by finding the longest existing configured PHP script prefix under the Web Service document root. FastCGI then receives, conceptually:
|
||||
|
||||
```text
|
||||
SCRIPT_FILENAME=/document/root/index.php
|
||||
SCRIPT_NAME=/index.php
|
||||
PATH_INFO=/foo/bar
|
||||
REQUEST_URI=/index.php/foo/bar
|
||||
```
|
||||
|
||||
The split uses the same document-root confinement and PHP file checks as normal script resolution. It is disabled by default because it changes URL-to-script resolution semantics.
|
||||
|
||||
## Compatibility
|
||||
|
||||
No SQLite migration is required. Existing vhosts, locations and Web Services deserialize unchanged because `internal`, `error_fallback` and PHP `path_info` use backward-compatible defaults. Existing single-range responses are retained.
|
||||
@@ -38,6 +38,12 @@ health/traffic history. Because the vhost configuration participates in cache co
|
||||
a rename also rotates that compatibility identity and invalidates incompatible cached objects.
|
||||
The editor warns before applying the rename.
|
||||
|
||||
## Internal error routing
|
||||
|
||||
The main vhost `source` may define `error_fallback`, and each Custom location may do the same. A fallback references the `id` of an enabled `internal` Custom location in the same vhost. Supported statuses are `403`, `404`, `500`, `502`, `503` and `504`; omitted status lists default to `404`. Internal targets are not reachable by ordinary path matching.
|
||||
|
||||
Configured upstream/Web Server failures are intercepted before final response/cache publication. The original request method, URI/query and context are internally dispatched to the target, which then follows normal vhost/location processing. Validation rejects fallback cycles and runtime dispatch is depth-limited. See [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md) for body replay bounds and an Nginx-style static -> app -> redirector example.
|
||||
|
||||
## Frontend selection and private IP isolation
|
||||
|
||||
`frontend.hosts` is a normalized hostname-pattern list. Exact hostnames and a single
|
||||
|
||||
+5
-4
@@ -217,20 +217,21 @@ Optional precompressed delivery checks sidecar files only after the original res
|
||||
|
||||
The client must advertise the matching coding in `Accept-Encoding`. Brotli is preferred when both are available. The response keeps the original file's MIME type and adds the appropriate `Content-Encoding` and `Vary: Accept-Encoding`.
|
||||
|
||||
Validators, streaming and a single Range request operate on the selected representation. RustProxy does not compress the file on every request in this mode.
|
||||
Validators, streaming and a single Range request operate on the selected representation. Multipart Range responses use the identity representation so MIME boundaries are never mixed with a precompressed sidecar. RustProxy does not compress the file on every request in this mode.
|
||||
|
||||
## Range and downloads
|
||||
|
||||
Static and precompressed responses support:
|
||||
Static responses support:
|
||||
|
||||
- single `Range: bytes=...`;
|
||||
- multipart `Range: bytes=0-99,200-299` with at most 16 ranges;
|
||||
- `If-Range`;
|
||||
- `206 Partial Content`;
|
||||
- `416 Range Not Satisfiable`;
|
||||
- `Accept-Ranges` and `Content-Range`;
|
||||
- `Accept-Ranges` and per-part `Content-Range`;
|
||||
- streaming rather than whole-file buffering.
|
||||
|
||||
Multipart ranges remain intentionally unsupported.
|
||||
Single ranges can operate on a selected precompressed representation as before. Multipart ranges intentionally use the identity representation and stream `multipart/byteranges`.
|
||||
|
||||
Configured `download_extensions` add `Content-Disposition: attachment` using an encoded filename. This is useful for storage/download services without changing the MIME type.
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ classes, cache outcomes, capture-state gauges and configured-route traffic. Raw
|
||||
are intentionally absent from Prometheus labels.
|
||||
|
||||
Import the JSON in Grafana and select a Prometheus datasource when prompted. The
|
||||
dashboard expects RustProxy 0.29.0 metric names and a scrape interval around 15s.
|
||||
dashboard expects RustProxy 0.29.2 metric names and a scrape interval around 15s.
|
||||
Collectors can be disabled in RustProxy settings; panels for disabled collectors
|
||||
will simply show no series.
|
||||
|
||||
|
||||
@@ -4529,9 +4529,9 @@
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "RustProxy 0.29.0 - Operations",
|
||||
"title": "RustProxy 0.29.2 - Operations",
|
||||
"uid": "rustproxy-0170",
|
||||
"version": 5,
|
||||
"weekStart": "",
|
||||
"description": "RustProxy 0.29.0 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
|
||||
"description": "RustProxy 0.29.2 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ echo "RustProxy bootstrap mode: $MODE"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y build-essential gcc git ca-certificates python3 python3-venv python3-pip pkg-config wget sudo
|
||||
apt-get install -y build-essential gcc git ca-certificates python3 python3-venv python3-pip pkg-config wget sudo logrotate
|
||||
created_builder=0
|
||||
if [[ -n ${SUDO_USER:-} && ${SUDO_USER} != root ]] && id "${SUDO_USER}" >/dev/null 2>&1; then
|
||||
builder=${SUDO_USER}
|
||||
|
||||
+10
-2
@@ -1825,7 +1825,7 @@
|
||||
"webServicePrecompressedGzip": "Gzip (.gz)",
|
||||
"webServiceDownloadExtensions": "Download extensions",
|
||||
"webServiceDownloadExtensionsHelp": "Files with these extensions receive Content-Disposition: attachment.",
|
||||
"webServiceRangeHelp": "Static and precompressed representations keep validators, single-range requests, 206/416 and streaming delivery.",
|
||||
"webServiceRangeHelp": "Static and precompressed representations keep validators, single and multipart byte-range requests, 206/416 and streaming delivery.",
|
||||
"webServicePhpEnabled": "Enable PHP-FPM / FastCGI",
|
||||
"webServicePhpEndpoint": "PHP-FPM endpoint",
|
||||
"webServicePhpEndpointHelp": "Unix: unix:/run/php/php-fpm.sock. TCP: tcp:127.0.0.1:9000 or tcp:[::1]:9000. TCP is intentionally limited to loopback addresses.",
|
||||
@@ -2227,6 +2227,14 @@
|
||||
"vhWwwOtherDomains": "Other domains (no redirect):",
|
||||
"vhWwwNoHosts": "Add an exact hostname above to enable a WWW redirect. Wildcards cannot be the destination.",
|
||||
"vhWwwSharedCount": "{count} names / one vhost",
|
||||
"vhWwwEnable": "Enable 308 redirect"
|
||||
"vhWwwEnable": "Enable 308 redirect",
|
||||
"vhErrorFallbackTarget": "Main error fallback target",
|
||||
"vhErrorFallbackStatuses": "Intercepted status codes",
|
||||
"vhErrorFallbackHelp": "Target must be an enabled internal Custom Location. Supported statuses: 403, 404, 500, 502, 503, 504. The request is dispatched internally without an HTTP redirect.",
|
||||
"locationInternal": "Internal named location",
|
||||
"locationErrorFallbackTarget": "Error fallback target",
|
||||
"locationErrorFallbackStatuses": "Intercepted status codes",
|
||||
"locationErrorFallbackHelp": "Optional Nginx-style error fallback. Target an enabled internal location by ID. Supported statuses: 403, 404, 500, 502, 503, 504.",
|
||||
"webServicePhpPathInfo": "Enable PHP PATH_INFO splitting"
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -1825,7 +1825,7 @@
|
||||
"webServicePrecompressedGzip": "Gzip (.gz)",
|
||||
"webServiceDownloadExtensions": "Rozszerzenia do pobierania",
|
||||
"webServiceDownloadExtensionsHelp": "Pliki z tymi rozszerzeniami otrzymają Content-Disposition: attachment.",
|
||||
"webServiceRangeHelp": "Statyczne i gotowe skompresowane reprezentacje zachowują walidatory, pojedynczy Range, 206/416 i streaming.",
|
||||
"webServiceRangeHelp": "Statyczne i prekompresowane reprezentacje zachowują walidatory, pojedyncze i wieloczęściowe zakresy bajtów, odpowiedzi 206/416 oraz streaming.",
|
||||
"webServicePhpEnabled": "Włącz PHP-FPM / FastCGI",
|
||||
"webServicePhpEndpoint": "Endpoint PHP-FPM",
|
||||
"webServicePhpEndpointHelp": "Unix: unix:/run/php/php-fpm.sock. TCP: tcp:127.0.0.1:9000 albo tcp:[::1]:9000. TCP jest celowo ograniczone do adresów loopback.",
|
||||
@@ -2227,6 +2227,14 @@
|
||||
"vhWwwOtherDomains": "Pozostałe domeny (bez przekierowania):",
|
||||
"vhWwwNoHosts": "Dodaj powyżej konkretną domenę. Wildcard nie może być adresem docelowym.",
|
||||
"vhWwwSharedCount": "{count} nazw / jeden vhost",
|
||||
"vhWwwEnable": "Włącz przekierowanie 308"
|
||||
"vhWwwEnable": "Włącz przekierowanie 308",
|
||||
"vhErrorFallbackTarget": "Główny cel fallbacku błędów",
|
||||
"vhErrorFallbackStatuses": "Przechwytywane kody statusu",
|
||||
"vhErrorFallbackHelp": "Cel musi być włączoną wewnętrzną Custom Location. Obsługiwane statusy: 403, 404, 500, 502, 503, 504. Request jest przekazywany wewnętrznie, bez redirectu HTTP.",
|
||||
"locationInternal": "Wewnętrzna nazwana lokalizacja",
|
||||
"locationErrorFallbackTarget": "Cel fallbacku błędów",
|
||||
"locationErrorFallbackStatuses": "Przechwytywane kody statusu",
|
||||
"locationErrorFallbackHelp": "Opcjonalny fallback błędów w stylu Nginx. Wskaż ID włączonej lokalizacji wewnętrznej. Obsługiwane statusy: 403, 404, 500, 502, 503, 504.",
|
||||
"webServicePhpPathInfo": "Włącz rozdzielanie PHP PATH_INFO"
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -9,6 +9,7 @@ import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import zipfile
|
||||
from scripts.version import project_version
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
MANAGED = [
|
||||
@@ -86,7 +87,7 @@ def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.29.0.zip')
|
||||
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / f'RustProxy-{project_version(ROOT)}.zip')
|
||||
output = parser.parse_args().output.resolve()
|
||||
paths = files()
|
||||
checksum = ROOT / 'SHA256SUMS'
|
||||
|
||||
@@ -85,7 +85,7 @@ def download(destination: Path) -> None:
|
||||
"""TLS verification uses Python's default trust store; never executes a shell."""
|
||||
started = time.monotonic()
|
||||
opener = build_opener(HttpsRedirects())
|
||||
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.29.0"})
|
||||
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build"})
|
||||
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
|
||||
length = response.headers.get("Content-Length")
|
||||
if length and int(length) > MAX_ARCHIVE:
|
||||
|
||||
@@ -50,7 +50,8 @@ def main() -> None:
|
||||
'start_service':not args.no_start,
|
||||
'geoip_action':'skip' if args.skip_geoip else ('download-and-verify' if args.mode == 'install' else 'verify-and-refresh-if-stale'),
|
||||
'shell_path_profile':str(SHELL_PATH_PROFILE),
|
||||
'shell_path_action':'install' if args.mode == 'install' else 'check-only',
|
||||
'shell_path_action':'install-or-repair',
|
||||
'logrotate_path':str(LOGROTATE_PATH), 'logrotate_action':'install-or-repair',
|
||||
'reset_saved_config':bool(args.reset_config), 'changes_made':False}, indent=2)); return
|
||||
if sys.platform != 'linux' or os.geteuid() != 0 or not shutil.which('systemctl') or not shutil.which('runuser'):
|
||||
raise ValueError('Installation requires root on Linux with systemd and runuser. Use --plan for inspection.')
|
||||
|
||||
@@ -34,6 +34,7 @@ import urllib.parse
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
UNIT_PATH = Path('/etc/systemd/system/rustproxy.service')
|
||||
SHELL_PATH_PROFILE = Path('/etc/profile.d/rustproxy-path.sh')
|
||||
LOGROTATE_PATH = Path('/etc/logrotate.d/rustproxy')
|
||||
SERVICE = 'rustproxy.service'
|
||||
GEOIP_SOURCES = {
|
||||
'user-country-ipv4.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv',
|
||||
@@ -95,7 +96,7 @@ def digest(path: Path) -> str:
|
||||
def fetch_https(url: str, max_bytes: int) -> bytes:
|
||||
if urllib.parse.urlparse(url).scheme != 'https':
|
||||
raise ValueError('GeoIP downloads require HTTPS.')
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.29.0'})
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer'})
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
if urllib.parse.urlparse(response.geturl()).scheme != 'https':
|
||||
raise ValueError('GeoIP download redirected away from HTTPS.')
|
||||
@@ -301,6 +302,24 @@ def install_shell_path(prefix: Path) -> None:
|
||||
write_private(SHELL_PATH_PROFILE, shell_path_profile(prefix), 0o644)
|
||||
|
||||
|
||||
def logrotate_config(user: str, group: str) -> bytes:
|
||||
# User/group are validated system account names, never interpolated from a request.
|
||||
for value in (user, group):
|
||||
if not re.fullmatch(r'[a-z_][a-z0-9_-]*\$?', value):
|
||||
raise ValueError('Unsafe service account for logrotate configuration.')
|
||||
return (f"# Managed by RustProxy installer; service account: {user}:{group}\n"
|
||||
'/var/log/rustproxy/*.jsonl /var/log/rustproxy/*.log /var/log/rustproxy/vhosts/*.jsonl /var/log/rustproxy/vhosts/*.log {\n'
|
||||
' daily\n maxsize 100M\n rotate 14\n missingok\n notifempty\n'
|
||||
' compress\n delaycompress\n copytruncate\n'
|
||||
f' su {user} {group}\n'
|
||||
'}\n').encode()
|
||||
|
||||
|
||||
def install_logrotate(record: dict) -> None:
|
||||
"""Keep logrotate in sync with the actual managed service account."""
|
||||
write_private(LOGROTATE_PATH, logrotate_config(record['user'], record['group']), 0o644)
|
||||
|
||||
|
||||
def check_shell_path(prefix: Path) -> bool:
|
||||
expected = shell_path_profile(prefix)
|
||||
try:
|
||||
|
||||
@@ -359,8 +359,8 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
|
||||
geoip_lock_fd: int | None = None
|
||||
geoip_stage: Path | None = None
|
||||
try:
|
||||
# Updates deliberately do not rewrite shell environment. Check the installer-
|
||||
# managed PATH profile early and report the exact remediation target instead.
|
||||
# Repair missing or stale installer-owned shell setup on upgrades.
|
||||
install_shell_path(prefix)
|
||||
check_shell_path(prefix)
|
||||
# Repair stale root:root/0700 metadata before staging/network work and before
|
||||
# stopping the currently healthy service.
|
||||
@@ -412,6 +412,8 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
|
||||
if not healthy(record, record['version']):
|
||||
raise RuntimeError(f'Update failed: {error}. Rollback restored code/data/unit but previous service health also failed. Backup: {backup}; failed state: {failed}') from error
|
||||
raise RuntimeError(f'Update failed: {error}. Previous code, private data and service unit were restored. Installation is still present; do not run install.sh. Fix the cause and rerun update.sh. Backup: {backup}; failed state: {failed}') from error
|
||||
install_shell_path(prefix)
|
||||
install_logrotate(replacement)
|
||||
print(f'Backup (contains private keys): {backup}')
|
||||
return replacement
|
||||
finally:
|
||||
@@ -443,6 +445,7 @@ def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bo
|
||||
record.update(release=release, version=version, sha256=sha)
|
||||
switch(prefix, release)
|
||||
install_shell_path(prefix)
|
||||
install_logrotate(record)
|
||||
# Record is written even if initial startup fails, so an update can repair it.
|
||||
write_private(prefix / 'install.json', json.dumps(record, indent=2).encode())
|
||||
write_private(UNIT_PATH, service_unit(record).encode(), 0o644)
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from scripts.version import project_version
|
||||
|
||||
MANAGED = ["build.rs", "langs", "deploy", "debian", "grafana", "make_zip.py", "src", "tests", "scripts", "tools", "integrations", "nagios", "docker-agent", "migrations", "examples", "docs", ".github", ".cargo",
|
||||
"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "dev.sh", "upgrade.sh", "install.sh", "update.sh",
|
||||
@@ -46,7 +48,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
|
||||
for name in MANAGED:
|
||||
if (target / name).is_symlink():
|
||||
raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.")
|
||||
backup = target / ".upgrade-backup" / ("before-0.29.0-" + uuid.uuid4().hex[:12])
|
||||
backup = target / ".upgrade-backup" / ("before-" + project_version(target) + "-" + uuid.uuid4().hex[:12])
|
||||
backup.mkdir(parents=True, mode=0o700)
|
||||
installed: list[str] = []
|
||||
moved: list[str] = []
|
||||
@@ -84,7 +86,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
|
||||
remove_legacy = legacy.exists()
|
||||
if remove_legacy and (legacy.is_symlink() or not legacy.is_file()):
|
||||
raise ValueError("Obsolete renewal observation sidecar must be a regular file.")
|
||||
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.29.0", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
|
||||
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":project_version(source), "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
|
||||
if remove_legacy:
|
||||
legacy.unlink()
|
||||
except BaseException:
|
||||
@@ -115,7 +117,7 @@ def main() -> None:
|
||||
backup = upgrade(Path(__file__).resolve().parents[2], args.target, stopped=args.stopped, data_dir=data_dir)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.exit(2, f"Upgrade refused or rolled back: {error}\n")
|
||||
print(f"Installed RustProxy 0.29.0. Previous managed source files: {backup}")
|
||||
print(f"Installed RustProxy {project_version(args.target)}. Previous managed source files: {backup}")
|
||||
print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.")
|
||||
print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh")
|
||||
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the canonical RustProxy release version from Cargo.toml."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
|
||||
def project_version(root: Path | None = None) -> str:
|
||||
manifest = (root or Path(__file__).resolve().parents[1]) / "Cargo.toml"
|
||||
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
|
||||
return data["package"]["version"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(project_version(Path(sys.argv[1]) if len(sys.argv) > 1 else None))
|
||||
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"/docs/V0.25.1_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
reader_doc!("docs/V0.29.1_IMPLEMENTATION.md", "technical"),
|
||||
reader_doc!("docs/V0.29.0_IMPLEMENTATION.md", "technical"),
|
||||
reader_doc!("docs/V0.28.2_IMPLEMENTATION.md", "technical"),
|
||||
reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"),
|
||||
|
||||
+77
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.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."
|
||||
},
|
||||
"servers": [
|
||||
@@ -412,6 +412,19 @@
|
||||
"target"
|
||||
],
|
||||
"description": "Backend-less client redirect for this location."
|
||||
},
|
||||
"internal": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Internal named location excluded from public URI matching; reachable only by internal dispatch."
|
||||
},
|
||||
"error_fallback": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ErrorFallback"
|
||||
}
|
||||
],
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -438,7 +451,10 @@
|
||||
"description": "Claim the direct www/non-www companion of the selected exact DNS hostname and redirect it permanently to that hostname."
|
||||
},
|
||||
"www_redirect_target": {
|
||||
"type": ["string", "null"],
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "An exact hostname from frontend.hosts to use as the canonical redirect destination. Null uses the first hostname for compatibility. Other hostnames continue serving the same vhost without redirects."
|
||||
},
|
||||
"forward_client_ip": {
|
||||
@@ -598,6 +614,15 @@
|
||||
},
|
||||
"compression": {
|
||||
"$ref": "#/components/schemas/CompressionOverrides"
|
||||
},
|
||||
"error_fallback": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ErrorFallback"
|
||||
}
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "Error fallback for the main vhost source."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
@@ -5084,6 +5109,21 @@
|
||||
"maxLength": 8192
|
||||
},
|
||||
"description": "Trusted php.ini directive/value overrides combined into the FastCGI PHP_VALUE parameter. PHP_ADMIN_VALUE is not exposed."
|
||||
},
|
||||
"path_info": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Safely split /script.php/extra/path into SCRIPT_FILENAME/SCRIPT_NAME for the existing script plus PATH_INFO."
|
||||
},
|
||||
"php_admin_values": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
"default": {},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
},
|
||||
"description": "Trusted operator PHP-FPM administrative php.ini directives packed into PHP_ADMIN_VALUE. Restrict access to FastCGI endpoints."
|
||||
}
|
||||
},
|
||||
"description": "Optional bounded direct FastCGI connection to a PHP-FPM pool."
|
||||
@@ -5574,6 +5614,41 @@
|
||||
"updated_files",
|
||||
"database"
|
||||
]
|
||||
},
|
||||
"ErrorFallback": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Enabled internal Custom Location ID in the same vhost."
|
||||
},
|
||||
"statuses": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 6,
|
||||
"uniqueItems": true,
|
||||
"default": [
|
||||
404
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
403,
|
||||
404,
|
||||
500,
|
||||
502,
|
||||
503,
|
||||
504
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"target"
|
||||
],
|
||||
"description": "Nginx-style internal error fallback. The original request is internally dispatched without a client redirect."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+7
-1
@@ -2,10 +2,16 @@
|
||||
use crate::{auth::Session, runtime::ApiError};
|
||||
use axum::{extract::rejection::JsonRejection, response::Html, Extension};
|
||||
use http::StatusCode;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static ADMIN_INDEX: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub(super) async fn index(session: Option<Extension<Session>>) -> Html<&'static str> {
|
||||
if session.is_some() {
|
||||
Html(include_str!("web/index.html"))
|
||||
Html(ADMIN_INDEX.get_or_init(|| {
|
||||
include_str!("web/index.html")
|
||||
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"))
|
||||
}).as_str())
|
||||
} else {
|
||||
Html(include_str!("web/login.html"))
|
||||
}
|
||||
|
||||
@@ -2891,7 +2891,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.29.0</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>__RUSTPROXY_VERSION__</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3335,6 +3335,8 @@
|
||||
id="web-service-php-extensions" maxlength="512" placeholder="php" /></label>
|
||||
<label class="check"><input id="web-service-php-pass-authorization" type="checkbox" /><span
|
||||
data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label>
|
||||
<label class="check"><input id="web-service-php-path-info" type="checkbox" /><span
|
||||
data-i18n="webServicePhpPathInfo">Enable PHP PATH_INFO splitting</span></label>
|
||||
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input
|
||||
id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /></label>
|
||||
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input
|
||||
@@ -3360,8 +3362,8 @@
|
||||
spellcheck="false"
|
||||
placeholder="memory_limit = 256M upload_max_filesize = 64M post_max_size = 64M"></textarea><small
|
||||
class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per line.
|
||||
RustProxy sends the validated set as PHP_VALUE; PHP_ADMIN_VALUE is never accepted
|
||||
here.</small></label>
|
||||
RustProxy sends the validated set as PHP_VALUE; Use the separate operator-only PHP_ADMIN_VALUE field for administrative overrides.</small></label>
|
||||
<label class="vh-wide"><span>PHP administrator INI overrides (PHP_ADMIN_VALUE)</span><textarea id="web-service-php-admin-values" maxlength="65536" rows="6" spellcheck="false" placeholder="open_basedir = /var/www/webmail/:/tmp/ display_errors = Off"></textarea><small class="fine">Trusted operator settings only. Restrict PHP-FPM socket/network access; do not use shared PHP-FPM pools as a security boundary.</small></label>
|
||||
<div class="vh-wide form-actions"><button class="outline" data-i18n="webServicePhpDocsButton"
|
||||
id="web-service-php-help" type="button">FastCGI syntax and safety</button></div>
|
||||
<p class="fine vh-wide" data-i18n="webServicePhpHelp">RustProxy speaks FastCGI directly to a
|
||||
@@ -3635,7 +3637,7 @@ max_execution_time = 30</code></pre>
|
||||
<article class="card about-card">
|
||||
<div class="about-product">
|
||||
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.29.0</strong></div>
|
||||
<div><span class="small-label">RustProxy</span><strong>__RUSTPROXY_VERSION__</strong></div>
|
||||
</div>
|
||||
<dl class="about-meta">
|
||||
<div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
@@ -313,8 +313,10 @@
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action === "proxy" || action === "web_server";
|
||||
const internal = yes("location-internal", r), fallbackTarget = str("location-error-target", r);
|
||||
const fallbackStatuses = lines(str("location-error-statuses", r)).map(value => Number(value));
|
||||
return {
|
||||
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
|
||||
id: str("location-id", r), internal, path_prefix: internal ? "/" : str("location-path", r), match_type: internal ? "prefix" : str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
@@ -325,7 +327,8 @@
|
||||
headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null,
|
||||
error_fallback: fallbackTarget ? { target: fallbackTarget, statuses: fallbackStatuses.length ? fallbackStatuses : [404] } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -339,6 +342,7 @@
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
@@ -347,6 +351,9 @@
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(handlingBody, "locationErrorFallbackHelp");
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
@@ -395,14 +402,15 @@
|
||||
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
match.disabled = path.disabled = internal.checked;
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
|
||||
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
const id = str("location-id", row) || t("locationId"), pattern = internal.checked ? `@${id}` : (str("location-path", row) || "/");
|
||||
summary.textContent = `${id} · ${internal.checked ? t("locationInternal") : t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhLocations");
|
||||
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhErrorFallbackHelp");
|
||||
locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhSsl");
|
||||
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||
@@ -386,6 +390,7 @@
|
||||
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
|
||||
const bindingSelection = readBindings();
|
||||
const mainSourceType = str("source-type");
|
||||
const mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value));
|
||||
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
|
||||
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
|
||||
@@ -419,7 +424,7 @@
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
}
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
|
||||
@@ -80,8 +80,10 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
function validatePhpCustomData(customParams, phpValues) {
|
||||
const size = [...Object.entries(customParams), ...Object.entries(phpValues)]
|
||||
function validatePhpCustomData(customParams, phpValues, phpAdminValues = {}) {
|
||||
// Never allow an admin override to conflict with ordinary PHP_VALUE.
|
||||
if (Object.keys(phpValues).some(name => Object.hasOwn(phpAdminValues, name))) throw new Error(t("webServicePhpValuesInvalid"));
|
||||
const size = [...Object.entries(customParams), ...Object.entries(phpValues), ...Object.entries(phpAdminValues)]
|
||||
.reduce((total, [name, value]) => total + name.length + value.length + 2, 0);
|
||||
if (size > 128 * 1024) throw new Error(t("webServicePhpCustomDataTooLarge"));
|
||||
}
|
||||
@@ -156,8 +158,9 @@
|
||||
for (const id of [
|
||||
"web-service-php-endpoint", "web-service-php-extensions", "web-service-php-connect-timeout",
|
||||
"web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-pass-authorization",
|
||||
"web-service-php-path-info",
|
||||
"web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit",
|
||||
"web-service-php-custom-params", "web-service-php-values"
|
||||
"web-service-php-custom-params", "web-service-php-values", "web-service-php-admin-values"
|
||||
]) $(id).disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -194,9 +197,9 @@
|
||||
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
|
||||
try_files: [], rewrites: [], compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
|
||||
symlink_policy: "deny", php: {
|
||||
enabled: false, extensions: ["php"], endpoint: "unix:/run/php/php-fpm.sock", connect_timeout_ms: 1000,
|
||||
enabled: false, path_info: false, extensions: ["php"], endpoint: "unix:/run/php/php-fpm.sock", connect_timeout_ms: 1000,
|
||||
request_timeout_ms: 30000, max_concurrent_requests: 64, pass_authorization: true, max_request_body_bytes: 0,
|
||||
max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}
|
||||
max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}, php_admin_values: {}
|
||||
},
|
||||
htaccess: { enabled: false, filename: ".htaccess", scan_subdirectories: true, strict: false },
|
||||
hardening: { block_common_attacks: false, strip_unnecessary_headers: false }
|
||||
@@ -241,6 +244,7 @@
|
||||
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
|
||||
$("web-service-download-extensions").value = (item.download_extensions || []).join("\n");
|
||||
$("web-service-php-enabled").checked = item.php?.enabled === true;
|
||||
$("web-service-php-path-info").checked = item.php?.path_info === true;
|
||||
$("web-service-php-endpoint").value = item.php?.endpoint || "unix:/run/php/php-fpm.sock";
|
||||
$("web-service-php-extensions").value = (item.php?.extensions || ["php"]).join(", ");
|
||||
$("web-service-php-connect-timeout").value = item.php?.connect_timeout_ms ?? 1000;
|
||||
@@ -252,6 +256,7 @@
|
||||
$("web-service-php-stderr-limit").value = item.php?.stderr_log_bytes ?? 4096;
|
||||
$("web-service-php-custom-params").value = mapLines(item.php?.custom_params);
|
||||
$("web-service-php-values").value = mapLines(item.php?.php_values);
|
||||
$("web-service-php-admin-values").value = mapLines(item.php?.php_admin_values);
|
||||
$("web-service-symlink-policy").value = item.symlink_policy || "deny";
|
||||
$("web-service-htaccess-enabled").checked = item.htaccess?.enabled === true;
|
||||
$("web-service-htaccess-filename").value = item.htaccess?.filename || ".htaccess";
|
||||
@@ -280,12 +285,14 @@
|
||||
preset.php.request_timeout_ms = Number($("web-service-php-request-timeout").value || preset.php.request_timeout_ms);
|
||||
preset.php.max_concurrent_requests = Number($("web-service-php-concurrency").value || preset.php.max_concurrent_requests);
|
||||
preset.php.pass_authorization = $("web-service-php-pass-authorization").checked;
|
||||
preset.php.path_info = $("web-service-php-path-info").checked;
|
||||
preset.php.max_request_body_bytes = Number($("web-service-php-body-limit").value || 0);
|
||||
preset.php.max_response_header_bytes = Number($("web-service-php-header-limit").value || preset.php.max_response_header_bytes);
|
||||
preset.php.stderr_log_bytes = Number($("web-service-php-stderr-limit").value || 0);
|
||||
preset.php.custom_params = parseFastCgiParams($("web-service-php-custom-params").value);
|
||||
preset.php.php_values = parsePhpValues($("web-service-php-values").value);
|
||||
validatePhpCustomData(preset.php.custom_params, preset.php.php_values);
|
||||
preset.php.php_admin_values = parsePhpValues($("web-service-php-admin-values").value);
|
||||
validatePhpCustomData(preset.php.custom_params, preset.php.php_values, preset.php.php_admin_values);
|
||||
preset.hardening.block_common_attacks = $("web-service-hardening-attacks").checked;
|
||||
preset.hardening.strip_unnecessary_headers = $("web-service-hardening-headers").checked;
|
||||
populateSettings(preset);
|
||||
@@ -423,6 +430,7 @@
|
||||
},
|
||||
php: {
|
||||
enabled: phpEnabled,
|
||||
path_info: $("web-service-php-path-info").checked,
|
||||
extensions: lines($("web-service-php-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()),
|
||||
endpoint: $("web-service-php-endpoint").value.trim() || "unix:/run/php/php-fpm.sock",
|
||||
connect_timeout_ms: Number($("web-service-php-connect-timeout").value || 1000),
|
||||
@@ -433,10 +441,11 @@
|
||||
max_response_header_bytes: Number($("web-service-php-header-limit").value || 65536),
|
||||
stderr_log_bytes: Number($("web-service-php-stderr-limit").value || 0),
|
||||
custom_params: parseFastCgiParams($("web-service-php-custom-params").value),
|
||||
php_values: parsePhpValues($("web-service-php-values").value)
|
||||
php_values: parsePhpValues($("web-service-php-values").value),
|
||||
php_admin_values: parsePhpValues($("web-service-php-admin-values").value)
|
||||
}
|
||||
};
|
||||
validatePhpCustomData(item.php.custom_params, item.php.php_values);
|
||||
validatePhpCustomData(item.php.custom_params, item.php.php_values, item.php.php_admin_values);
|
||||
if (phpEnabled && item.symlink_policy !== "deny") throw new Error(t("webServicePhpSymlinkConflict"));
|
||||
const next = structuredClone(envelope.config.web_services || []);
|
||||
const duplicate = next.find(service => service.id === item.id && service.id !== editing);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Desired configuration and its validated, immutable routing snapshot.
|
||||
include!("config/model.rs");
|
||||
include!("config/snapshot.rs");
|
||||
include!("config/internal_routing.rs");
|
||||
include!("config/backend.rs");
|
||||
include!("config/routing.rs");
|
||||
include!("config/compile.rs");
|
||||
|
||||
@@ -292,6 +292,7 @@ pub fn compile_with_active(
|
||||
"HTTP/3 listeners require TLS 1.3 and a TLS 1.3 cipher suite in the global TLS policy"
|
||||
);
|
||||
Ok(Snapshot {
|
||||
web_php_services: web_services,
|
||||
vhost_metrics,
|
||||
forwarding,
|
||||
quic_tls,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
impl Snapshot {
|
||||
/// Resolve a vhost-local internal named location. Internal locations are
|
||||
/// deliberately excluded from ordinary URI matching and can only be reached
|
||||
/// by an explicit internal dispatch such as an error fallback.
|
||||
pub fn select_internal_on(
|
||||
&self,
|
||||
host: &str,
|
||||
vhost_id: &str,
|
||||
target: &str,
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&CompiledRoute> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
let owns_host = self.fallbacks.get(&pattern).is_some_and(|routes| {
|
||||
routes.iter().any(|route| {
|
||||
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
});
|
||||
if !owns_host {
|
||||
continue;
|
||||
}
|
||||
return self.routes.get(&pattern).and_then(|routes| {
|
||||
routes.iter().find(|route| {
|
||||
route.internal
|
||||
&& route.name == target
|
||||
&& route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -197,13 +197,17 @@ pub struct CompiledRoute {
|
||||
pub frontend: Option<Arc<CompiledFrontend>>,
|
||||
pub pool: Option<Arc<crate::balance::Pool>>,
|
||||
pub cache: Option<bool>,
|
||||
/// Vhost-local stable location ID (`main` for the main source).
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub internal: bool,
|
||||
pub path_prefix: String,
|
||||
pub match_type: crate::vhost::PathMatch,
|
||||
pub path_regex: Option<regex::Regex>,
|
||||
pub rewrite: crate::vhost::PathRewrite,
|
||||
pub response: Option<crate::vhost::LocationResponse>,
|
||||
pub redirect: Option<CompiledLocationRedirect>,
|
||||
pub error_fallback: Option<crate::vhost::ErrorFallback>,
|
||||
pub source: CompiledSource,
|
||||
pub backend: Option<Arc<CompiledBackend>>,
|
||||
pub acl_profile_id: Option<String>,
|
||||
@@ -265,6 +269,7 @@ pub struct ResolvedLogVhost {
|
||||
}
|
||||
|
||||
pub struct Snapshot {
|
||||
pub web_php_services: HashMap<String, Arc<crate::web_server::CompiledService>>,
|
||||
pub vhost_metrics: std::collections::BTreeMap<String, Arc<crate::metrics::VhostMetrics>>,
|
||||
pub forwarding: crate::forwarding::Compiled,
|
||||
pub quic_tls: Option<Arc<quinn::crypto::rustls::QuicServerConfig>>,
|
||||
|
||||
+19
-2
@@ -91,8 +91,17 @@ fn compile_routing(
|
||||
}
|
||||
claimed.extend(host_bindings.iter().copied());
|
||||
}
|
||||
// Backend definitions are irrelevant to a Web Server main source unless
|
||||
// an enabled Custom Location explicitly uses Reverse Proxy.
|
||||
let needs_proxy_pool = host.source.is_reverse_proxy()
|
||||
|| host.routes.iter().any(|route| {
|
||||
route.enabled
|
||||
&& route.response.is_none()
|
||||
&& route.redirect.is_none()
|
||||
&& route.source.is_reverse_proxy()
|
||||
});
|
||||
let mut compiled_members = Vec::new();
|
||||
for definition in &host.backends {
|
||||
for definition in host.backends.iter().filter(|_| needs_proxy_pool) {
|
||||
let mut backend = compile_backend_with_timeout(
|
||||
&definition.backend(),
|
||||
&desired_policy,
|
||||
@@ -240,13 +249,16 @@ fn compile_routing(
|
||||
frontend: Some(frontend.clone()),
|
||||
pool: fallback_pool,
|
||||
cache: None,
|
||||
name: "main".into(),
|
||||
id: format!("{}/main", host.id),
|
||||
internal: false,
|
||||
path_prefix: "/".into(),
|
||||
match_type: crate::vhost::PathMatch::Prefix,
|
||||
path_regex: None,
|
||||
rewrite: crate::vhost::PathRewrite::default(),
|
||||
response: None,
|
||||
redirect: None,
|
||||
error_fallback: host.error_fallback.clone(),
|
||||
source: fallback_source,
|
||||
backend: fallback_backend,
|
||||
acl_profile_id: None,
|
||||
@@ -256,7 +268,7 @@ fn compile_routing(
|
||||
country_access: crate::rate_limit::CountryAccess::default(),
|
||||
method_policy: crate::vhost::MethodPolicy::default(),
|
||||
headers: crate::headers::CompiledHeaders::default(),
|
||||
unavailable: host.transient_unavailable,
|
||||
unavailable: host.transient_unavailable && host.source.is_reverse_proxy(),
|
||||
};
|
||||
for name in &routing_hosts {
|
||||
fallbacks
|
||||
@@ -305,10 +317,12 @@ fn compile_routing(
|
||||
None
|
||||
}
|
||||
};
|
||||
if !route.internal {
|
||||
ensure!(
|
||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||
"duplicate vhost location matcher"
|
||||
);
|
||||
}
|
||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||
validate_location_response(route.response.as_ref())?;
|
||||
let compiled_redirect = validate_location_redirect(route.redirect.as_ref())?;
|
||||
@@ -377,13 +391,16 @@ fn compile_routing(
|
||||
frontend: Some(frontend.clone()),
|
||||
pool: route_pool,
|
||||
cache: route.cache,
|
||||
name: route.id.clone(),
|
||||
id: format!("{}/{}", host.id, route.id),
|
||||
internal: route.internal,
|
||||
path_prefix: route.path_prefix.clone(),
|
||||
match_type: route.match_type,
|
||||
path_regex,
|
||||
rewrite: route.rewrite.clone(),
|
||||
response: route.response.clone(),
|
||||
redirect: compiled_redirect,
|
||||
error_fallback: route.error_fallback.clone(),
|
||||
source,
|
||||
backend,
|
||||
acl_profile_id: route.acl_profile_id.clone(),
|
||||
|
||||
@@ -76,6 +76,7 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn frontend(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -224,7 +225,8 @@ where
|
||||
F: Fn(&CompiledRoute) -> bool,
|
||||
{
|
||||
if let Some(exact) = routes.iter().find(|route| {
|
||||
allowed(route)
|
||||
!route.internal
|
||||
&& allowed(route)
|
||||
&& route.match_type == crate::vhost::PathMatch::Exact
|
||||
&& route.path_prefix == path
|
||||
}) {
|
||||
@@ -233,7 +235,8 @@ where
|
||||
let prefix = routes
|
||||
.iter()
|
||||
.filter(|route| {
|
||||
allowed(route)
|
||||
!route.internal
|
||||
&& allowed(route)
|
||||
&& matches!(
|
||||
route.match_type,
|
||||
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority
|
||||
@@ -245,7 +248,8 @@ where
|
||||
return prefix;
|
||||
}
|
||||
if let Some(regex) = routes.iter().find(|route| {
|
||||
allowed(route)
|
||||
!route.internal
|
||||
&& allowed(route)
|
||||
&& matches!(
|
||||
route.match_type,
|
||||
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive
|
||||
|
||||
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.29.0"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
|
||||
println!("RustProxy live logs 0.29.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs {} - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.", env!("CARGO_PKG_VERSION"));
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
include!("prometheus/model.rs");
|
||||
include!("prometheus/core.rs");
|
||||
include!("prometheus/traffic.rs");
|
||||
include!("prometheus/web_php.rs");
|
||||
include!("prometheus/vhosts_backends.rs");
|
||||
include!("prometheus/docker_misc.rs");
|
||||
|
||||
|
||||
@@ -428,6 +428,7 @@ fn render_vhosts(snapshot: &crate::config::Snapshot, f: &mut Families) {
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,7 @@ pub fn render(app: &App) -> String {
|
||||
render_core(app, &snapshot, &mut out, &mut families);
|
||||
render_vhosts(&snapshot, &mut families);
|
||||
render_traffic(app, &snapshot, &mut families);
|
||||
render_web_php(&snapshot, &mut families);
|
||||
render_backends(&snapshot, &docker_targets, &mut families);
|
||||
render_docker(&snapshot, &docker_targets, &mut families);
|
||||
render_misc(app, &snapshot, &mut families);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
fn render_web_php(snapshot: &crate::config::Snapshot, f: &mut Families) {
|
||||
let config = &snapshot.envelope.config;
|
||||
for service in &config.web_services {
|
||||
let Some(compiled) = snapshot.web_php_services.get(&service.id) else { continue };
|
||||
let web = &compiled.web_metrics;
|
||||
let wl = labels(&[("web_service", service.id.as_str())]);
|
||||
f.add("rust_proxy_web_server_requests_total", "counter", "Web Server requests completed through response headers, including PHP requests.", &wl, web.requests.load(Ordering::Relaxed));
|
||||
for (class, count) in [
|
||||
("2xx", &web.status_2xx), ("3xx", &web.status_3xx),
|
||||
("4xx", &web.status_4xx), ("5xx", &web.status_5xx),
|
||||
] {
|
||||
f.add("rust_proxy_web_server_responses_total", "counter", "Web Server responses by status class.", &format!("{wl},class=\"{class}\""), count.load(Ordering::Relaxed));
|
||||
}
|
||||
f.add("rust_proxy_web_server_response_header_duration_microseconds_total", "counter", "Web Server response header latency, excluding streamed body transfer.", &wl, web.response_headers_us.load(Ordering::Relaxed));
|
||||
let Some(php) = &compiled.php else { continue };
|
||||
let l = labels(&[("web_service", service.id.as_str())]);
|
||||
let m = &php.metrics;
|
||||
f.add("rust_proxy_php_requests_total", "counter", "PHP-FPM requests completed through response headers.", &l, m.requests.load(Ordering::Relaxed));
|
||||
for (class, count) in [
|
||||
("2xx", &m.status_2xx), ("3xx", &m.status_3xx),
|
||||
("4xx", &m.status_4xx), ("5xx", &m.status_5xx),
|
||||
] {
|
||||
f.add("rust_proxy_php_responses_total", "counter", "PHP response headers by status class.", &format!("{l},class=\"{class}\""), count.load(Ordering::Relaxed));
|
||||
}
|
||||
f.add("rust_proxy_php_response_header_duration_microseconds_total", "counter", "Cumulative PHP response header latency in microseconds, excluding streamed body time.", &l, m.response_headers_us.load(Ordering::Relaxed));
|
||||
f.add("rust_proxy_php_concurrency_available", "gauge", "Available PHP-FPM request permits in this Web Service.", &l, php.permits.available_permits());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#[derive(Clone)]
|
||||
struct InternalFallbackRoute {
|
||||
vhost_id: String,
|
||||
target: String,
|
||||
depth: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FinalRouteContext(crate::config::CompiledRoute);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ErrorFallbackRequest {
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
version: Version,
|
||||
headers: HeaderMap,
|
||||
transport: Option<Transport>,
|
||||
snapshot: Arc<crate::config::Snapshot>,
|
||||
exchange: Option<Arc<Exchange>>,
|
||||
connection_lease: Option<Arc<ConnectionLease>>,
|
||||
stop: Option<ServerShutdown>,
|
||||
depth: u8,
|
||||
}
|
||||
|
||||
impl ErrorFallbackRequest {
|
||||
fn capture(request: &Request, snapshot: &Arc<crate::config::Snapshot>) -> Self {
|
||||
Self {
|
||||
method: request.method().clone(),
|
||||
uri: request.uri().clone(),
|
||||
version: request.version(),
|
||||
headers: request.headers().clone(),
|
||||
transport: request.extensions().get::<Transport>().copied(),
|
||||
snapshot: snapshot.clone(),
|
||||
exchange: request.extensions().get::<Arc<Exchange>>().cloned(),
|
||||
connection_lease: request
|
||||
.extensions()
|
||||
.get::<Arc<ConnectionLease>>()
|
||||
.cloned(),
|
||||
stop: request.extensions().get::<ServerShutdown>().cloned(),
|
||||
depth: request
|
||||
.extensions()
|
||||
.get::<InternalFallbackRoute>()
|
||||
.map(|dispatch| dispatch.depth)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&self, body: Bytes, peer: PeerAddr, local: LocalAddr, dispatch: InternalFallbackRoute) -> Request {
|
||||
let mut request = Request::new(Body::from(body));
|
||||
*request.method_mut() = self.method.clone();
|
||||
*request.uri_mut() = self.uri.clone();
|
||||
*request.version_mut() = self.version;
|
||||
*request.headers_mut() = self.headers.clone();
|
||||
request.extensions_mut().insert(peer);
|
||||
request.extensions_mut().insert(local);
|
||||
if let Some(transport) = self.transport {
|
||||
request.extensions_mut().insert(transport);
|
||||
}
|
||||
request.extensions_mut().insert(self.snapshot.clone());
|
||||
if let Some(exchange) = &self.exchange {
|
||||
request.extensions_mut().insert(exchange.clone());
|
||||
}
|
||||
if let Some(lease) = &self.connection_lease {
|
||||
request.extensions_mut().insert(lease.clone());
|
||||
}
|
||||
if let Some(stop) = &self.stop {
|
||||
request.extensions_mut().insert(stop.clone());
|
||||
}
|
||||
request.extensions_mut().insert(dispatch);
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
enum ErrorFallbackBody {
|
||||
Disabled,
|
||||
Replayable(Bytes),
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
const MAX_ERROR_FALLBACK_DEPTH: u8 = 8;
|
||||
const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Result<ErrorFallbackBody, Response> {
|
||||
if !enabled {
|
||||
return Ok(ErrorFallbackBody::Disabled);
|
||||
}
|
||||
if request.body().is_end_stream() {
|
||||
return Ok(ErrorFallbackBody::Replayable(Bytes::new()));
|
||||
}
|
||||
let Some(length) = request.body().size_hint().exact() else {
|
||||
return Ok(ErrorFallbackBody::Unavailable);
|
||||
};
|
||||
if length > MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES {
|
||||
return Ok(ErrorFallbackBody::Unavailable);
|
||||
}
|
||||
let body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
let collected = body.collect().await.map_err(|_| {
|
||||
error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or incomplete request body\n",
|
||||
)
|
||||
})?;
|
||||
let bytes = collected.to_bytes();
|
||||
*request.body_mut() = Body::from(bytes.clone());
|
||||
Ok(ErrorFallbackBody::Replayable(bytes))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn execute_error_fallback(
|
||||
state: Arc<App>,
|
||||
peer: PeerAddr,
|
||||
local: LocalAddr,
|
||||
snapshot: &Arc<crate::config::Snapshot>,
|
||||
host: &str,
|
||||
tls: bool,
|
||||
route: &crate::config::CompiledRoute,
|
||||
status: StatusCode,
|
||||
template: &ErrorFallbackRequest,
|
||||
body: &ErrorFallbackBody,
|
||||
) -> Option<Response> {
|
||||
let fallback = route
|
||||
.error_fallback
|
||||
.as_ref()
|
||||
.filter(|fallback| fallback.matches(status))?;
|
||||
let frontend = route.frontend.as_ref()?;
|
||||
if template.depth >= MAX_ERROR_FALLBACK_DEPTH {
|
||||
tracing::warn!(vhost = %frontend.id, route = %route.id, target = %fallback.target, "error fallback depth limit reached");
|
||||
return None;
|
||||
}
|
||||
let Some(target) = snapshot
|
||||
.select_internal_on(host, &frontend.id, &fallback.target, local.0, tls)
|
||||
.cloned()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let ErrorFallbackBody::Replayable(body) = body else {
|
||||
tracing::warn!(vhost = %frontend.id, route = %route.id, target = %fallback.target, "error fallback request body is not replayable");
|
||||
return None;
|
||||
};
|
||||
let request = template.rebuild(
|
||||
body.clone(),
|
||||
peer,
|
||||
local,
|
||||
InternalFallbackRoute {
|
||||
vhost_id: frontend.id.clone(),
|
||||
target: fallback.target.clone(),
|
||||
depth: template.depth.saturating_add(1),
|
||||
},
|
||||
);
|
||||
let mut response = Box::pin(forward_request(state, peer, local, request)).await;
|
||||
if response.extensions().get::<FinalRouteContext>().is_none() {
|
||||
response.extensions_mut().insert(FinalRouteContext(target));
|
||||
}
|
||||
Some(response)
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
match upstream_result {
|
||||
Ok(Ok(mut response)) => {
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
let (origin_validation_headers, origin_plan, origin_reason, mut response) =
|
||||
include!("success_prepare.rs");
|
||||
include!("success_finish.rs")
|
||||
@@ -53,6 +59,12 @@
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
status, &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
let mut response = error(
|
||||
status,
|
||||
if status == StatusCode::SERVICE_UNAVAILABLE {
|
||||
@@ -95,6 +107,12 @@
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
StatusCode::GATEWAY_TIMEOUT, &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
error(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"RustProxy: upstream response deadline exceeded\n",
|
||||
|
||||
@@ -39,6 +39,12 @@ if route.unavailable {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
@@ -88,6 +94,12 @@ if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
response.extensions_mut().insert(StaticSourceResponse);
|
||||
return response;
|
||||
}
|
||||
@@ -108,6 +120,12 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
Err(crate::balance::SelectionFailure::Capacity) => {
|
||||
@@ -118,6 +136,12 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
};
|
||||
@@ -128,10 +152,17 @@ let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool
|
||||
)
|
||||
} else {
|
||||
let Some(backend) = route.backend.clone() else {
|
||||
return error(
|
||||
let response = error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Reverse Proxy route has no backend\n",
|
||||
);
|
||||
if let Some(fallback) = execute_error_fallback(
|
||||
state.clone(), peer, local, &snapshot, &host, tls, route,
|
||||
response.status(), &fallback_request, &fallback_body,
|
||||
).await {
|
||||
return fallback;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
(backend, None, None)
|
||||
};
|
||||
|
||||
@@ -2,6 +2,15 @@ async fn forward(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(peer): Extension<PeerAddr>,
|
||||
Extension(local): Extension<LocalAddr>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
forward_request(state, peer, local, request).await
|
||||
}
|
||||
|
||||
async fn forward_request(
|
||||
state: Arc<App>,
|
||||
peer: PeerAddr,
|
||||
local: LocalAddr,
|
||||
mut request: Request,
|
||||
) -> Response {
|
||||
if request.method() == Method::CONNECT
|
||||
@@ -88,6 +97,10 @@ async fn forward(
|
||||
.get::<Arc<crate::config::Snapshot>>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.config.load_full());
|
||||
let internal_dispatch = request
|
||||
.extensions()
|
||||
.get::<InternalFallbackRoute>()
|
||||
.cloned();
|
||||
let Ok(path) = crate::acl::routing_path(request.uri().path()) else {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -226,6 +239,7 @@ async fn forward(
|
||||
"RustProxy: denied by access policy\n",
|
||||
);
|
||||
}
|
||||
if internal_dispatch.is_none() {
|
||||
if let Err(limited) = state.rate_limiter.check(
|
||||
snapshot.envelope.revision,
|
||||
&frontend.id,
|
||||
@@ -243,6 +257,7 @@ async fn forward(
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global host ACLs remain an independent restrictive scope and also apply to
|
||||
// maintenance/fallback/direct-IP responses. Profile/vhost IP+country policy
|
||||
// was evaluated above as one scope each.
|
||||
@@ -272,7 +287,15 @@ async fn forward(
|
||||
});
|
||||
return response;
|
||||
}
|
||||
let selected = snapshot.select_on(&host, &path, local.0, tls);
|
||||
let selected = if let Some(dispatch) = &internal_dispatch {
|
||||
if frontend.is_none_or(|frontend| frontend.id != dispatch.vhost_id) {
|
||||
None
|
||||
} else {
|
||||
snapshot.select_internal_on(&host, &dispatch.vhost_id, &dispatch.target, local.0, tls)
|
||||
}
|
||||
} else {
|
||||
snapshot.select_on(&host, &path, local.0, tls)
|
||||
};
|
||||
if let Some(route) = selected {
|
||||
if let Some(response) = method_policy_denial(&route.method_policy, request.method()) {
|
||||
return response;
|
||||
@@ -306,6 +329,7 @@ async fn forward(
|
||||
);
|
||||
}
|
||||
}
|
||||
let fallback_request = ErrorFallbackRequest::capture(&request, &snapshot);
|
||||
// The admin cookie is never ordinary application state. Strip it BEFORE
|
||||
// auth/cache and keep only the removed fragment locally. A verified self-admin
|
||||
// backend may restore it after the actual upstream has been selected.
|
||||
@@ -374,6 +398,10 @@ async fn forward(
|
||||
)
|
||||
});
|
||||
};
|
||||
let fallback_body = match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
|
||||
Ok(body) => body,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let (
|
||||
backend,
|
||||
backend_lease,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
include!("types.rs");
|
||||
include!("error_fallback.rs");
|
||||
include!("cache_helpers.rs");
|
||||
include!("upstream_helpers.rs");
|
||||
include!("redirect_helpers.rs");
|
||||
|
||||
@@ -20,6 +20,7 @@ use http::{
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use http_body_util::BodyExt;
|
||||
use bytes::Bytes;
|
||||
#[derive(Clone)]
|
||||
struct ResponseRulesApplied;
|
||||
#[derive(Clone)]
|
||||
@@ -38,6 +39,7 @@ struct LocalHostResponse {
|
||||
struct OriginTiming(Duration);
|
||||
#[derive(Clone)]
|
||||
struct OriginBackend(String);
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering},
|
||||
|
||||
@@ -191,6 +191,11 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
))
|
||||
});
|
||||
let mut response = next.run(request).await;
|
||||
let final_route_context = response
|
||||
.extensions()
|
||||
.get::<FinalRouteContext>()
|
||||
.map(|context| context.0.clone());
|
||||
let effective_route_context = final_route_context.as_ref().or(route_context.as_ref());
|
||||
let minimal_welcome = response
|
||||
.extensions()
|
||||
.get::<crate::pages::MinimalWelcome>()
|
||||
@@ -255,7 +260,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
effective_route_context,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -289,7 +294,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
if let Some(frontend) = frontend_context.as_ref() {
|
||||
compression = frontend.compression.resolve(global_compression, &compression);
|
||||
}
|
||||
if let Some(route) = route_context.as_ref() {
|
||||
if let Some(route) = effective_route_context {
|
||||
if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
compression = service.compression.resolve(global_compression, &compression);
|
||||
}
|
||||
@@ -355,7 +360,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
effective_route_context,
|
||||
);
|
||||
}
|
||||
if response
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! A Host header is never evidence that traffic arrived on a private interface.
|
||||
|
||||
include!("vhost/model.rs");
|
||||
include!("vhost/error_fallback.rs");
|
||||
include!("vhost/redirect.rs");
|
||||
include!("vhost/validation.rs");
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
pub const ERROR_FALLBACK_STATUSES: [u16; 6] = [403, 404, 500, 502, 503, 504];
|
||||
|
||||
fn default_error_fallback_statuses() -> Vec<u16> {
|
||||
vec![404]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ErrorFallback {
|
||||
/// Stable ID of an internal Custom Location within the same vhost.
|
||||
pub target: String,
|
||||
/// HTTP statuses intercepted before normal response finalization/cache publication.
|
||||
#[serde(default = "default_error_fallback_statuses")]
|
||||
pub statuses: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Default for ErrorFallback {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: String::new(),
|
||||
statuses: default_error_fallback_statuses(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorFallback {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.target = self.target.trim().to_owned();
|
||||
ensure!(crate::config::valid_id(&self.target), "invalid error fallback target");
|
||||
ensure!(
|
||||
!self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(),
|
||||
"error fallback requires 1..={} status codes",
|
||||
ERROR_FALLBACK_STATUSES.len()
|
||||
);
|
||||
self.statuses.sort_unstable();
|
||||
self.statuses.dedup();
|
||||
ensure!(
|
||||
self.statuses
|
||||
.iter()
|
||||
.all(|status| ERROR_FALLBACK_STATUSES.contains(status)),
|
||||
"error fallback status must be one of 403, 404, 500, 502, 503, 504"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn matches(&self, status: http::StatusCode) -> bool {
|
||||
self.statuses.contains(&status.as_u16())
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -7,7 +7,6 @@ use crate::{
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -17,12 +16,10 @@ fn root() -> String {
|
||||
fn weight() -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
pub const DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS: u64 = 10;
|
||||
pub const DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS: u64 = 120;
|
||||
pub const DEFAULT_PROXY_READ_TIMEOUT_SECONDS: u64 = 60;
|
||||
pub const DEFAULT_PROXY_SEND_TIMEOUT_SECONDS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ProxyTimeouts {
|
||||
@@ -53,7 +50,6 @@ pub enum MethodPolicyMode {
|
||||
Allow,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MethodPolicy {
|
||||
@@ -78,7 +74,6 @@ impl MethodPolicy {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn allows(&self, method: &http::Method) -> bool {
|
||||
let listed = self
|
||||
.methods
|
||||
@@ -90,14 +85,12 @@ impl MethodPolicy {
|
||||
MethodPolicyMode::Block => !listed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allow_header(&self) -> Option<http::HeaderValue> {
|
||||
(self.mode == MethodPolicyMode::Allow)
|
||||
.then(|| self.methods.join(", ").parse::<http::HeaderValue>().ok())
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyTimeouts {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value, max) in [
|
||||
@@ -136,7 +129,6 @@ impl ProxyTimeouts {
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PathMatch {
|
||||
@@ -148,7 +140,6 @@ pub enum PathMatch {
|
||||
Regex,
|
||||
RegexInsensitive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RewriteMode {
|
||||
@@ -301,6 +292,9 @@ pub struct Vhost {
|
||||
/// Main source for the vhost fallback. Missing in older configs means Reverse Proxy.
|
||||
#[serde(default)]
|
||||
pub source: Source,
|
||||
/// Nginx-style internal error_page/proxy_intercept_errors behavior for the main source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_fallback: Option<ErrorFallback>,
|
||||
#[serde(default)]
|
||||
pub backends: Vec<PoolMember>,
|
||||
#[serde(default)]
|
||||
@@ -461,6 +455,10 @@ pub struct PathRoute {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_id: Option<String>,
|
||||
pub id: String,
|
||||
/// Internal named location. It is never selected by the public URI matcher and
|
||||
/// can only be reached through an error fallback from this vhost.
|
||||
#[serde(default)]
|
||||
pub internal: bool,
|
||||
/// Prefix/exact path or a Rust-regex pattern, depending on `match_type`.
|
||||
#[serde(default = "root")]
|
||||
pub path_prefix: String,
|
||||
@@ -492,4 +490,8 @@ pub struct PathRoute {
|
||||
/// Local HTTP redirect; mutually exclusive with `response`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub redirect: Option<LocationRedirect>,
|
||||
/// Intercept selected upstream/Web Server error statuses and internally dispatch
|
||||
/// the original request to another internal Custom Location.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_fallback: Option<ErrorFallback>,
|
||||
}
|
||||
@@ -147,6 +147,44 @@ mod default_listener_tests {
|
||||
.unwrap();
|
||||
assert!(vhost.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_fallback_requires_internal_target_and_rejects_cycles() {
|
||||
let base = json!({
|
||||
"id": "fallback-test",
|
||||
"frontend": {"hosts": ["example.test"], "listener_ids": ["http"]},
|
||||
"backends": [{"id": "origin", "url": "http://127.0.0.1:9001", "allow_private": true}],
|
||||
"error_fallback": {"target": "redirect-check", "statuses": [503, 404, 404]},
|
||||
"routes": [{
|
||||
"id": "redirect-check",
|
||||
"internal": true,
|
||||
"path_prefix": "/",
|
||||
"source": {"type": "reverse_proxy"}
|
||||
}]
|
||||
});
|
||||
let mut valid: Vhost = serde_json::from_value(base.clone()).unwrap();
|
||||
valid.validate().unwrap();
|
||||
assert_eq!(valid.error_fallback.as_ref().unwrap().statuses, vec![404, 503]);
|
||||
|
||||
let mut public_target: Vhost = serde_json::from_value(base.clone()).unwrap();
|
||||
public_target.routes[0].internal = false;
|
||||
assert!(public_target
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("internal location"));
|
||||
|
||||
let mut cyclic: Vhost = serde_json::from_value(base).unwrap();
|
||||
cyclic.routes[0].error_fallback = Some(ErrorFallback {
|
||||
target: "redirect-check".into(),
|
||||
statuses: vec![404],
|
||||
});
|
||||
assert!(cyclic
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("recursive error fallback"));
|
||||
}
|
||||
#[test]
|
||||
fn collision_explains_addresses_owners_and_protocol_limits() {
|
||||
let mut config = Config::default();
|
||||
|
||||
@@ -4,6 +4,7 @@ impl Default for PathRoute {
|
||||
source: Source::default(),
|
||||
backend_id: None,
|
||||
id: "root".into(),
|
||||
internal: false,
|
||||
path_prefix: root(),
|
||||
match_type: PathMatch::Prefix,
|
||||
enabled: true,
|
||||
@@ -16,6 +17,7 @@ impl Default for PathRoute {
|
||||
rewrite: PathRewrite::default(),
|
||||
response: None,
|
||||
redirect: None,
|
||||
error_fallback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +33,7 @@ impl PathRoute {
|
||||
&& self.enabled
|
||||
&& self.source == Source::ReverseProxy
|
||||
&& self.backend_id.is_none()
|
||||
&& !self.internal
|
||||
&& self.cache.is_none()
|
||||
&& self.acl_profile_id.is_none()
|
||||
&& self.acl.is_none()
|
||||
@@ -42,6 +45,7 @@ impl PathRoute {
|
||||
&& self.rewrite.value.is_empty()
|
||||
&& self.response.is_none()
|
||||
&& self.redirect.is_none()
|
||||
&& self.error_fallback.is_none()
|
||||
}
|
||||
}
|
||||
impl Vhost {
|
||||
@@ -140,7 +144,15 @@ impl Vhost {
|
||||
// Normalize the untouched pre-0.16 synthetic root route out of the custom
|
||||
// location list so adding/removing Custom locations cannot delete fallback.
|
||||
self.routes.retain(|route| !route.is_legacy_main_fallback());
|
||||
if let Some(fallback) = &mut self.error_fallback {
|
||||
fallback.validate()?;
|
||||
}
|
||||
let mut route_ids = std::collections::HashSet::new();
|
||||
for route in &mut self.routes {
|
||||
ensure!(
|
||||
crate::config::valid_id(&route.id) && route_ids.insert(route.id.clone()),
|
||||
"invalid/duplicate vhost path id"
|
||||
);
|
||||
route.source.validate()?;
|
||||
ensure!(
|
||||
route.response.is_none() || route.redirect.is_none(),
|
||||
@@ -155,11 +167,55 @@ impl Vhost {
|
||||
route.source.is_reverse_proxy() || route.backend_id.is_none(),
|
||||
"Web Server location cannot pin a reverse-proxy backend"
|
||||
);
|
||||
if let Some(fallback) = &mut route.error_fallback {
|
||||
fallback.validate()?;
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
self.routes.len() <= 64,
|
||||
"at most 64 custom locations per vhost"
|
||||
);
|
||||
let resolve_target = |target: &str| {
|
||||
self.routes
|
||||
.iter()
|
||||
.find(|route| route.id == target && route.enabled && route.internal)
|
||||
};
|
||||
if let Some(fallback) = &self.error_fallback {
|
||||
ensure!(
|
||||
resolve_target(&fallback.target).is_some(),
|
||||
"vhost error fallback target must reference an enabled internal location"
|
||||
);
|
||||
}
|
||||
for route in &self.routes {
|
||||
if let Some(fallback) = &route.error_fallback {
|
||||
ensure!(
|
||||
resolve_target(&fallback.target).is_some(),
|
||||
"location error fallback target must reference an enabled internal location"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reject cycles at configuration time; a runtime depth guard remains as a
|
||||
// second line of defense for hot-reload/state inconsistencies.
|
||||
let mut starts = vec![None];
|
||||
starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str())));
|
||||
for start in starts {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut current = start;
|
||||
loop {
|
||||
let key = current.unwrap_or("<main>");
|
||||
ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected");
|
||||
let fallback = match current {
|
||||
None => self.error_fallback.as_ref(),
|
||||
Some(id) => self
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.id == id)
|
||||
.and_then(|route| route.error_fallback.as_ref()),
|
||||
};
|
||||
let Some(fallback) = fallback else { break };
|
||||
current = Some(fallback.target.as_str());
|
||||
}
|
||||
}
|
||||
self.load_balancing.validate()?;
|
||||
self.proxy_timeouts.validate()?;
|
||||
self.rate_limit.validate()?;
|
||||
|
||||
@@ -242,11 +242,14 @@ impl Service {
|
||||
);
|
||||
self.php.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.php.custom_params))?;
|
||||
self.php.php_values = normalize_php_values(std::mem::take(&mut self.php.php_values))?;
|
||||
self.php.php_admin_values = normalize_php_values(std::mem::take(&mut self.php.php_admin_values))?;
|
||||
ensure!(!self.php.php_values.keys().any(|key| self.php.php_admin_values.contains_key(key)), "PHP_VALUE and PHP_ADMIN_VALUE must not define the same directive");
|
||||
let php_custom_bytes = self
|
||||
.php
|
||||
.custom_params
|
||||
.iter()
|
||||
.chain(self.php.php_values.iter())
|
||||
.chain(self.php.php_admin_values.iter())
|
||||
.map(|(name, value)| name.len() + value.len() + 2)
|
||||
.sum::<usize>();
|
||||
ensure!(
|
||||
@@ -350,6 +353,7 @@ impl Service {
|
||||
let php = if self.php.enabled {
|
||||
Some(CompiledPhp {
|
||||
extensions: self.php.extensions.iter().cloned().collect(),
|
||||
path_info: self.php.path_info,
|
||||
endpoint: FastCgiEndpoint::parse(&self.php.endpoint)?,
|
||||
connect_timeout: Duration::from_millis(self.php.connect_timeout_ms),
|
||||
request_timeout: Duration::from_millis(self.php.request_timeout_ms),
|
||||
@@ -360,11 +364,14 @@ impl Service {
|
||||
stderr_log_bytes: self.php.stderr_log_bytes,
|
||||
custom_params: self.php.custom_params.clone(),
|
||||
php_values: self.php.php_values.clone(),
|
||||
php_admin_values: self.php.php_admin_values.clone(),
|
||||
metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(CompiledService {
|
||||
web_metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()),
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
root,
|
||||
|
||||
@@ -257,6 +257,9 @@ impl Default for Precompressed {
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Php {
|
||||
pub enabled: bool,
|
||||
/// Safely split `/script.php/extra/path` into SCRIPT_FILENAME + PATH_INFO.
|
||||
/// Disabled by default because it changes URL-to-script resolution semantics.
|
||||
pub path_info: bool,
|
||||
#[serde(default = "default_php_extensions")]
|
||||
pub extensions: Vec<String>,
|
||||
/// `unix:/run/php/php-fpm.sock` or `tcp:127.0.0.1:9000`.
|
||||
@@ -282,12 +285,14 @@ pub struct Php {
|
||||
pub custom_params: BTreeMap<String, String>,
|
||||
/// Trusted operator-defined PHP INI overrides encoded through a single PHP_VALUE parameter.
|
||||
pub php_values: BTreeMap<String, String>,
|
||||
pub php_admin_values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for Php {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
path_info: false,
|
||||
extensions: default_php_extensions(),
|
||||
endpoint: "unix:/run/php/php-fpm.sock".into(),
|
||||
connect_timeout_ms: default_php_connect_timeout_ms(),
|
||||
@@ -299,6 +304,7 @@ impl Default for Php {
|
||||
stderr_log_bytes: default_php_stderr_log_bytes(),
|
||||
custom_params: BTreeMap::new(),
|
||||
php_values: BTreeMap::new(),
|
||||
php_admin_values: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,23 +24,27 @@ impl FastCgiEndpoint {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CompiledPhp {
|
||||
pub(crate) struct CompiledPhp {
|
||||
pub(super) extensions: HashSet<String>,
|
||||
pub(super) path_info: bool,
|
||||
pub(super) endpoint: FastCgiEndpoint,
|
||||
pub(super) connect_timeout: Duration,
|
||||
pub(super) request_timeout: Duration,
|
||||
pub(super) permits: Arc<Semaphore>,
|
||||
pub(crate) permits: Arc<Semaphore>,
|
||||
pub(super) pass_authorization: bool,
|
||||
pub(super) max_request_body_bytes: u64,
|
||||
pub(super) max_response_header_bytes: usize,
|
||||
pub(super) stderr_log_bytes: usize,
|
||||
pub(super) custom_params: BTreeMap<String, String>,
|
||||
pub(super) php_values: BTreeMap<String, String>,
|
||||
pub(super) php_admin_values: BTreeMap<String, String>,
|
||||
pub(crate) metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledService {
|
||||
pub id: String,
|
||||
pub(crate) web_metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
pub name: String,
|
||||
pub(super) root: PathBuf,
|
||||
pub(super) index: Vec<String>,
|
||||
@@ -57,7 +61,7 @@ pub struct CompiledService {
|
||||
pub(super) download_extensions: HashSet<String>,
|
||||
pub(super) symlink_policy: SymlinkPolicy,
|
||||
pub(super) script_extensions: HashSet<String>,
|
||||
pub(super) php: Option<CompiledPhp>,
|
||||
pub(crate) php: Option<CompiledPhp>,
|
||||
pub(super) htaccess: Option<htaccess::Compiled>,
|
||||
pub(super) hardening: Hardening,
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -123,6 +123,7 @@ pub(super) fn fastcgi_params(
|
||||
headers: &HeaderMap,
|
||||
original_uri: &Uri,
|
||||
script_path: &str,
|
||||
path_info: Option<&str>,
|
||||
query: Option<&str>,
|
||||
context: &RequestContext,
|
||||
) -> Result<Vec<u8>> {
|
||||
@@ -141,6 +142,9 @@ pub(super) fn fastcgi_params(
|
||||
params.insert("REQUEST_METHOD".into(), method.as_str().into());
|
||||
params.insert("SCRIPT_FILENAME".into(), script_filename);
|
||||
params.insert("SCRIPT_NAME".into(), script_path.into());
|
||||
if let Some(path_info) = path_info.filter(|value| !value.is_empty()) {
|
||||
params.insert("PATH_INFO".into(), path_info.into());
|
||||
}
|
||||
params.insert("QUERY_STRING".into(), query.unwrap_or("").into());
|
||||
params.insert("REQUEST_URI".into(), request_uri.into());
|
||||
params.insert("DOCUMENT_ROOT".into(), document_root);
|
||||
@@ -159,7 +163,7 @@ pub(super) fn fastcgi_params(
|
||||
if context.tls { "on" } else { "off" }.into(),
|
||||
);
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.29.0".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), concat!("RustProxy/", env!("CARGO_PKG_VERSION")).into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers
|
||||
.get(CONTENT_TYPE)
|
||||
@@ -213,6 +217,10 @@ pub(super) fn fastcgi_params(
|
||||
}
|
||||
params.insert("PHP_VALUE".into(), value);
|
||||
}
|
||||
if !php.php_admin_values.is_empty() {
|
||||
let value = php.php_admin_values.iter().map(|(name, item)| format!("{name}={item}")).collect::<Vec<_>>().join("\n");
|
||||
params.insert("PHP_ADMIN_VALUE".into(), value);
|
||||
}
|
||||
// Request headers cannot synthesize PHP_VALUE/PHP_ADMIN_VALUE because only HTTP_*
|
||||
// names are derived above. PHP_VALUE comes exclusively from validated operator config.
|
||||
let mut encoded = Vec::with_capacity(4096);
|
||||
|
||||
@@ -5,6 +5,25 @@ pub(super) async fn php_response(
|
||||
headers: HeaderMap,
|
||||
original_uri: Uri,
|
||||
script_path: String,
|
||||
path_info: Option<String>,
|
||||
query: Option<String>,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let response = php_response_inner( service, php, method, headers, original_uri, script_path, path_info, query, body, context).await;
|
||||
php.metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64);
|
||||
response
|
||||
}
|
||||
|
||||
async fn php_response_inner(
|
||||
service: &CompiledService,
|
||||
php: &CompiledPhp,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
original_uri: Uri,
|
||||
script_path: String,
|
||||
path_info: Option<String>,
|
||||
query: Option<String>,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
@@ -45,6 +64,7 @@ pub(super) async fn php_response(
|
||||
&headers,
|
||||
&original_uri,
|
||||
&script_path,
|
||||
path_info.as_deref(),
|
||||
query.as_deref(),
|
||||
&context,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Fixed-cardinality PHP counters. Values are per compiled Web Service snapshot.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PhpMetrics {
|
||||
pub(crate) requests: AtomicU64,
|
||||
pub(crate) status_2xx: AtomicU64,
|
||||
pub(crate) status_3xx: AtomicU64,
|
||||
pub(crate) status_4xx: AtomicU64,
|
||||
pub(crate) status_5xx: AtomicU64,
|
||||
pub(crate) response_headers_us: AtomicU64,
|
||||
}
|
||||
|
||||
impl PhpMetrics {
|
||||
pub fn record(&self, status: http::StatusCode, elapsed_us: u64) {
|
||||
self.requests.fetch_add(1, Ordering::Relaxed);
|
||||
let counter = match status.as_u16() / 100 {
|
||||
2 => Some(&self.status_2xx),
|
||||
3 => Some(&self.status_3xx),
|
||||
4 => Some(&self.status_4xx),
|
||||
5 => Some(&self.status_5xx),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(counter) = counter { counter.fetch_add(1, Ordering::Relaxed); }
|
||||
self.response_headers_us.fetch_add(elapsed_us, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod config;
|
||||
mod fastcgi;
|
||||
mod filesystem;
|
||||
mod htaccess;
|
||||
mod metrics;
|
||||
mod request;
|
||||
mod response;
|
||||
mod routing;
|
||||
|
||||
@@ -30,9 +30,9 @@ pub(super) async fn file_response(
|
||||
}
|
||||
|
||||
let range_header = headers.get(RANGE).and_then(|value| value.to_str().ok());
|
||||
let range = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) {
|
||||
match range_header.and_then(|value| parse_range(value, length)) {
|
||||
Some(range) => Some(range),
|
||||
let ranges = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) {
|
||||
match range_header.and_then(|value| parse_ranges(value, length)) {
|
||||
Some(ranges) => Some(ranges),
|
||||
None => {
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||
@@ -64,25 +64,49 @@ pub(super) async fn file_response(
|
||||
None
|
||||
};
|
||||
|
||||
let (status, start, body_length) = match range {
|
||||
Some(range) => (
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
range.start,
|
||||
range.end - range.start + 1,
|
||||
),
|
||||
let multipart = ranges.as_ref().is_some_and(|ranges| ranges.len() > 1);
|
||||
let single_range = ranges.as_ref().and_then(|ranges| (ranges.len() == 1).then_some(ranges[0]));
|
||||
let (status, start, body_length) = match single_range {
|
||||
Some(range) => (StatusCode::PARTIAL_CONTENT, range.start, range.end - range.start + 1),
|
||||
None if multipart => (StatusCode::PARTIAL_CONTENT, 0, 0),
|
||||
None => (StatusCode::OK, 0, length),
|
||||
};
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = status;
|
||||
let boundary = multipart.then(|| format!("rustproxy-{:016x}", rand::random::<u64>()));
|
||||
if let Some(boundary) = &boundary {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str(&format!("multipart/byteranges; boundary={boundary}")).unwrap(),
|
||||
);
|
||||
let mime_text = mime.to_str().unwrap_or("application/octet-stream");
|
||||
let mut total = 0u64;
|
||||
for range in ranges.as_ref().expect("multipart range set") {
|
||||
let header = format!(
|
||||
"--{boundary}\r\nContent-Type: {mime_text}\r\nContent-Range: bytes {}-{}/{length}\r\n\r\n",
|
||||
range.start, range.end
|
||||
);
|
||||
total = total
|
||||
.saturating_add(header.len() as u64)
|
||||
.saturating_add(range.end - range.start + 1)
|
||||
.saturating_add(2);
|
||||
}
|
||||
total = total.saturating_add(format!("--{boundary}--\r\n").len() as u64);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&total.to_string()).unwrap(),
|
||||
);
|
||||
} else {
|
||||
response.headers_mut().insert(CONTENT_TYPE, mime.clone());
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&body_length.to_string()).unwrap(),
|
||||
);
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
if let Some(range) = range {
|
||||
if let Some(range) = single_range {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_RANGE,
|
||||
HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end))
|
||||
@@ -100,11 +124,39 @@ pub(super) async fn file_response(
|
||||
.headers_mut()
|
||||
.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
}
|
||||
if method == Method::HEAD || body_length == 0 {
|
||||
if method == Method::HEAD || (!multipart && body_length == 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::from_std(opened.file);
|
||||
if let Some(boundary) = boundary {
|
||||
let ranges = ranges.expect("multipart ranges");
|
||||
let mime_text = mime.to_str().unwrap_or("application/octet-stream").to_owned();
|
||||
let (mut writer, reader) = tokio::io::duplex(64 * 1024);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
for range in ranges {
|
||||
let header = format!(
|
||||
"--{boundary}\r\nContent-Type: {mime_text}\r\nContent-Range: bytes {}-{}/{length}\r\n\r\n",
|
||||
range.start, range.end
|
||||
);
|
||||
if writer.write_all(header.as_bytes()).await.is_err()
|
||||
|| file.seek(std::io::SeekFrom::Start(range.start)).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut limited = (&mut file).take(range.end - range.start + 1);
|
||||
if tokio::io::copy(&mut limited, &mut writer).await.is_err()
|
||||
|| writer.write_all(b"\r\n").await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = writer.write_all(format!("--{boundary}--\r\n").as_bytes()).await;
|
||||
});
|
||||
*response.body_mut() = Body::from_stream(ReaderStream::new(reader));
|
||||
return response;
|
||||
}
|
||||
if start != 0 {
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return status_response(
|
||||
@@ -185,7 +237,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
|
||||
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
|
||||
));
|
||||
}
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.29.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.push_str(&format!(r#"</div></section><footer class="page-footer"><span>RustProxy <b>{}</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#, env!("CARGO_PKG_VERSION")));
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -308,33 +308,52 @@ struct ByteRange {
|
||||
end: u64,
|
||||
}
|
||||
|
||||
fn parse_range(value: &str, length: u64) -> Option<ByteRange> {
|
||||
let value = value.strip_prefix("bytes=")?.trim();
|
||||
if value.contains(',') || value.is_empty() || length == 0 {
|
||||
return None;
|
||||
const MAX_BYTE_RANGES: usize = 16;
|
||||
|
||||
fn parse_range_item(value: &str, length: u64) -> Result<Option<ByteRange>, ()> {
|
||||
if length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (start, end) = value.split_once('-')?;
|
||||
let (start, end) = value.split_once('-').ok_or(())?;
|
||||
if start.is_empty() {
|
||||
let suffix: u64 = end.parse().ok()?;
|
||||
let suffix: u64 = end.parse().map_err(|_| ())?;
|
||||
if suffix == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let size = suffix.min(length);
|
||||
return Some(ByteRange {
|
||||
return Ok(Some(ByteRange {
|
||||
start: length - size,
|
||||
end: length - 1,
|
||||
});
|
||||
}));
|
||||
}
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let start: u64 = start.parse().map_err(|_| ())?;
|
||||
if start >= length {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let end = if end.is_empty() {
|
||||
length - 1
|
||||
} else {
|
||||
end.parse::<u64>().ok()?.min(length - 1)
|
||||
end.parse::<u64>().map_err(|_| ())?.min(length - 1)
|
||||
};
|
||||
(end >= start).then_some(ByteRange { start, end })
|
||||
Ok((end >= start).then_some(ByteRange { start, end }))
|
||||
}
|
||||
|
||||
fn parse_ranges(value: &str, length: u64) -> Option<Vec<ByteRange>> {
|
||||
let value = value.strip_prefix("bytes=")?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut ranges = Vec::new();
|
||||
for (index, item) in value.split(',').enumerate() {
|
||||
if index >= MAX_BYTE_RANGES {
|
||||
return None;
|
||||
}
|
||||
match parse_range_item(item.trim(), length).ok()? {
|
||||
Some(range) => ranges.push(range),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
(!ranges.is_empty()).then_some(ranges)
|
||||
}
|
||||
|
||||
fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
|
||||
|
||||
@@ -126,6 +126,16 @@ pub(super) async fn select_precompressed(
|
||||
if !service.precompressed.enabled || components.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Multipart byte ranges mix MIME boundaries with representation bytes. Keep
|
||||
// those responses on the identity representation so Content-Encoding never
|
||||
// describes only the individual file fragments.
|
||||
if headers
|
||||
.get("range")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains(','))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let candidates = [
|
||||
(service.precompressed.brotli, "br", ".br"),
|
||||
(service.precompressed.gzip, "gzip", ".gz"),
|
||||
|
||||
+57
-1
@@ -20,6 +20,35 @@ use http::{
|
||||
};
|
||||
use std::{io, path::PathBuf};
|
||||
|
||||
async fn resolve_php_path_info(
|
||||
service: &CompiledService,
|
||||
components: &[String],
|
||||
) -> Option<(String, String)> {
|
||||
let php = service.php.as_ref().filter(|php| php.path_info)?;
|
||||
if components.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
for script_end in 0..components.len() - 1 {
|
||||
let script_path = format!("/{}", components[..=script_end].join("/"));
|
||||
if !php.extensions.iter().any(|extension| {
|
||||
PathBuf::from(&script_path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(extension))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
match open_components(service, components[..=script_end].to_vec()).await {
|
||||
Ok(opened) if opened.metadata.is_file() => {
|
||||
let path_info = format!("/{}", components[script_end + 1..].join("/"));
|
||||
return Some((script_path, path_info));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn serve_inner(
|
||||
service: &CompiledService,
|
||||
method: Method,
|
||||
@@ -104,11 +133,34 @@ async fn serve_inner(
|
||||
{
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
let mut request_body = Some(body);
|
||||
let opened = match open_components(service, components.clone()).await {
|
||||
Ok(opened) => opened,
|
||||
Err(error)
|
||||
if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) =>
|
||||
{
|
||||
if let Some((script_path, path_info)) = resolve_php_path_info(service, &components).await {
|
||||
let public_path = PathBuf::from(&script_path);
|
||||
if let Some(php) = php_for_path(service, &public_path) {
|
||||
return php_response(
|
||||
service,
|
||||
php,
|
||||
method,
|
||||
headers,
|
||||
uri,
|
||||
script_path,
|
||||
Some(path_info),
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return io_response(&error);
|
||||
}
|
||||
Err(error) => return io_response(&error),
|
||||
};
|
||||
let mut request_body = Some(body);
|
||||
|
||||
if opened.metadata.is_file() {
|
||||
let public_path = PathBuf::from(&effective_path);
|
||||
@@ -120,6 +172,7 @@ async fn serve_inner(
|
||||
headers,
|
||||
uri,
|
||||
effective_path,
|
||||
None,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
@@ -172,6 +225,7 @@ async fn serve_inner(
|
||||
headers,
|
||||
uri,
|
||||
script_path,
|
||||
None,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
@@ -289,9 +343,11 @@ pub async fn serve(
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let start = std::time::Instant::now();
|
||||
let mut response = serve_inner(service, method, headers, uri, path, body, context).await;
|
||||
add_service_headers(&mut response, service);
|
||||
htaccess::apply_response(service.htaccess.as_ref(), &mut response, path);
|
||||
strip_unnecessary_headers(&mut response, service);
|
||||
service.web_metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64);
|
||||
response
|
||||
}
|
||||
@@ -182,6 +182,43 @@ async fn validators_and_single_ranges_work() {
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_byte_ranges_stream_each_requested_part() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, "bytes=0-1,6-8".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap().to_owned();
|
||||
assert!(content_type.starts_with("multipart/byteranges; boundary="));
|
||||
assert!(!response.headers().contains_key(CONTENT_RANGE));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body.contains("Content-Range: bytes 0-1/10"));
|
||||
assert!(body.contains("Content-Range: bytes 6-8/10"));
|
||||
assert!(body.contains("\r\n01\r\n"));
|
||||
assert!(body.contains("\r\n678\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn too_many_byte_ranges_are_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let ranges = std::iter::repeat("0-0").take(17).collect::<Vec<_>>().join(",");
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, format!("bytes={ranges}").parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_escapes_html_and_encodes_urls() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.29.0</b>"));
|
||||
assert!(body.contains(&format!("RustProxy <b>{}</b>", env!("CARGO_PKG_VERSION"))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -22,6 +22,7 @@ fn fastcgi_never_forwards_proxy_header_as_http_proxy() {
|
||||
&headers,
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
@@ -69,6 +70,7 @@ fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: None,
|
||||
@@ -86,6 +88,49 @@ fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
assert!(!encoded.windows(b"HTTP_AUTHORIZATION".len()).any(|window| window == b"HTTP_AUTHORIZATION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_path_info_keeps_script_filename_separate() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-path-info".into(),
|
||||
name: "PHP PATH_INFO".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
path_info: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
assert!(service.php.as_ref().unwrap().path_info);
|
||||
let uri: Uri = "/index.php/foo/bar?q=1".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
service.php.as_ref().unwrap(),
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
&uri,
|
||||
"/index.php",
|
||||
Some("/foo/bar"),
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 80,
|
||||
tls: false,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(encoded.windows(b"PATH_INFO".len()).any(|window| window == b"PATH_INFO"));
|
||||
assert!(encoded.windows(b"/foo/bar".len()).any(|window| window == b"/foo/bar"));
|
||||
assert!(encoded.windows(b"SCRIPT_FILENAME".len()).any(|window| window == b"SCRIPT_FILENAME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -209,3 +254,34 @@ async fn hardening_strips_fingerprint_headers_after_source_rules() {
|
||||
assert!(!response.headers().contains_key("x-powered-by"));
|
||||
assert_eq!(response.headers()["x-custom"], "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_admin_values_are_operator_only_and_serialized() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-admin".into(), name: "PHP admin".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true, endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
php_admin_values: BTreeMap::from([("open_basedir".into(), "/var/www/webmail/:/tmp/".into())]),
|
||||
..Default::default()
|
||||
}, ..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service, service.php.as_ref().unwrap(), &Method::GET, &HeaderMap::new(),
|
||||
&"/index.php".parse().unwrap(), "/index.php", None, None,
|
||||
&RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None,
|
||||
server_name: "webmail.example.test".into(), server_port: 8589, tls: false,
|
||||
version: Version::HTTP_11 },
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"PHP_ADMIN_VALUE".len()).any(|w| w == b"PHP_ADMIN_VALUE"));
|
||||
assert!(encoded.windows(b"open_basedir=/var/www/webmail/:/tmp/".len())
|
||||
.any(|w| w == b"open_basedir=/var/www/webmail/:/tmp/"));
|
||||
configured.php.php_values.insert("open_basedir".into(), "/".into());
|
||||
assert!(configured.validate().is_err());
|
||||
configured.php.php_values.clear();
|
||||
configured.php.php_admin_values.insert("unsafe".into(), "value\nother=1".into());
|
||||
assert!(configured.validate().is_err());
|
||||
}
|
||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.29.0"
|
||||
assert manifest["package"]["version"] == "0.29.2"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"]
|
||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
||||
lock_path = root / "Cargo.lock"
|
||||
if lock_path.exists():
|
||||
lock = lock_path.read_text()
|
||||
assert 'name = "rust-proxy"\nversion = "0.29.0"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.29.2"' in lock
|
||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||
for release_doc, marker in {
|
||||
"README.md": "# RustProxy 0.29.0",
|
||||
"CHANGELOG.md": "## 0.29.0",
|
||||
"ROADMAP.md": "## 0.29.0",
|
||||
"AGENTS.md": "Current source version: 0.29.0.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.0",
|
||||
"README.md": "# RustProxy 0.29.2",
|
||||
"CHANGELOG.md": "## 0.29.2",
|
||||
"ROADMAP.md": "## 0.29.2",
|
||||
"AGENTS.md": "Current source version: 0.29.2.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.2",
|
||||
}.items():
|
||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||
for marker in ['"version": "0.29.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
for marker in ['"version": "0.29.2"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
assert marker in openapi, marker
|
||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
@@ -1096,7 +1096,7 @@ assert 'throw fieldError(row.querySelector(".docker-agent-listener-choices")' in
|
||||
assert '#docker-agents-form>.form-actions {' in style_source and 'justify-content: flex-start' in style_source
|
||||
deploy_source = read_deploy_source()
|
||||
assert "SHELL_PATH_PROFILE = Path('/etc/profile.d/rustproxy-path.sh')" in deploy_source
|
||||
assert 'install_shell_path(prefix)' in deploy_source and 'check_shell_path(prefix)' in deploy_source and "'shell_path_action':'install' if args.mode == 'install' else 'check-only'" in deploy_source
|
||||
assert 'install_shell_path(prefix)' in deploy_source and 'check_shell_path(prefix)' in deploy_source and 'install_logrotate(' in deploy_source and "'shell_path_action':'install-or-repair'" in deploy_source
|
||||
assert 'grid-template-columns: minmax(0, 1fr)' in style_source and '.log-column-option:hover' in style_source
|
||||
assert '.check>span {' in style_source and 'transform: translateY(-1px)' in style_source and '.cache-toolbar-status .check' in style_source and '.docker-toolbar .check' in style_source and 'grid-column: span 3' in style_source
|
||||
assert '.docker-alias-field>span:first-child {' in style_source and 'margin-bottom: 0' in style_source and '.docker-alias-editor {' in style_source and 'min-height: 38px' in style_source and 'height: 30px' in style_source
|
||||
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.29.0"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.29.2"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
@@ -1518,7 +1518,35 @@ assert 'listener-active-item' in runtime_web_0280 and 'listener-active-feature'
|
||||
assert 'cache-via-enabled' in cache_web_0280 and 'via_header:' in cache_web_0280
|
||||
assert 'location-header-direction location-header-editor' in vhost_core_0280
|
||||
|
||||
# 0.29.0 redirect/health/cache/gzip-log contracts.
|
||||
# 0.29.2 internal-error/multipart/PATH_INFO contracts.
|
||||
vhost_model_0291 = rust_source(root, "src/vhost.rs")
|
||||
vhost_validation_0291 = rust_source(root, "src/vhost/validation.rs")
|
||||
config_snapshot_0291 = rust_source(root, "src/config/internal_routing.rs")
|
||||
proxy_types_0291 = rust_source(root, "src/proxy/error_fallback.rs")
|
||||
proxy_response_0291 = rust_source(root, "src/proxy/forward/response.rs")
|
||||
web_headers_0291 = rust_source(root, "src/web_server/response/headers.rs")
|
||||
web_file_0291 = rust_source(root, "src/web_server/response/file.rs")
|
||||
php_protocol_0291 = rust_source(root, "src/web_server/fastcgi/protocol.rs")
|
||||
php_model_0291 = rust_source(root, "src/web_server/config/model.rs")
|
||||
prometheus_0291 = rust_source(root, "src/prometheus/core.rs")
|
||||
for marker_0291 in ['pub struct ErrorFallback', 'ERROR_FALLBACK_STATUSES', 'pub internal: bool', 'pub error_fallback: Option<ErrorFallback>']:
|
||||
assert marker_0291 in vhost_model_0291, marker_0291
|
||||
assert 'recursive error fallback loop detected' in vhost_validation_0291
|
||||
assert 'select_internal_on' in config_snapshot_0291
|
||||
for marker_0291 in ['MAX_ERROR_FALLBACK_DEPTH', 'MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES', 'execute_error_fallback']:
|
||||
assert marker_0291 in proxy_types_0291, marker_0291
|
||||
assert 'execute_error_fallback' in proxy_response_0291
|
||||
assert 'MAX_BYTE_RANGES: usize = 16' in web_headers_0291 and 'parse_ranges' in web_headers_0291
|
||||
assert 'multipart/byteranges' in web_file_0291
|
||||
assert 'pub path_info: bool' in php_model_0291 and 'PATH_INFO' in php_protocol_0291
|
||||
for marker_0291 in ['rust_proxy_vhost_error_fallback_triggered_total', 'rust_proxy_vhost_error_fallback_successful_total', 'rust_proxy_vhost_error_fallback_failed_total']:
|
||||
assert marker_0291 not in prometheus_0291, marker_0291
|
||||
assert 'unavailable: host.transient_unavailable && host.source.is_reverse_proxy()' in rust_source(root, "src/config/routing.rs")
|
||||
assert 'filter(|_| needs_proxy_pool)' in rust_source(root, "src/config/routing.rs")
|
||||
assert (root / 'docs/V0.29.1_IMPLEMENTATION.md').is_file()
|
||||
assert '"ErrorFallback"' in openapi and '"path_info"' in openapi and '"internal"' in openapi
|
||||
|
||||
# Retained 0.29.0 redirect/health/cache/gzip-log contracts.
|
||||
vhost_model_0290 = rust_source(root, "src/vhost/model.rs")
|
||||
vhost_validation_0290 = rust_source(root, "src/vhost/validation.rs")
|
||||
config_model_0290 = rust_source(root, "src/config/model.rs")
|
||||
@@ -1534,7 +1562,7 @@ for marker_0290 in ['www_redirect', 'redirect: Option<LocationRedirect>']:
|
||||
for marker_0290 in ['routing_hosts', 'www_redirect_pair']:
|
||||
assert marker_0290 in vhost_validation_0290, marker_0290
|
||||
assert 'www_redirect: Option<(String, String)>' in config_model_0290
|
||||
assert 'health_check.host.is_none()' in config_routing_0290 and 'HealthMode::Http' in config_routing_0290
|
||||
assert 'health_check.host.is_none()' in config_routing_0290 and 'HealthMode::Http' in rust_source(root, "src/config/routing.rs")
|
||||
assert 'canonical_host_redirect' in proxy_redirect_0290 and 'PERMANENT_REDIRECT' in proxy_redirect_0290
|
||||
assert 'redirect_location' in rust_source(root, "src/config/snapshot.rs") and 'local_redirect' in route_cache_0290
|
||||
assert 'pub struct UnsafeForce' in cache_entry_0290 and 'unsafe_force: UnsafeForce' in cache_entry_0290
|
||||
@@ -1607,5 +1635,5 @@ for marker_0282 in [
|
||||
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
|
||||
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text()
|
||||
|
||||
print("PASS: 0.29.0 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
||||
print("PASS: 0.29.2 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
||||
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.29.0 release."""
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.29.2 release."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
from rust_source import rust_source
|
||||
@@ -14,6 +14,7 @@ required = [
|
||||
DOCS / "README.md",
|
||||
DOCS / "WEB_SERVER.md",
|
||||
DOCS / "TODO.md",
|
||||
DOCS / "V0.29.1_IMPLEMENTATION.md",
|
||||
DOCS / "V0.29.0_IMPLEMENTATION.md",
|
||||
DOCS / "V0.28.2_IMPLEMENTATION.md",
|
||||
DOCS / "V0.28.1_IMPLEMENTATION.md",
|
||||
@@ -63,16 +64,16 @@ assert '"/docs/content.json"' in admin
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# RustProxy 0.29.0")
|
||||
assert readme.startswith("# RustProxy 0.29.2")
|
||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||
assert roadmap.startswith("# RustProxy roadmap") and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
|
||||
assert roadmap.startswith("# RustProxy roadmap") and "## 0.29.2" and "## 0.29.1" in roadmap and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
|
||||
assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
|
||||
assert changelog.startswith("# Changelog\n")
|
||||
for release in ["0.29.0", "0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
|
||||
for release in ["0.29.1", "0.29.0", "0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
|
||||
assert f"## {release}" in changelog, release
|
||||
assert changelog.index("## 0.29.0") < changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
|
||||
assert changelog.index("## 0.29.1") < changelog.index("## 0.29.0") < changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
|
||||
assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog
|
||||
for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]:
|
||||
assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
|
||||
|
||||
@@ -73,12 +73,12 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
flat = module.parent.with_suffix(".rs")
|
||||
if flat.is_file():
|
||||
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/proxy/websocket.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/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/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/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", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "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/NPM_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/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
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/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/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", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "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/NPM_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/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.29.0 source tree: missing {path}")
|
||||
raise ValueError(f"Incomplete RustProxy 0.29.2 source tree: missing {path}")
|
||||
text = (root / "src/lib.rs").read_text(encoding="utf-8")
|
||||
if '#[path = "auth/mod.rs"]' not in text:
|
||||
raise ValueError("src/lib.rs is not the 0.29.0 entry point; extract the complete package, not individual files.")
|
||||
raise ValueError("src/lib.rs is not the 0.29.2 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert backup.is_dir()
|
||||
assert (backup / "src/obsolete.rs").is_file()
|
||||
assert not (target / "src/obsolete.rs").exists()
|
||||
assert 'version = "0.29.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.29.2"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||
assert (target / "langs/site.json").is_file()
|
||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert (target / "docker-agent/certificate.go").is_file()
|
||||
assert (target / "docker-agent/server.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.29.0"
|
||||
assert manifest["to_version"] == "0.29.2"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -619,7 +619,7 @@ def source_contract_smoke() -> None:
|
||||
assert marker in admin, marker
|
||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||
assert '"/api/status"' not in anonymous_block
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.0"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.2"' in openapi_0241
|
||||
assert 'id="panel-logging"' not in html
|
||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||
assert marker in html, marker
|
||||
@@ -799,7 +799,7 @@ def source_contract_smoke() -> None:
|
||||
assert not re.search(r'(?<![A-Za-z])(?:window\.)?(?:alert|confirm)\s*\(', all_admin_js)
|
||||
assert 'function appConfirm(message, options = {})' in app_js and 'function apiErrorMessage(path, response, value)' in app_js
|
||||
deploy_py = read_deploy_source()
|
||||
assert "SHELL_PATH_PROFILE = Path('/etc/profile.d/rustproxy-path.sh')" in deploy_py and 'install_shell_path(prefix)' in deploy_py and 'check_shell_path(prefix)' in deploy_py
|
||||
assert "SHELL_PATH_PROFILE = Path('/etc/profile.d/rustproxy-path.sh')" in deploy_py and 'install_shell_path(prefix)' in deploy_py and 'check_shell_path(prefix)' in deploy_py and 'install_logrotate(' in deploy_py
|
||||
assert 'grid-column: span 3' in ui_css and '.check>span {' in ui_css and 'transform: translateY(-1px)' in ui_css
|
||||
assert '.cache-toolbar-status .check' in ui_css and '.docker-toolbar .check' in ui_css and '.docker-alias-field>span:first-child {' in ui_css and 'margin-bottom: 0' in ui_css and '.docker-alias-editor {' in ui_css and 'min-height: 38px' in ui_css and 'height: 30px' in ui_css
|
||||
assert 'readOnly: !!editing' not in vhosts_js and 'vhIdRenameConfirm' in vhosts_js and 'v.id === value.id && v.id !== editing' in vhosts_js
|
||||
@@ -1109,7 +1109,7 @@ def source_contract_smoke() -> None:
|
||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||
assert '"/api/config/history/restore"' in openapi
|
||||
for marker in ['"version": "0.29.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.29.2"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||
@@ -1314,7 +1314,7 @@ def main() -> None:
|
||||
source_contract_smoke()
|
||||
container_contract_smoke()
|
||||
grafana_contract_smoke()
|
||||
print("PASS: 0.29.0 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
print("PASS: 0.29.2 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep executable release identity derived from the Cargo package version."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from scripts.version import project_version
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
version = project_version(root)
|
||||
for name in ('src/main/bootstrap.rs','src/main/tasks.rs','src/web_server/fastcgi/protocol.rs','src/web_server/response/file.rs','dev.sh','scripts/deploy/upgrade.py','make_zip.py'):
|
||||
text = (root/name).read_text(encoding='utf-8')
|
||||
assert f'RustProxy {version}' not in text and f'RustProxy/{version}' not in text, f'hardcoded runtime version: {name}'
|
||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/fastcgi/protocol.rs').read_text()
|
||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/response/file.rs').read_text()
|
||||
assert '__RUSTPROXY_VERSION__' in (root/'src/admin/web/index.html').read_text()
|
||||
assert 'OnceLock<String>' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'include_str!("web/index.html")' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/admin/public.rs').read_text()
|
||||
assert 'admin_index.html' not in (root/'build.rs').read_text()
|
||||
assert 'project_version' in (root/'make_zip.py').read_text()
|
||||
print(f'PASS: executable version comes from Cargo.toml ({version})')
|
||||
@@ -96,7 +96,7 @@ need(web,
|
||||
'HeaderValue::from_static("no-store")',
|
||||
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
|
||||
"empty_index_can_go_directly_to_directory_listing",
|
||||
"RustProxy <b>0.29.0</b>",
|
||||
"env!(\"CARGO_PKG_VERSION\")",
|
||||
"traversal_and_symlink_escape_are_not_served",
|
||||
"try_files_rewrites_cache_rules_headers_and_downloads_work",
|
||||
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
|
||||
|
||||
@@ -43,8 +43,8 @@ const ids = [
|
||||
"web-service-htaccess-strict","web-service-id","web-service-index","web-service-list","web-service-listing",
|
||||
"web-service-mime-types","web-service-name","web-service-new","web-service-php-concurrency",
|
||||
"web-service-php-connect-timeout","web-service-php-enabled","web-service-php-endpoint","web-service-php-extensions",
|
||||
"web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-body-limit","web-service-php-header-limit",
|
||||
"web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-help",
|
||||
"web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-path-info","web-service-php-body-limit","web-service-php-header-limit",
|
||||
"web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-admin-values","web-service-php-help",
|
||||
"web-service-compression","web-service-compression-options","web-service-compression-zstd","web-service-compression-br","web-service-compression-gzip","web-service-compression-deflate","web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip",
|
||||
"web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset",
|
||||
"web-service-response-headers","web-service-rewrites","web-service-root","web-service-search",
|
||||
@@ -60,6 +60,7 @@ elements.get("web-service-php-connect-timeout").value = "1000";
|
||||
elements.get("web-service-php-request-timeout").value = "30000";
|
||||
elements.get("web-service-php-concurrency").value = "64";
|
||||
elements.get("web-service-php-pass-authorization").checked = true;
|
||||
elements.get("web-service-php-path-info").checked = false;
|
||||
elements.get("web-service-php-body-limit").value = "0";
|
||||
elements.get("web-service-php-header-limit").value = "65536";
|
||||
elements.get("web-service-php-stderr-limit").value = "4096";
|
||||
@@ -145,6 +146,7 @@ elements.get("web-service-id").value = "web1";
|
||||
elements.get("web-service-name").value = "web1";
|
||||
elements.get("web-service-root").value = "/srv/web1";
|
||||
elements.get("web-service-php-pass-authorization").checked = false;
|
||||
elements.get("web-service-php-path-info").checked = true;
|
||||
elements.get("web-service-php-body-limit").value = "1048576";
|
||||
elements.get("web-service-php-header-limit").value = "131072";
|
||||
elements.get("web-service-php-stderr-limit").value = "8192";
|
||||
@@ -163,6 +165,7 @@ if (!saved.php.enabled) throw new Error("Saved WordPress preset lost PHP-FPM ena
|
||||
if (!saved.precompressed.enabled) throw new Error("Saved WordPress preset lost precompressed enablement");
|
||||
if (saved.compression?.enabled !== true || saved.compression?.gzip !== false) throw new Error("Saved WordPress preset lost compression/codec override");
|
||||
if (saved.php.pass_authorization !== false) throw new Error("Saved WordPress preset lost Authorization forwarding choice");
|
||||
if (saved.php.path_info !== true) throw new Error("Saved WordPress preset lost PATH_INFO choice");
|
||||
if (saved.php.max_request_body_bytes !== 1048576 || saved.php.max_response_header_bytes !== 131072 || saved.php.stderr_log_bytes !== 8192) throw new Error("Saved WordPress preset lost advanced FastCGI limits");
|
||||
if (saved.php.custom_params.APP_ENV !== "production") throw new Error("Saved WordPress preset lost custom FastCGI parameters");
|
||||
if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPress preset lost PHP_VALUE settings");
|
||||
|
||||
Reference in new issue
Block a user