This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 11:27:18 +02:00
1 parent d940f9995f
commit 65e082b9d1
61 files changed
+1223 -579

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. 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 The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.28.1. compatibility. Current source version: 0.28.2.
## Delivery ## Delivery
+9
View File
@@ -4,6 +4,15 @@
> test-only changes and packaging implementation notes were moved to > test-only changes and packaging implementation notes were moved to
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.28.2
- Harden external certificate-file loading for Certbot/system-managed PEMs without requiring an exact chmod. POSIX ACL read access and group-readable private keys are supported; private keys still reject world-read and all external certificate/key files reject group/world-write.
- Validate the configured external path and the resolved Certbot target through root/RustProxy-owned non-writable parent directories, while allowing root-owned sticky ancestors such as `/tmp`.
- Make Certificate API credentials least-privilege: new tokens are reload-only by default, require an explicit certificate-ID allowlist, cannot call global reload, and need an explicit opt-in for deploy permission. Existing credentials migrate to reload-only broad access until rotated.
- Add SQLite schema 9 for per-token Certificate API grants; the saved proxy configuration and COLD cache format remain unchanged.
- Add explicit read-only systemd access declarations for `/etc/letsencrypt` and `/etc/ssl` and document POSIX ACL setup, including directory traversal-only grants.
- Improve English/Polish certificate UI help so external file ownership, ACL, parent-directory and reload-token requirements are visible where the paths/tokens are configured.
## 0.28.1 ## 0.28.1
- Add manual certificate support for absolute external certificate/key paths, including `/etc/letsencrypt/live/...` pairs. Certbot-style symlinks are resolved again on every reload so an external renewal can be hot-published without copying files into RustProxy storage. - Add manual certificate support for absolute external certificate/key paths, including `/etc/letsencrypt/live/...` pairs. Certbot-style symlinks are resolved again on every reload so an external renewal can be hot-published without copying files into RustProxy storage.
Generated
+1 -1
View File
@@ -1688,7 +1688,7 @@ dependencies = [
[[package]] [[package]]
name = "rust-proxy" name = "rust-proxy"
version = "0.28.1" version = "0.28.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rust-proxy" name = "rust-proxy"
version = "0.28.1" version = "0.28.2"
edition = "2021" edition = "2021"
rust-version = "1.88" rust-version = "1.88"
description = "RustProxy - independent reverse proxy with local administration" description = "RustProxy - independent reverse proxy with local administration"
+11 -11
View File
@@ -1,9 +1,9 @@
# RustProxy 0.28.1 # RustProxy 0.28.2
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration, 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. streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
**0.28.1 release source.** **0.28.2 release source.**
## Layer-4 streams ## Layer-4 streams
@@ -11,17 +11,17 @@ 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. Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
## What changed in 0.28.1 ## What changed in 0.28.2
- Add an explicit **External files (absolute paths)** certificate mode for operator-managed PEM pairs such as `/etc/letsencrypt/live/example.com/fullchain.pem` and `privkey.pem`. RustProxy resolves Certbot-style `live/` symlinks on every reload, validates the resulting pair and publishes it atomically for new TLS/H2/H3 handshakes without copying the files into `<data-dir>/certs`. - Harden external certificate-file loading without requiring one exact `chmod`: POSIX ACL based read access is supported, group read is allowed for external private keys, and harmless execute-mask bits no longer cause rejection. Private keys still reject world-read and group/world-write; all external certificate/key files reject group/world-write.
- Keep the existing `POST /api/certificates/{certificate_id}/reload` contract for renewal wrappers. A Certbot/acme.sh hook may renew files in place and then call the targeted reload endpoint; the saved configuration revision is unchanged. - Validate both the configured path and the resolved Certbot `live/ -> archive/` target through a trusted parent chain. Parent directories must be owned by root or the RustProxy service user and cannot be group/world-writable, except root-owned sticky ancestors such as `/tmp`.
- Preserve the safer managed-file mode for simple filenames inside `<data-dir>/certs`, reject mixed managed/external pairs, and keep upload/deploy endpoints from accepting arbitrary host filesystem paths in their request bodies. - Make Certificate API credentials least-privilege. New tokens are reload-only by default, require an explicit allowlist of 1-32 certificate IDs, cannot call the global reload endpoint, and may receive deploy permission only when explicitly enabled. Existing credentials migrate to reload-only broad access and should be rotated to explicit IDs.
- Clarify all certificate-source UI text in English and Polish: ACME tabs state that RustProxy/Certbot owns the generated files, Import explains that browser-selected PEMs are copied into RustProxy storage, and Existing files explains the managed-directory versus external-path modes. - Add schema 9 for Certificate API grants while preserving the existing proxy configuration document and COLD cache format 3.
- Exclude externally referenced certificate material from RustProxy backups. The absolute paths remain in configuration and must exist or be mounted separately after restore. - Add explicit read-only systemd paths for `/etc/letsencrypt` and `/etc/ssl`; Unix/ACL permissions still decide which files the `rustproxy` user can actually read.
- Retain all 0.28.0 GeoIP, listener protocol-badge, cache Via/invalidation, Security-editor and ACME runtime-reporting improvements. - Expand English/Polish certificate help with concrete POSIX ACL examples, including `setfacl -m u:rustproxy:--x /etc/ssl/`, and clarify the external-file security requirements.
- Keep SQLite schema 8 and COLD on-disk format 3 unchanged. - Retain the 0.28.1 external-file/Certbot hot-reload workflow and all 0.28.0 GeoIP, listener protocol-badge, cache Via/invalidation, Security-editor and ACME runtime-reporting improvements.
0.28.1 is configuration-compatible with 0.28.0; no database migration is required. Historical details are in [CHANGELOG.md](CHANGELOG.md). 0.28.2 upgrades SQLite schema 8 to schema 9 with an additive Certificate API grants table. Saved proxy configuration remains compatible with 0.28.1. Historical details are in [CHANGELOG.md](CHANGELOG.md).
### Historical 0.26.7 release notes ### Historical 0.26.7 release notes
+12
View File
@@ -1,5 +1,17 @@
# RustProxy roadmap # RustProxy roadmap
## 0.28.2 - External certificate hardening and least-privilege reload API
Current direction: 0.28.1 -> 0.28.2
- [x] Accept POSIX ACL-based read access for external certificate keys without requiring one exact chmod value.
- [x] Reject unsafe world-readable private keys, group/world-writable external PEMs and untrusted/writable external-path parent directories.
- [x] Validate both configured Certbot `live/` paths and their resolved `archive/` targets while keeping hot reload behavior.
- [x] Scope Certificate API credentials to explicit certificate IDs, default new tokens to reload-only, block global reload for tokens and make deploy an explicit permission.
- [x] Migrate existing Certificate API credentials to reload-only compatibility grants and move SQLite to schema 9.
- [x] Document read-only systemd access plus POSIX ACL examples for `/etc/letsencrypt` and `/etc/ssl` in English and Polish.
- [x] Keep the saved proxy configuration format and COLD format 3 unchanged.
## 0.28.1 - External certificate paths and certificate-source clarity ## 0.28.1 - External certificate paths and certificate-source clarity
Current direction: 0.28.0 -> 0.28.1 Current direction: 0.28.0 -> 0.28.1
+60 -55
View File
@@ -1,18 +1,18 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
a3c26b50db33b62a2da3741f278ff9e4d0c64026850daee61db2dd921db763b2 AGENTS.md 50ce81b53fc7fb7c33c5a1e3dd47c2e9de851ebdec103dbeb93e6c83f4bca3c2 AGENTS.md
b2c08597dd9c6e77d06f130a13291baaf278779d0f1120b702b75368b504fc70 CHANGELOG.md 35c34cfb19cab211e02cf473911c412fac0637ab0f9a62334704b5f598e3f80f CHANGELOG.md
821290c7a251e9e9962d264306340e85ac81edbb060b9416d278ffcbabaa9d0b Cargo.lock 5fec7c9f4b7c16088b05bdaf5ecd439e4521b75df07c4ee6ee8fdb50a4b78420 Cargo.lock
ca10fd79662dbdd719d08c6fbea4d01f3e6cb4cb2943d466cc8756ff513d3290 Cargo.toml eda38ce1f741b31b66b4d3d7f1b197bcb4710b933863cfe925a8a71068e632c8 Cargo.toml
75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile 75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile
8ce8f96cb98b2c59bc6f02479ec0fc53c71be3ee2e0d1db104e84f10870543ec README.md 3981a80f8516471defe68d7d76972b949278704ee5586982232f779ad4c7728e README.md
e0453e362a48a16961cea02e8e4c280392d4a43c09a31d5568b60bc27813e913 ROADMAP.md 5f5956476616f774c0488e7db20883619bd4844e2e5009c5801e96c7b4bbbbf0 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
385ac7970fa94f043d0bcf04a91184e6e2c807587b85e55517ed064bb4bddba6 deploy/README.md ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
31d4e0cd454060b46f359fdb84a14b16a5fe0d3baabb387dd584d76e555558bb deploy/rustproxy.service 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
c5e254880fb348b71e39d683652fc2d7777e2444d2c0b1c51c43b7820853582e dev.sh 6e4601243bdbb6917494a21319ef9240150ea51b86f5bbeee0e8283f54a08470 dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
@@ -21,49 +21,49 @@ bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/d
9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh 9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh
b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go
3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go 3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go
f9dfa0f572a5a5f6b6fc3c99eeca6b577dbf63a41d3d656075e13c11dbb8c2e0 docker-agent/model.go dccc9fb4ae1f8358fb4b91fe3746afaa9a58288fc4ff9fd7ae7d2083d360672c docker-agent/model.go
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md 875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
cca5747a345cc1c238564213a5b1ba3167994e81e603fba0668f70e247523852 docs/API.md 67a1e0cbe5d1139251852401d876273ec7ea8cd1c595ecd63af097921b0d21b3 docs/API.md
824aeb96e1678b3c84a9f8217bedea6f3a38b60175073054a88f752415843d7c docs/API_ACCESS.md 598a93c724e6eed16083e304af3e082c804e227cc9175288e582db93805dc869 docs/API_ACCESS.md
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md 4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
3f18d48514ad003ac24ad07ea47271a5719dd8bc1f2329f0c3fbae774e086426 docs/CACHE.md 3f18d48514ad003ac24ad07ea47271a5719dd8bc1f2329f0c3fbae774e086426 docs/CACHE.md
6266163e17d2921468abdae92c1620266af8351321530e2c9b7dca6f3dd89c88 docs/CACHE_DIAGNOSTICS.md 6266163e17d2921468abdae92c1620266af8351321530e2c9b7dca6f3dd89c88 docs/CACHE_DIAGNOSTICS.md
c6003dcbc0b95e2cfa9523b7017186cc8b4826248b588af77111ac5eb15174f9 docs/CHANGELOG_TECHNICAL_ARCHIVE.md c6003dcbc0b95e2cfa9523b7017186cc8b4826248b588af77111ac5eb15174f9 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
17e42b2924788960f1d97407619ab4cbc27dc5dc0e45b50d2b6e7c4629e8626a docs/CONTAINER.md 6476f0c99eef0bff0b17b5891306ed4073f3de242f1c147a8fa1838a06a0b2bd docs/CONTAINER.md
9538a52d508d8d7053c1387c42eef60e6d5dc1879c300b6fe951b3374c16884c docs/CUSTOM_LOCATIONS.md 9538a52d508d8d7053c1387c42eef60e6d5dc1879c300b6fe951b3374c16884c docs/CUSTOM_LOCATIONS.md
16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md 16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md
9ea8db4d034ecb573976dd323da29f044538525c230df9f43a47be1ac867b24c docs/ERROR_LOGGING.md 9ea8db4d034ecb573976dd323da29f044538525c230df9f43a47be1ac867b24c docs/ERROR_LOGGING.md
bf127609d35246e95b5183411795cd7f3112164af13b97044c3dd498ab3bd68f docs/FILE_LOGS.md 8182d586687b1c4900f17c857e6c5a1b7cf252d86dc243361c0b59241aa68261 docs/FILE_LOGS.md
8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md 8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md
9e728d982d4ee709b95959ed426c27fe1c25ee7c7aec7a39742404120cb0d715 docs/INSTALL.md f2689412e7a5f84ce29ee496d4adac01eccc022afe464747063fefa6e18ecf08 docs/INSTALL.md
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md 4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md 7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
9583b0b69f81320e2227f44a853259e7a600846bbed681ac54896d83befd7b3b docs/MIGRATION.md fea1ff4055d0930edeffb0c8501a6ba1ac55ec7a7064dcd0e300a43915e8432e docs/MIGRATION.md
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md 5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md 1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md 89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md
712cb1cbf84515384b081fd2929e86e40af7c10f65bc69919b78b7b3e2fd12b5 docs/PHP_FPM.md 712cb1cbf84515384b081fd2929e86e40af7c10f65bc69919b78b7b3e2fd12b5 docs/PHP_FPM.md
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md 572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
9a08c40b2b22fffca6ed9a4f32106cefc4f55c8c954c9380775c9136fa655419 docs/README.md a4ec600ee09eef7fa02d6d894cec6cfb7146da4253b40ad2b65968efa8cc2c06 docs/README.md
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
1ce39b327026b86d77e81ea15f9376f1abd83150669503f8275a171288247bf0 docs/RENEWAL.md d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_HISTORY.md a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_HISTORY.md
759bf2987f2b602488829d1856fa33d57c2a3b23bc681ef5a931b9633af3a533 docs/SECRET_STORE_BACKUP.md 759bf2987f2b602488829d1856fa33d57c2a3b23bc681ef5a931b9633af3a533 docs/SECRET_STORE_BACKUP.md
5dbdf45fddba198a28f511dbbd2f12bf7d2b88c41c3cbedbc20dfea3858779d3 docs/SECURITY.md 5dbdf45fddba198a28f511dbbd2f12bf7d2b88c41c3cbedbc20dfea3858779d3 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
63c776e2531c3e58d067caa921fbee79d0c9dd8c26a5e91a7ec10af088ed9be6 docs/SETTINGS.md d5c5c59f6e63b8d83dad5d159657c4d4039dfeb4cc1d8af210169b576adbf7e2 docs/SETTINGS.md
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
0fb8555ecc7c2b3692c690768934f8b571fab7b83f58f8927a604a4e920443f2 docs/TEST_REPORT.md e7471f3641e2ac84cd02f61effcb8ba8b91f45d941fcb99c3a038f14c90938a0 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md 8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md
6564b378dac5dd03c49f20eb1a267d88a2c41d922d517aff96fa12cf9d937871 docs/TRANSPORT.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
96a6816915d526f00e261846131ee3f942ef332af996cf0f73c79ec1ba9f9c7a docs/V0.21.0_AUDIT.md 96a6816915d526f00e261846131ee3f942ef332af996cf0f73c79ec1ba9f9c7a docs/V0.21.0_AUDIT.md
0c1c3ba6e8724d85ec54e254e70bbf05e063db025280ef4af866b774fb43f76b docs/V0.22.0_AUDIT.md 0c1c3ba6e8724d85ec54e254e70bbf05e063db025280ef4af866b774fb43f76b docs/V0.22.0_AUDIT.md
3f811c457bf6be662969fbac32c3cfcc49b13c2f00d0125e4f2eeab768f59f0e docs/V0.22.1_AUDIT.md 3f811c457bf6be662969fbac32c3cfcc49b13c2f00d0125e4f2eeab768f59f0e docs/V0.22.1_AUDIT.md
@@ -101,6 +101,7 @@ efd5935852d4cfd09758bf67c70e437490cfce2baade46c8733201e506dbcc42 docs/V0.27.0_I
49fca8928f3ed6169df4480f9658b65b8d9dd5d1331ef5bb2b1f9adaca8212eb docs/V0.27.3_IMPLEMENTATION.md 49fca8928f3ed6169df4480f9658b65b8d9dd5d1331ef5bb2b1f9adaca8212eb docs/V0.27.3_IMPLEMENTATION.md
713518c1a09969703d7b09b83380d5e659f5d1587ef23585abfa1fa87c033ae5 docs/V0.28.0_IMPLEMENTATION.md 713518c1a09969703d7b09b83380d5e659f5d1587ef23585abfa1fa87c033ae5 docs/V0.28.0_IMPLEMENTATION.md
8e1b45415a2bffcf26fe113bbc25627daf5d8758eb9e44c38abb060a4a402a46 docs/V0.28.1_IMPLEMENTATION.md 8e1b45415a2bffcf26fe113bbc25627daf5d8758eb9e44c38abb060a4a402a46 docs/V0.28.1_IMPLEMENTATION.md
097a4ff47634e5b09180012135b49a8ed16193fadc36113cad0c8dffb9e47cca docs/V0.28.2_IMPLEMENTATION.md
bc2ac0117528ed4616c68cf7fe05cb1d9bbd36ecaa8573828cb3b3136923f265 docs/VHOSTS.md bc2ac0117528ed4616c68cf7fe05cb1d9bbd36ecaa8573828cb3b3136923f265 docs/VHOSTS.md
09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md 09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md
4026a4adeee70f98a96cc7aa8d8be16cda42ffb2e5890d38f1ba638134ab0675 docs/WEB_SERVER.md 4026a4adeee70f98a96cc7aa8d8be16cda42ffb2e5890d38f1ba638134ab0675 docs/WEB_SERVER.md
@@ -115,8 +116,8 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock-
5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json 5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json
d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhosts-balanced.json d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhosts-balanced.json
af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhosts-private.json af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhosts-private.json
dacefab2e3d97113dac1065db3ca10476a2760ee38d181b01913e9cadc65d2a4 grafana/README.md c710b7658d131be4b8a5c90a31d149f442c1c511464fddc5522e38f15f20f687 grafana/README.md
a63837ec7742fed482d7f9738f22bdad2b340d8730f6f27022eda46646a0d068 grafana/rustproxy-overview.json b1312b071a840fa4e7d9538eff7fadbe197e564811dc6fb4608a2ee34bd197f3 grafana/rustproxy-overview.json
a51d911526d2631282a358be4152ef1ad6ed4034e34f961a5290b1b90fffaf32 install-debian13.sh a51d911526d2631282a358be4152ef1ad6ed4034e34f961a5290b1b90fffaf32 install-debian13.sh
909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh 909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh
42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md 42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md
@@ -129,9 +130,9 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
3847e8eca1de8c6e7f5d51d8eb7ef1f47221822e3f501da08baa050d5e98dce6 langs/en_US.json 37dd685d47305be1450cb3ecc47f5e614c745cc09e30cd711e029c145e308e52 langs/en_US.json
986da8af322bea5ad4da6373fa246b97e6dc7e1f9f058f06bbc652a98a89fe80 langs/pl_pl.json a443d4cf2a37447ccf7553f8a3e14631bcd7997474c198c667617318dba38489 langs/pl_pl.json
c3e837100baa919445387bae65a579ea7dd05a315554ba48b3361131104f6168 make_zip.py 8791b72d868d7c1a54ba6ba06927899a5bebba3dd57ed29bf4a735d1bf2303ac make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql 5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql
@@ -139,6 +140,7 @@ e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/000
faa7e50922bf180c363f7627fbcfe8079831e36282220d17481cf69bc64b57f1 migrations/0006_certificate_api_credentials.sql faa7e50922bf180c363f7627fbcfe8079831e36282220d17481cf69bc64b57f1 migrations/0006_certificate_api_credentials.sql
f093aef9639a959a2522946995a02d2e67c464e794c3ef665261a9a5b348c968 migrations/0007_secret_store.sql f093aef9639a959a2522946995a02d2e67c464e794c3ef665261a9a5b348c968 migrations/0007_secret_store.sql
90f6dce865496e018c047133d3bc1721fc2a19b2f4ced22e2a3625d9e9ff18ac migrations/0008_config_history.sql 90f6dce865496e018c047133d3bc1721fc2a19b2f4ced22e2a3625d9e9ff18ac migrations/0008_config_history.sql
fc7585fdcbe99fe3496f0e62f4c4e2161f4a0173222ba4950331aec6c44e50f8 migrations/0009_certificate_api_grants.sql
478220552ffeea3de00e2866d427d9f87f4cfc4ae5232e5c3a774e62425b1e0c nagios/README.md 478220552ffeea3de00e2866d427d9f87f4cfc4ae5232e5c3a774e62425b1e0c nagios/README.md
91233910aa4dab07ae917fef93d018a7a0beaab697f9a0bb889ac613e595cca4 nagios/check_rustproxy.py 91233910aa4dab07ae917fef93d018a7a0beaab697f9a0bb889ac613e595cca4 nagios/check_rustproxy.py
a42e01b341725c9da0928edf40c8c3b9c814ef90592f208c52b1f2c56a2ad383 nagios/check_rustproxy_api a42e01b341725c9da0928edf40c8c3b9c814ef90592f208c52b1f2c56a2ad383 nagios/check_rustproxy_api
@@ -147,11 +149,11 @@ ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_r
6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues 6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues
7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml 7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml
2145ad17ae7a823756371ceab6bdbeb332211df93193ffb4e486f7a80c4f29ae scripts/README.md 2145ad17ae7a823756371ceab6bdbeb332211df93193ffb4e486f7a80c4f29ae scripts/README.md
1ffd80b4e699ecc4daf30eeec1884bcc83ae506e8abd8833d336e170261e7638 scripts/build/swagger_assets.py 0c404444b802c0c7e71307c05b5348ae23bf6b00d7b824eabf0e240e2bb579d3 scripts/build/swagger_assets.py
187ee92c8029cdd59e89b4447cf4d170e856017a2091ceb85ae7c22dd1a79c72 scripts/deploy/deploy.py 187ee92c8029cdd59e89b4447cf4d170e856017a2091ceb85ae7c22dd1a79c72 scripts/deploy/deploy.py
9e24d65db00e1f89166a2aad95b3989f949cec6041e92de6f1aa63a2c0125ad3 scripts/deploy/deploy_core.py 533af06a16e99be60db5cd198afa0fb55a71e573b6fe1f2a214d7999d7603000 scripts/deploy/deploy_core.py
2eec7ff7c689416135984e38a90c177a8b06bdf176da69294cce25e5571847d5 scripts/deploy/deploy_runtime.py 2eec7ff7c689416135984e38a90c177a8b06bdf176da69294cce25e5571847d5 scripts/deploy/deploy_runtime.py
390e8d14a338bbdc84cea738a8e7722cbe0e21e1840c7f131a40c2f263ab5d59 scripts/deploy/upgrade.py 005565787a15a28b2ae622af4496ac5f49b119bbe1d22a65693195d0ba86d1d3 scripts/deploy/upgrade.py
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
@@ -167,27 +169,27 @@ b9e57db4bd80ef37564dfc533a96897d8a494671d53acea9fb4bf28d23ba370d src/acme/provi
29ff0647417b0f1d50a4cc075ab51afecff9ddf47db3eb7948d51db5a38f33f3 src/acme/runtime_bootstrap.rs 29ff0647417b0f1d50a4cc075ab51afecff9ddf47db3eb7948d51db5a38f33f3 src/acme/runtime_bootstrap.rs
c8941a929dfef9e8875adf534394ca01a1532e567d5099fefe3657826886ee25 src/acme/runtime_env.rs c8941a929dfef9e8875adf534394ca01a1532e567d5099fefe3657826886ee25 src/acme/runtime_env.rs
7acbaf34e7b953b753ab074893e47b5024f0cd756dc4c7a1074067dd936c2e8e src/acme/tests.rs 7acbaf34e7b953b753ab074893e47b5024f0cd756dc4c7a1074067dd936c2e8e src/acme/tests.rs
55ef7ceb44c25c7a8ddcbfd5e0d4b647526f9bdcde901ee08db5314e4d055b39 src/admin/access.rs bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs
123f3ae5d19cdb5e9a478f634a8b04df067f944f281c180bb1e8d735955162b3 src/admin/certificates.rs 019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs 1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
9440c8a6cae7fce2e88ab0a8f1e6c804890add140760612cc25ead01443e8cc2 src/admin/documentation/documents.rs af141b6eafa1729af1a5e8c8540bf4ee2aeb94f7443d5855f84ef2163fd41128 src/admin/documentation/documents.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs 002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
d3210e037481e1f95e840550428e088ffd83ec9a42f1810e4c976298fb555a9a src/admin/file_logs.rs d3210e037481e1f95e840550428e088ffd83ec9a42f1810e4c976298fb555a9a src/admin/file_logs.rs
de331ab75c31df65a4f0f1607dbfb26972799bff5d9f232d8e914c38225cffc7 src/admin/logging_api.rs de331ab75c31df65a4f0f1607dbfb26972799bff5d9f232d8e914c38225cffc7 src/admin/logging_api.rs
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs 58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
74a15448f57c52972343645521e4e4238d8d37dc2b392e5db34f26c2d46b6b83 src/admin/openapi.json 8c1e6163414bafd7c7106008886a952f005a2ed0e50928ca83545cce647528e1 src/admin/openapi.json
6b14f2a884e41745ad6f99d5ff4d36f502a3c0b5ba09ba15802eddabccc46177 src/admin/operations.rs 6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs
5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs 5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs
b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs
772063772b8688317aa4d7ba3a2ac509cdf0b10e0f6980ed058cf4db96054420 src/admin/router.rs d73ea424a2795b6f44248abce459da770be179369a59345cb8f7514040b21071 src/admin/router.rs
333d5f9f4c27941c535ac689e5826264ffa2bb86b00832b597a9bb9f811f01e8 src/admin/session.rs 8708427263cd4caf8454b9115e89acb09172483a090c6b0bd43811dd9e52c7f6 src/admin/session.rs
bebf42cd1808a99a0e669d483951a441ffa22c8d5a0a289c64491adf13c8841a src/admin/status.rs bebf42cd1808a99a0e669d483951a441ffa22c8d5a0a289c64491adf13c8841a src/admin/status.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
59ea2bb773c86c3d8240ccc212a8bf7f670e17e360def9e773906469ef135ac5 src/admin/tests.rs 59ea2bb773c86c3d8240ccc212a8bf7f670e17e360def9e773906469ef135ac5 src/admin/tests.rs
5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs 5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs
6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs 6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs
77608ad33da348dbea41d2a7ceadb75b0bcf05336cb5a20b7f2a47502f1e4842 src/admin/tests/part_03.rs 34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs
46c6d04fb6a125ebb00f7295d3ad3c16071477fc40f2b377455ed7a6eb1370d2 src/admin/tests/part_04.rs 46c6d04fb6a125ebb00f7295d3ad3c16071477fc40f2b377455ed7a6eb1370d2 src/admin/tests/part_04.rs
5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs 5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs
f869e4da5de149fc04ce1aab658befeaf3208c862f149ca976235d15832c3cdb src/admin/tests/part_06.rs f869e4da5de149fc04ce1aab658befeaf3208c862f149ca976235d15832c3cdb src/admin/tests/part_06.rs
@@ -196,7 +198,7 @@ f869e4da5de149fc04ce1aab658befeaf3208c862f149ca976235d15832c3cdb src/admin/test
446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js 446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js
ed063b8a414cf3b5e539c900e8722aa82d84c561a5cd54d875650793a25529e2 src/admin/web/app/core.js ed063b8a414cf3b5e539c900e8722aa82d84c561a5cd54d875650793a25529e2 src/admin/web/app/core.js
cb5c4c2dd4964795b61393b05537cc1528c72d7c1390953f4d119a5e55c65c7c src/admin/web/app/preferences.js cb5c4c2dd4964795b61393b05537cc1528c72d7c1390953f4d119a5e55c65c7c src/admin/web/app/preferences.js
36b6d6c00fae30acdd26279cb88ab343c4926acd1f713500eb9db76aa0a1ecea src/admin/web/app/runtime.js de17987bda89dfab54b6751007474ff9e7cc8da35f7e9c46a2eff922a50fb085 src/admin/web/app/runtime.js
3b63cc15068d287fe873956b38306abaae8c88a2f288ecd98159c5c47acb98e1 src/admin/web/app/settings.js 3b63cc15068d287fe873956b38306abaae8c88a2f288ecd98159c5c47acb98e1 src/admin/web/app/settings.js
74777adaba5a448f3a9abf3a46d91efc6eb81ff6f3b5441e5b99a9e1d6c09af0 src/admin/web/cache-panel.js 74777adaba5a448f3a9abf3a46d91efc6eb81ff6f3b5441e5b99a9e1d6c09af0 src/admin/web/cache-panel.js
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
@@ -214,14 +216,14 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
05e89230f3d15436e20331ac327f112d70c53e8cdad0b7c2dc42f6469b4bcdfd src/admin/web/index.html 964b78bb365d9c2965031860f8d268c20593966b22670e58ca6b96673c4efae4 src/admin/web/index.html
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js 24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html
99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js 99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js
e169d77359785ba604ce1a728420571fdc9b491fc1c0088642399466fbed8c1b src/admin/web/management.js e169d77359785ba604ce1a728420571fdc9b491fc1c0088642399466fbed8c1b src/admin/web/management.js
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
0ff365087966846a7bda3e159f061443dcb32c35a4b3f6f46ec5530343542bf3 src/admin/web/settings.js 7fc4c3661524f11448bc43ebe8f494ed44b8b0a414e63ce5bb8717f314c33808 src/admin/web/settings.js
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
ff777b36069747dc30c10fdaf16f610a866879f6acf8af6ef67fb96c1d9b00c2 src/admin/web/style.css ff777b36069747dc30c10fdaf16f610a866879f6acf8af6ef67fb96c1d9b00c2 src/admin/web/style.css
a8a73a4fa849ae49391fcd6143e900c515c8b577f71b55cbae9701fa9f5bbe47 src/admin/web/traffic-panel.js a8a73a4fa849ae49391fcd6143e900c515c8b577f71b55cbae9701fa9f5bbe47 src/admin/web/traffic-panel.js
@@ -307,9 +309,10 @@ f6e9452a960f1d1c825ef6459125eda5ec9d16d5c461b5c6d21baae8e7d95d6c src/config/com
7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/config/tests.rs 7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/config/tests.rs
7592bdeaad9950a720e2d841fcb1a4edb2a68af5333a6163e672d0392616103c src/config/tests/part_01.rs 7592bdeaad9950a720e2d841fcb1a4edb2a68af5333a6163e672d0392616103c src/config/tests/part_01.rs
fb0d4d91ae95fe551b06b9195f9e6d41bc40fb6a3a7c547b170fcfeb9abe213c src/config/tests/part_02.rs fb0d4d91ae95fe551b06b9195f9e6d41bc40fb6a3a7c547b170fcfeb9abe213c src/config/tests/part_02.rs
fa4e955ad23c0b5b3fb25af9f144c5c483f116fb79568300543b6501575d9e18 src/db.rs a5bbae014b7b5c620f327c337c54951f56713ade149e3a5f558bcc2b73a9e50b src/db.rs
7d85028d6470c2ceb2bac9861a4fe0c4793dbbd886434bfd5d289011777010b5 src/db/certificate_api.rs
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs 2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
eb0f3d5ca222d36a915493e9bb96ef34dfe3abc627ec887782f9dda6f3174754 src/db/tests.rs c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs
19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs 19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs
e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs
b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
@@ -340,11 +343,11 @@ a16613114273c54b6d2a16a94fa8748550e91468585469642622bd2c86087baa src/logging/fo
17425ebbca6bbbb0dcaeb65ebc44b1e51488a4092b7b6add556e764c6d486d6a src/logging/tests.rs 17425ebbca6bbbb0dcaeb65ebc44b1e51488a4092b7b6add556e764c6d486d6a src/logging/tests.rs
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs 3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
2f6b5a9517453541bf43580a9d963e64e36338575ee9ad295ca3718803a59ed6 src/main/admin_runtime.rs 2f6b5a9517453541bf43580a9d963e64e36338575ee9ad295ca3718803a59ed6 src/main/admin_runtime.rs
4ab51b81c126409a8cd33925958165a366d28f91cecdb42df348d8dd5a06e333 src/main/bootstrap.rs 2f7c87361711b80b1f1740a91e80af44122c7015887b79e9b0db9684065c8c4d src/main/bootstrap.rs
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
4dd7c1084a6214c0f2513ebf86fe8ef144470695557772bfe25979edd64491ab src/main/shutdown.rs 4dd7c1084a6214c0f2513ebf86fe8ef144470695557772bfe25979edd64491ab src/main/shutdown.rs
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
085b1e39c4376a691ce254e1071ebfe6ac68ffc7965cac6aee375035eedf3dd8 src/main/tasks.rs 4837a31562dd411b998fe8397459ac72aa4777ae61ce6484365c552b8b553cb8 src/main/tasks.rs
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs 49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs 201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
49b11df65b4fb0b5b727f9f867436584680b45e3f4fbd53f74bc0cab8b9a88ec src/metrics/vhost.rs 49b11df65b4fb0b5b727f9f867436584680b45e3f4fbd53f74bc0cab8b9a88ec src/metrics/vhost.rs
@@ -414,12 +417,14 @@ d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_sta
050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs 050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs
7467d83066bb6986d8c9103597f7a7617ac677579ed46d573c573ff6ca57bbd9 src/tinyauth.rs 7467d83066bb6986d8c9103597f7a7617ac677579ed46d573c573ff6ca57bbd9 src/tinyauth.rs
0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs 0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs
7d94e9ea622abb2ffee9f2bba43b3d41e603d3e907a42f0fe2a83b0c78faa7b1 src/tls.rs 1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs
e62b55a057ab39daf540e225342e98a57a6266f1603d685c48468f2f7c37c4b8 src/tls/certificates.rs e62b55a057ab39daf540e225342e98a57a6266f1603d685c48468f2f7c37c4b8 src/tls/certificates.rs
ca471212a97bae737e441920818e2e251868dfb2e75db45dce01ff8e420bf736 src/tls/external_paths.rs
3e6947dde167f82a5efc407ccf9bd4c9f5b26cc8633d1418a10284bc1a1503d4 src/tls/server_config.rs 3e6947dde167f82a5efc407ccf9bd4c9f5b26cc8633d1418a10284bc1a1503d4 src/tls/server_config.rs
05ae78a3c495bfd97889a1f5499b48d68cc6c42b9bb5e4ee6577d47bdda3a54d src/tls/tests.rs 893d1077fc0d9a08598596a1d3f4bde7019357fac27ebe13f52c11a4859a1d25 src/tls/tests.rs
e0ab65406911d4b95cb26594df65779a88d9011d5b11adb22c1a28831cda3826 src/tls/tests/acme.rs e0ab65406911d4b95cb26594df65779a88d9011d5b11adb22c1a28831cda3826 src/tls/tests/acme.rs
601ee1d8e2bdd9d55f99041c86897699b77d6fbf7a936ed1b580029d490d75bf src/tls/types.rs cfab6b61f0d121a15d6f78943091c770cb5a2a023d68d66be3c0228f9c8adf9c src/tls/tests/external_paths.rs
10c4a187e3aeb0c72a8c8968ea0cc8bfae4e3cb2f3d834ebd3da1056c4171ec5 src/tls/types.rs
438506b1743daddcbefe0ebec5a6050d5f9555710446a1578c0a3d326d4b68ac src/traffic.rs 438506b1743daddcbefe0ebec5a6050d5f9555710446a1578c0a3d326d4b68ac src/traffic.rs
36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs 36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs
@@ -451,7 +456,7 @@ ff4980f0a091da9ff1722d6b9f6662529cbd7c5e2ac68dcd22a507ce6aaf3011 src/vhost/vali
c1fef6dfb79275479c15097b211cd5f063ad7e0852a0767b0145cf4f32f751da src/web_server/config/model.rs c1fef6dfb79275479c15097b211cd5f063ad7e0852a0767b0145cf4f32f751da src/web_server/config/model.rs
f1d3e427c53f8b5be5768d85e69ead746d83647fcd3b355acc98e3f9d5e217e0 src/web_server/config/validation.rs f1d3e427c53f8b5be5768d85e69ead746d83647fcd3b355acc98e3f9d5e217e0 src/web_server/config/validation.rs
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs 86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
3a7414dd4fd65b3ef1c3553c6db72ec79683cf54f2fdaac9149006d6a0faad8b src/web_server/fastcgi/protocol.rs 7a32e7950f485cf8897c6842ff24bb67e2a1348f6af1bbc32a95df8948c76583 src/web_server/fastcgi/protocol.rs
134c4998f6a0aa27e121af1ae3355b7e335488b2ae867ac0a934c58b4eb77191 src/web_server/fastcgi/response.rs 134c4998f6a0aa27e121af1ae3355b7e335488b2ae867ac0a934c58b4eb77191 src/web_server/fastcgi/response.rs
8186363315bd998919c6c783e6f7501528f257a5c0da77c5d60f423e8670db5e src/web_server/filesystem.rs 8186363315bd998919c6c783e6f7501528f257a5c0da77c5d60f423e8670db5e src/web_server/filesystem.rs
0aafd7f37c4fe784b047646e4ce5ddcb5fe821231a41ed0a9f519529385288a8 src/web_server/htaccess.rs 0aafd7f37c4fe784b047646e4ce5ddcb5fe821231a41ed0a9f519529385288a8 src/web_server/htaccess.rs
@@ -461,14 +466,14 @@ f1d3e427c53f8b5be5768d85e69ead746d83647fcd3b355acc98e3f9d5e217e0 src/web_server
80f946570c14c8915fd21bfdb1a84dfb6a14c633ab6060acb828fab4c7c21daa src/web_server/mod.rs 80f946570c14c8915fd21bfdb1a84dfb6a14c633ab6060acb828fab4c7c21daa src/web_server/mod.rs
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs 67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs
02c6353294707b5b352b3802b9f456ba01a1d6f16b247428e5787c8239b2d5c3 src/web_server/response/file.rs 6276ca74bd6d613a511a794bc5f0dfed91f16ef691a4818ee8a96e658be05a33 src/web_server/response/file.rs
9aa68251a5f549f4ce3b1bda88d4abb9efc629caf444989381b4117c9488e37b src/web_server/response/headers.rs 9aa68251a5f549f4ce3b1bda88d4abb9efc629caf444989381b4117c9488e37b src/web_server/response/headers.rs
2268a6aeed2f565f9791af33e1cd21c56c228c8873b031725b39a6c57184f2d1 src/web_server/routing.rs 2268a6aeed2f565f9791af33e1cd21c56c228c8873b031725b39a6c57184f2d1 src/web_server/routing.rs
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs 400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
3e0b4e92600a7d5abcec92efed532b3a01e0781cb4f2681daac6d6ae13b0a6e9 src/web_server/serve.rs 3e0b4e92600a7d5abcec92efed532b3a01e0781cb4f2681daac6d6ae13b0a6e9 src/web_server/serve.rs
cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs
c62752fe2fa6b74ddc9d94d36f8d191376b1575b15d21fc434c5a5da4c932692 src/web_server/tests/part_01.rs c62752fe2fa6b74ddc9d94d36f8d191376b1575b15d21fc434c5a5da4c932692 src/web_server/tests/part_01.rs
95dbefdfee00d8db187fcc5abcc01afec9a9fb8ac4ceb452dc19bc2107b1683c src/web_server/tests/part_02.rs 47d67d3033295e0172219f6a640a93d3d59a87da6c35563b438075ceff271d37 src/web_server/tests/part_02.rs
e54f44f6ca1ed2c41e620de9195d32c4f8bb6936c3881745316a07160f87b4fa src/web_server/tests/part_03.rs e54f44f6ca1ed2c41e620de9195d32c4f8bb6936c3881745316a07160f87b4fa src/web_server/tests/part_03.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs 1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
@@ -477,19 +482,19 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs 728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py 358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
e5a533eda85138fd13388fb17579dc7dc27ebfca9a350cc73e10995c7514a848 tools/checks/check_source.py af22d8904631672566122c842eda77054b1c6cf422861fa4cdf972173ef6537d tools/checks/check_source.py
9d2c29b1b3636b127c6f2b05df00926b19a2ab83b9a8a01e497ed4cf0690751b tools/checks/docs_check.py dbb2f8e117197732f3ee14d7eefcadf18dda3a1679cd6b96f9f0a0471c0be8ae tools/checks/docs_check.py
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py
dfcc74597bbaa1853608db5d62908f5832a48453be40fbdaeebce68d041461f1 tools/checks/preflight.py 08af3e80470758493c107c925aab6c30e755807eac1734ad664ef72a1e6043e8 tools/checks/preflight.py
b1ac96ebdbea050e20c762eaac3199611f1bbb768e0e9fb7e2fe77414f17c9d0 tools/checks/release_smoke.py 40e68075e14de2987b3ddc4614b4dbe66ca2bb7c823fa52a1c710a62be7d867d tools/checks/release_smoke.py
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py 23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
2309c41e769e797c838d4f40d50bb3c6b9ad9ad0f9ed07fb93e07f95d66563f9 tools/checks/source_module_contracts.py 2309c41e769e797c838d4f40d50bb3c6b9ad9ad0f9ed07fb93e07f95d66563f9 tools/checks/source_module_contracts.py
0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py 0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py
b3299d86d94fac19e0e33462c65547c7353e4276fa619145c6e12420d48f2b46 tools/checks/web_server_contracts.py a465784a5b40897f905c639130e4989d5764451900155d3e4820b5b4feeaa18d tools/checks/web_server_contracts.py
c245f8f832bd2c36a3fbd5e66fe98e7367c70a8db840c79e6b640f1d9d74f2ac tools/checks/web_service_ui_test.mjs c245f8f832bd2c36a3fbd5e66fe98e7367c70a8db840c79e6b640f1d9d74f2ac tools/checks/web_service_ui_test.mjs
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py 2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
+10
View File
@@ -36,6 +36,7 @@ systemd drop-in, reset `ExecStart` before replacing it. Example preserving the l
ExecStart= ExecStart=
ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 127.0.0.1:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 127.0.0.1:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443
ReadWritePaths=/var/log/rustproxy ReadWritePaths=/var/log/rustproxy
ReadOnlyPaths=-/etc/letsencrypt -/etc/ssl
``` ```
Saving HTTP/TLS or TCP/UDP stream listener changes in the UI does not restart this service. Restart explicitly Saving HTTP/TLS or TCP/UDP stream listener changes in the UI does not restart this service. Restart explicitly
@@ -45,3 +46,12 @@ bind can prevent startup; preserve a pre-change database backup and consult
`systemd-analyze verify` and exercise actual non-root binding before accepting a `systemd-analyze verify` and exercise actual non-root binding before accepting a
production deployment. This packaging environment did not run systemd or bind production deployment. This packaging environment did not run systemd or bind
privileged ports through this unit. privileged ports through this unit.
External certificate files under `/etc/letsencrypt` or `/etc/ssl` remain read-only under the systemd sandbox. `ReadOnlyPaths=` does not grant Unix permissions; use narrow POSIX ACLs for the `rustproxy` user. For traversal of a protected root, for example:
```bash
setfacl -m u:rustproxy:--x /etc/ssl/
```
Grant `r--` only on the certificate/key files RustProxy actually needs; never grant write access to the service identity.
If Certbot or another renewer recreates a key as `0600`, reapply the named file ACL to the newly resolved target in the renewal hook before calling the RustProxy reload API.
+1
View File
@@ -29,6 +29,7 @@ ProtectSystem=strict
LogsDirectory=rustproxy LogsDirectory=rustproxy
LogsDirectoryMode=0770 LogsDirectoryMode=0770
ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy
ReadOnlyPaths=-/etc/letsencrypt -/etc/ssl
ProtectHome=true ProtectHome=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true ProtectKernelModules=true
+2 -2
View File
@@ -18,7 +18,7 @@ if [[ $# -gt 0 ]]; then shift; fi
case "$mode" in case "$mode" in
help|-h|--help) help|-h|--help)
cat <<'HELP' cat <<'HELP'
RustProxy 0.28.1 RustProxy 0.28.2
./dev.sh Build and start proxy + local panel ./dev.sh Build and start proxy + local panel
./dev.sh run [serve flags] Same, with extra server options ./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 ./dev.sh admin setup-token Generate/print a one-time setup capability before first login
@@ -139,7 +139,7 @@ if [[ "$mode" == "test" ]]; then
printf 'Checking all Rust targets before tests...\n' >&2 printf 'Checking all Rust targets before tests...\n' >&2
cargo check --locked --all-targets cargo check --locked --all-targets
fi fi
printf 'Building RustProxy 0.28.1...\n' >&2 printf 'Building RustProxy 0.28.2...\n' >&2
cargo build --locked cargo build --locked
BIN="$CARGO_TARGET_DIR/debug/rust-proxy" BIN="$CARGO_TARGET_DIR/debug/rust-proxy"
case "$mode" in case "$mode" in
+1 -1
View File
@@ -1,6 +1,6 @@
package main package main
const version = "0.28.1" const version = "0.28.2"
type config struct { type config struct {
Listen string Listen string
+14 -10
View File
@@ -88,7 +88,7 @@ and rechecks session validity. It does not queue an unlimited per-viewer event l
or guarantee replay. The browser falls back to polling. An active dashboard renews or guarantee replay. The browser falls back to polling. An active dashboard renews
idle activity, not the absolute session deadline. idle activity, not the absolute session deadline.
Configuration history retains the newest 512 successful manual/configuration revisions in SQLite schema 8. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121. Configuration history, introduced in SQLite schema 8 and retained in current schema 9, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121.
Configuration updates return 409 for a stale revision, 422 for validation failure Configuration updates return 409 for a stale revision, 422 for validation failure
and 429 when the eight-operation update admission bound is reached. Persistence and and 429 when the eight-operation update admission bound is reached. Persistence and
@@ -380,8 +380,8 @@ contracts and INTEGRATIONS_040.md for configuration examples.
## 0.22.3 certificate hot reload and external deployment ## 0.22.3 certificate hot reload and external deployment
`POST /api/certificates/reload` is an authenticated, Origin/CSRF-protected control-plane `POST /api/certificates/reload` is an administrator-session-only, Origin/CSRF-protected control-plane
operation. It re-reads every certificate/key file referenced by the current desired operation. Certificate API bearer tokens are intentionally not accepted by this global endpoint. It re-reads every certificate/key file referenced by the current desired
configuration, validates a fresh immutable routing/TLS snapshot and publishes it at the configuration, validates a fresh immutable routing/TLS snapshot and publishes it at the
**same configuration revision**. Invalid or mismatched material returns an error and the **same configuration revision**. Invalid or mismatched material returns an error and the
previous snapshot stays active. The response contains `reloaded`, `revision`, `public_tls`, previous snapshot stays active. The response contains `reloaded`, `revision`, `public_tls`,
@@ -389,7 +389,7 @@ previous snapshot stays active. The response contains `reloaded`, `revision`, `p
handshakes use the refreshed certificates; established connections are not renegotiated. handshakes use the refreshed certificates; established connections are not renegotiated.
Administration TLS is startup-fixed and therefore reports when a process restart is needed. Administration TLS is startup-fixed and therefore reports when a process restart is needed.
`POST /api/certificates/{certificate_id}/reload` is the targeted variant for a manual/file-backed certificate (`issuer == null`). It validates the requested configured ID and atomically republishes the immutable TLS snapshot at the same configuration revision. It is intended for external renewers that replace the files already referenced by RustProxy. A manual entry may use simple filenames from `<data-dir>/certs` or two absolute external paths such as `/etc/letsencrypt/live/example/fullchain.pem` and `/etc/letsencrypt/live/example/privkey.pem`. Absolute paths are resolved again on every reload, so Certbot's `live/` symlinks follow the newly renewed generation. Application-managed ACME certificate IDs are rejected with `409` because their renewal pipeline owns those files. The endpoint accepts either the normal administrator session + CSRF flow or a scoped Certificate API token. `POST /api/certificates/{certificate_id}/reload` is the targeted variant for a manual/file-backed certificate (`issuer == null`). It validates the requested configured ID and atomically republishes the immutable TLS snapshot at the same configuration revision. It is intended for external renewers that replace the files already referenced by RustProxy. A manual entry may use simple filenames from `<data-dir>/certs` or two absolute external paths such as `/etc/letsencrypt/live/example/fullchain.pem` and `/etc/letsencrypt/live/example/privkey.pem`. Absolute paths are resolved again on every reload, so Certbot's `live/` symlinks follow the newly renewed generation. Application-managed ACME certificate IDs are rejected with `409` because their renewal pipeline owns those files. The endpoint accepts either the normal administrator session + CSRF flow or a Certificate API token whose grant explicitly contains that certificate ID.
`POST /api/certificates/deploy` accepts `POST /api/certificates/deploy` accepts
`{"certificate_id":"shared-cert","cert_pem":"...","key_pem":"..."}` under the same `{"certificate_id":"shared-cert","cert_pem":"...","key_pem":"..."}` under the same
@@ -418,9 +418,11 @@ the generic PEM staging endpoint; deploy/upload request bodies never accept host
paths. Absolute paths may only be saved explicitly in administrator TLS configuration as a paths. Absolute paths may only be saved explicitly in administrator TLS configuration as a
manual certificate source, where they are validated and never returned as PEM contents. manual certificate source, where they are validated and never returned as PEM contents.
## 0.23.0 scoped Certificate API credentials ## 0.28.2 least-privilege Certificate API credentials
External automation no longer needs a full administrator browser session. From **Settings -> Certificate API**, an administrator can create multiple dedicated credentials (up to 32), each consisting of a Key ID, Bearer token and optional non-secret comment. Each Key ID can be rotated independently. The clear token is returned only by the create/rotation response; SQLite stores only its SHA-256 hash plus metadata/comment. Every credential has exactly two scopes: certificate deploy and certificate reload. External automation does not need a full administrator browser session. From **Settings -> Certificate API**, an administrator can create multiple dedicated credentials (up to 32), each consisting of a Key ID, Bearer token, optional non-secret comment and an explicit allowlist of 1-32 certificate IDs. New credentials are **reload-only by default**. Deploy is an independent opt-in permission.
The clear token is returned only by the create/rotation response; SQLite stores only its SHA-256 hash plus metadata and the grant. Credentials created before 0.28.2 migrate to a reload-only compatibility grant for all certificate IDs (`*`). Rotate those credentials after upgrading to replace the wildcard with explicit IDs.
API clients send both headers: API clients send both headers:
@@ -429,7 +431,7 @@ Authorization: Bearer <token>
X-RustProxy-Certificate-Key: <key-id> X-RustProxy-Certificate-Key: <key-id>
``` ```
Example Certbot/acme.sh post-renew reload for an external-path certificate: A reload-only token may call only the targeted endpoint for an allowlisted certificate:
```bash ```bash
curl --fail --show-error -X POST \ curl --fail --show-error -X POST \
@@ -440,14 +442,16 @@ curl --fail --show-error -X POST \
https://proxy-admin.example/api/certificates/shared-cert/reload https://proxy-admin.example/api/certificates/shared-cert/reload
``` ```
A Certificate API credential is accepted only by `POST /api/certificates/deploy`, `POST /api/certificates/reload`, and `POST /api/certificates/{certificate_id}/reload`; it cannot read configuration, logs, metrics or any other administration endpoint. Non-loopback use requires the configured HTTPS administration surface. The normal administrator session + Origin/request-marker + CSRF path remains valid for the same operations. Credential management remains administrator-only: `GET /api/certificate-api` lists metadata, `PUT /api/certificate-api` creates/rotates one Key ID, `DELETE /api/certificate-api/{key_id}` revokes one token, and `DELETE /api/certificate-api` revokes all tokens. Certificate API tokens are **not** accepted by the global `POST /api/certificates/reload` endpoint. This prevents a compromised renewal token from forcing reads/reloads of unrelated certificate material. `POST /api/certificates/deploy` is available only when the credential was explicitly created with deploy permission, and the `certificate_id` in the request body must also be present in that credential's allowlist.
Example external deploy: A Certificate API credential cannot read configuration, logs, metrics, users or any other administration endpoint. Non-loopback use requires the configured HTTPS administration surface. The normal administrator session + Origin/request-marker + CSRF path remains valid for certificate operations. Credential management remains administrator-only: `GET /api/certificate-api` lists metadata/grants, `PUT /api/certificate-api` creates or rotates one Key ID, `DELETE /api/certificate-api/{key_id}` revokes one token, and `DELETE /api/certificate-api` revokes all tokens.
Example external deploy for a token that has **Also allow deploy** enabled and contains `shared-cert` in its allowlist:
```bash ```bash
curl --fail --show-error \ curl --fail --show-error \
-H 'Authorization: Bearer TOKEN' \ -H 'Authorization: Bearer TOKEN' \
-H 'X-RustProxy-Certificate-Key: certbot-main' \ -H 'X-RustProxy-Certificate-Key: deploy-hook' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
--data-binary @deploy.json \ --data-binary @deploy.json \
https://proxy-admin.example/api/certificates/deploy https://proxy-admin.example/api/certificates/deploy
+8 -8
View File
@@ -1,4 +1,4 @@
# Administration API access - 0.28.1 # Administration API access - 0.28.2
## Sign-in boundary ## Sign-in boundary
@@ -101,15 +101,15 @@ by Swagger UI. Native logout/password writes can invalidate the browser session;
sign in again afterward. Ordinary API mutations still require the administrator session, sign in again afterward. Ordinary API mutations still require the administrator session,
Origin/request marker and CSRF token. Origin/request marker and CSRF token.
0.23.0 adds one deliberately narrow exception for certificate automation. An operator can 0.28.2 keeps the deliberately narrow Certificate API exception for automation and makes it least-privilege. An operator can create one or more **Certificate API Key ID + Bearer token** credentials in Settings -> Certificate API. Each new credential requires an explicit allowlist of 1-32 certificate IDs and is reload-only by default. Deploy access must be enabled separately.
create one or more **Certificate API Key ID + Bearer token** credentials in Settings -> Certificate API. Each credential may carry an operator comment and authorizes only:
- `POST /api/certificates/deploy` A reload-only credential authorizes only:
- `POST /api/certificates/reload`
- `POST /api/certificates/{certificate_id}/reload`
Both `Authorization: Bearer <token>` and `X-RustProxy-Certificate-Key: <key-id>` are required. - `POST /api/certificates/{certificate_id}/reload` when that ID is allowlisted.
The clear token is returned only on creation/rotation; the database stores its SHA-256 hash. Up to 32 Key IDs can coexist, and each can be revoked independently or all at once. The credential does not authorize configuration, logs, metrics, users or any other endpoint. Non-loopback use is accepted only on the configured HTTPS administration surface. Revocation from Settings invalidates that credential immediately.
`POST /api/certificates/reload` is administrator-session only and never accepts a Certificate API token. `POST /api/certificates/deploy` accepts a Certificate API token only when the credential has explicit deploy permission and the request's `certificate_id` is also allowlisted.
Both `Authorization: Bearer <token>` and `X-RustProxy-Certificate-Key: <key-id>` are required. The clear token is returned only on creation/rotation; the database stores its SHA-256 hash. Up to 32 Key IDs can coexist, and each can be revoked independently or all at once. Credentials created before 0.28.2 migrate to reload-only compatibility access for all certificate IDs (`*`); rotate them to narrow the allowlist. The credential does not authorize configuration, logs, metrics, users or any other endpoint. Non-loopback use is accepted only on the configured HTTPS administration surface. Revocation from Settings invalidates that credential immediately.
Local host automation does not need this token. The installed binary can run Local host automation does not need this token. The installed binary can run
`rust-proxy tls deploy ...` or `rust-proxy tls reload` directly; it obtains short-lived local `rust-proxy tls deploy ...` or `rust-proxy tls reload` directly; it obtains short-lived local
+2 -2
View File
@@ -9,7 +9,7 @@ each Docker host, so the proxy container never needs Docker-socket access. See
Build locally with a normal OCI builder: Build locally with a normal OCI builder:
```bash ```bash
docker build -t rustproxy:0.28.1 . docker build -t rustproxy:0.28.2 .
``` ```
Persist `/var/lib/rustproxy`; it contains the SQLite database, certificates and the 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 \ docker run --rm -d --name rustproxy \
-p 8080:8080 \ -p 8080:8080 \
-v rustproxy-data:/var/lib/rustproxy \ -v rustproxy-data:/var/lib/rustproxy \
rustproxy:0.28.1 rustproxy:0.28.2
docker exec rustproxy rust-proxy admin setup-token docker exec rustproxy rust-proxy admin setup-token
``` ```
+1 -1
View File
@@ -1,6 +1,6 @@
# Stored log explorer # Stored log explorer
RustProxy 0.28.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. RustProxy 0.28.2 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 ## Separation from Live Logs
+40 -5
View File
@@ -1,4 +1,4 @@
# Installing and updating RustProxy 0.28.1 # Installing and updating RustProxy 0.28.2
The scripts prepare a Linux/systemd installation. This source archive is **not a The scripts prepare a Linux/systemd installation. This source archive is **not a
verified production build**: Rust/Cargo, real CA operations, live systemd startup verified production build**: Rust/Cargo, real CA operations, live systemd startup
@@ -201,7 +201,7 @@ The provider script is embedded in the RustProxy binary; this command is diagnos
If another service already owns a certificate through the system Certbot, keep that lineage If another service already owns a certificate through the system Certbot, keep that lineage
outside RustProxy's managed ACME profiles and create/use a **manual** RustProxy certificate id. outside RustProxy's managed ACME profiles and create/use a **manual** RustProxy certificate id.
The preferred 0.28.1 workflow is to select **Existing server files / external paths** and then The preferred 0.28.2 workflow is to select **Existing server files / external paths** and then
**External files (absolute paths)** in the Certificates panel. For example: **External files (absolute paths)** in the Certificates panel. For example:
```text ```text
@@ -223,9 +223,44 @@ curl --fail --show-error -X POST \
``` ```
The `rustproxy` service identity must be able to traverse the parent directories and read both The `rustproxy` service identity must be able to traverse the parent directories and read both
files. An external private key may be `0600` when owned by that identity or `0640` with a files. RustProxy 0.28.2 supports POSIX ACLs and does not require one exact chmod value. External
restricted group readable by RustProxy; world access, group write and executable bits are certificate/key files must not be group/world-writable, and an external private key must not be
rejected. These external files are not included in a RustProxy backup. world-readable. Named ACL read access for `rustproxy` is accepted; the operating system still
performs the final access check when RustProxy opens the file. Every parent directory in the
configured and resolved path must be owned by root or the RustProxy service user and must not be
group/world-writable (root-owned sticky ancestors such as `/tmp` are the narrow exception).
These external files are not included in a RustProxy backup.
The packaged systemd service keeps `/etc/letsencrypt` and `/etc/ssl` read-only. `ReadOnlyPaths=`
does **not** grant Unix permissions, so use narrow ACLs instead of making private keys globally
readable. For example:
```bash
# Traversal only for the protected roots.
setfacl -m u:rustproxy:--x /etc/ssl/
setfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/
# Traversal only for this Certbot lineage.
setfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/
# Read only the certificate/key material RustProxy actually needs.
setfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem \
/etc/letsencrypt/archive/example.com/privkey*.pem
# Optional default ACL for renewers that preserve inherited ACL masks.
setfacl -m d:u:rustproxy:r-- /etc/letsencrypt/archive/example.com/
# Robust renewal-hook form: re-grant read access to the current generation before API reload.
setfacl -m u:rustproxy:r-- \
"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)" \
"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)"
```
Because `/etc/letsencrypt/live/.../*.pem` are symlinks into `archive/`, access must cover the
resolved archive files. Some renewal tools recreate private keys as `0600` and may mask an
inherited default ACL; therefore the renewal wrapper should reapply the named file ACL before
calling the RustProxy reload API. Never grant `w` to `rustproxy` on the Certbot tree. Verify a deployment
with `sudo -u rustproxy test -r /etc/letsencrypt/live/example.com/privkey.pem && echo OK`.
If you do not want the RustProxy service to read `/etc/letsencrypt`, keep the previous copy/deploy If you do not want the RustProxy service to read `/etc/letsencrypt`, keep the previous copy/deploy
workflow instead. A Certbot deploy hook can publish every successful renewal into RustProxy's workflow instead. A Certbot deploy hook can publish every successful renewal into RustProxy's
+6
View File
@@ -1,5 +1,11 @@
# Deployment and configuration migration history # Deployment and configuration migration history
## 0.28.1 -> 0.28.2
SQLite schema 8 is upgraded automatically to schema 9. The additive migration creates `certificate_api_grants`. Existing Certificate API credentials become reload-only compatibility credentials covering all certificate IDs (`*`); rotate them in **Settings -> Certificate API** to replace the wildcard with explicit certificate IDs. New credentials require 1-32 explicit certificate IDs and are reload-only unless deploy permission is enabled.
The saved proxy configuration document and COLD cache format are unchanged. External certificate paths keep their 0.28.1 semantics, but 0.28.2 validates the full configured/resolved parent chain and accepts POSIX ACL read access without requiring an exact chmod value. The packaged systemd service marks `/etc/letsencrypt` and `/etc/ssl` read-only; Unix permissions/ACLs still need to grant the `rustproxy` user the required traversal/read access.
## 0.26.1 -> 0.26.2 ## 0.26.1 -> 0.26.2
No SQLite or saved-configuration migration is required. Version 0.26.2 changes only administration/log-explorer behavior and account-scoped view preferences. Existing JSONL and RAW log files remain readable, and old Stored Logs filter query parameters remain accepted by the API for compatibility even though the dashboard now uses one query field. No SQLite or saved-configuration migration is required. Version 0.26.2 changes only administration/log-explorer behavior and account-scoped view preferences. Existing JSONL and RAW log files remain readable, and old Stored Logs filter query parameters remain accepted by the API for compatibility even though the dashboard now uses one query field.
+2 -1
View File
@@ -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.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 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.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.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. - [0.28.1 implementation note](V0.28.1_IMPLEMENTATION.md) - external certificate paths, Certbot symlink reloads, backup semantics and certificate-source UX.
- [0.28.0 implementation note](V0.28.0_IMPLEMENTATION.md) - manual/hot-reloaded GeoIP, listener status UX, cache Via/narrow invalidation and header-rule layout. - [0.28.0 implementation note](V0.28.0_IMPLEMENTATION.md) - manual/hot-reloaded GeoIP, listener status UX, cache Via/narrow invalidation and header-rule layout.
- [0.27.3 implementation note](V0.27.3_IMPLEMENTATION.md) - HTTP/3 HEAD cache parity, COLD/rate-limit memory reduction, backup scope and package-path update. - [0.27.3 implementation note](V0.27.3_IMPLEMENTATION.md) - HTTP/3 HEAD cache parity, COLD/rate-limit memory reduction, backup scope and package-path update.
@@ -73,7 +74,7 @@ This is the canonical index for the current project documentation. The authentic
## Historical or version-labelled context ## 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.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md); [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.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md); [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. 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.
+31 -5
View File
@@ -79,7 +79,7 @@ migration of its stored paths. A system-wide timer should not also manage these
RustProxy-isolated profiles. RustProxy-isolated profiles.
For a certificate intentionally owned by a separate system Certbot instead of this managed For a certificate intentionally owned by a separate system Certbot instead of this managed
renewal subsystem, 0.28.1 can reference the Certbot lineage directly. Configure a manual renewal subsystem, 0.28.2 can reference the Certbot lineage directly. Configure a manual
certificate id with both absolute paths, for example certificate id with both absolute paths, for example
`/etc/letsencrypt/live/example.com/fullchain.pem` and `/etc/letsencrypt/live/example.com/fullchain.pem` and
`/etc/letsencrypt/live/example.com/privkey.pem`. RustProxy resolves the `live/` symlinks again on `/etc/letsencrypt/live/example.com/privkey.pem`. RustProxy resolves the `live/` symlinks again on
@@ -94,10 +94,36 @@ curl --fail --show-error -X POST \
https://proxy-admin.example/api/certificates/shared-cert/reload https://proxy-admin.example/api/certificates/shared-cert/reload
``` ```
The service identity must be able to traverse the Certbot directories and read the files. External The service identity must be able to traverse the Certbot directories and read the files. RustProxy
private keys may be owner-only (`0600`) or group-readable (`0640`) for a restricted group; they 0.28.2 accepts named POSIX ACL read access and does not require an exact `0600`/`0640` mode.
must not be executable, group-writable or world-accessible. The referenced files are not copied External certificate/key files must not be group/world-writable, and private keys must not be
into RustProxy backups. world-readable. Parent directories must be root- or RustProxy-owned and not group/world-writable
(apart from root-owned sticky ancestors such as `/tmp`). The referenced files are not copied into
RustProxy backups.
For a normal Certbot lineage, grant traversal without directory listing and read only the archive
files. A default ACL is useful, but a renewer may create a new private key with mode `0600` and
mask inherited ACL permissions. The reliable hook therefore reapplies the named read ACL to the
newly resolved generation before calling the reload API:
```bash
setfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/
setfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/
setfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem \
/etc/letsencrypt/archive/example.com/privkey*.pem
setfacl -m d:u:rustproxy:r-- /etc/letsencrypt/archive/example.com/
# Run these two resolved-file grants after renewal, before the API reload.
setfacl -m u:rustproxy:r-- \
"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)" \
"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)"
```
For external material below `/etc/ssl`, traversal of the protected root can be granted separately:
```bash
setfacl -m u:rustproxy:--x /etc/ssl/
```
If direct read access to the Certbot tree is undesirable, use the copy/deploy workflow instead: If direct read access to the Certbot tree is undesirable, use the copy/deploy workflow instead:
+1 -1
View File
@@ -47,7 +47,7 @@ This is a settings fragment, not a replacement for the existing routing/vhost do
A complete opt-in example is in `examples/cache-dashboard.json`. A complete opt-in example is in `examples/cache-dashboard.json`.
The dashboard exposes the complete raw JSON editor under **Settings -> Manual config**. The dashboard exposes the complete raw JSON editor under **Settings -> Manual config**.
The same card exposes **Revision history**, which reads the newest 512 retained configuration revisions from SQLite schema 8 and groups them by month/day. Selecting a revision shows timestamp, actor and a bounded path-level before/after diff against the previous retained revision. An older retained revision can be restored from the same dialog; RustProxy validates it through the normal configuration update path and saves it as a new revision, preserving the prior history. The same card exposes **Revision history**, which reads the newest 512 retained configuration revisions from the configuration-history table introduced in SQLite schema 8 (current database schema 9) and groups them by month/day. Selecting a revision shows timestamp, actor and a bounded path-level before/after diff against the previous retained revision. An older retained revision can be restored from the same dialog; RustProxy validates it through the normal configuration update path and saves it as a new revision, preserving the prior history.
The historical `/configuration` browser route is retained only as a compatibility alias and The historical `/configuration` browser route is retained only as a compatibility alias and
opens that Settings block; it is no longer a primary-navigation item. Prefer the structured opens that Settings block; it is no longer a primary-navigation item. Prefer the structured
section editors for routine changes so unrelated sections are not republished. section editors for routine changes so unrelated sections are not republished.
+20 -29
View File
@@ -1,38 +1,29 @@
# Verification report - RustProxy 0.28.1 # Verification report - RustProxy 0.28.2
## Scope ## Scope
RustProxy 0.28.1 adds absolute external certificate/key paths for manual certificates while preserving the existing managed filename mode under `<data-dir>/certs`. The intended renewal path is Certbot/acme.sh updating `/etc/letsencrypt/live/...` followed by `POST /api/certificates/{certificate_id}/reload`. External paths are canonicalized on every load/reload so Certbot `live/` symlinks follow the current archive generation, then the final file is opened as a bounded regular file. Mixed managed/external pairs are rejected, certificate/key matching remains mandatory and managed private keys retain owner-only checks, while external keys may be `0640` for a restricted service group but cannot be group-writable, executable or world-accessible. RustProxy 0.28.2 hardens the 0.28.1 external-certificate workflow. Absolute certificate/key paths are validated across the configured and resolved parent chains, the final file is opened with `O_NOFOLLOW`, POSIX ACL read access is supported without requiring an exact chmod value, private keys may not be world-readable, and external certificate/key files may not be group/world-writable. The packaged systemd service explicitly keeps `/etc/letsencrypt` and `/etc/ssl` read-only while normal Unix permissions/ACLs remain authoritative for access.
The targeted reload path remains revision-stable and republishes one immutable TLS snapshot. New public H1/H2/H3 handshakes use the refreshed material; invalid replacement files leave the previous snapshot active. The deploy endpoint refuses an ID configured with absolute external paths so a renewal wrapper cannot accidentally change storage ownership. Externally referenced files are excluded from RustProxy backups; only their configured paths survive, and the operator must restore or mount the external material separately. Certificate automation is also narrowed. SQLite schema 9 adds per-Key-ID grants: new Certificate API tokens require explicit certificate IDs, are reload-only by default, cannot call the global all-certificate reload endpoint, and require an explicit deploy grant before `POST /api/certificates/deploy` is accepted. Credentials from earlier releases migrate to reload-only wildcard compatibility grants and should be rotated to explicit IDs.
The certificate UI now distinguishes RustProxy-managed filenames from external absolute paths and provides explicit English/Polish storage guidance for ACME, Import and Existing-files modes. 0.28.1 retains all 0.28.0 GeoIP direct-source/MMDB/hot-swap, listener protocol-badge, ACME runtime-reporting, cache Via/narrow invalidation and Security-editor behavior. SQLite schema 8 and COLD cache format 3 remain unchanged. The release retains the 0.28.0 GeoIP direct-source/MMDB hot reload, listener protocol badges, cache Via/narrow invalidation, Security-editor layout and ACME runtime reporting. COLD cache format 3 and saved proxy configuration format remain unchanged.
## Verification status in this workspace ## Verification
Rust/Cargo is not installed in this workspace. Rust-independent release checks can run here; compiler/type/runtime gates and real H2/H3 protocol execution require a Rust-enabled host.
## Release checks
| Check | Result | | Check | Result |
| --- | --- | |---|---|
| Rust/source module contracts | PASS | | Source/static contracts | PASS - `tools/checks/check_source.py` |
| GeoIP direct-source/optional-checksum/MMDB/manual endpoint/UI/hot-swap source contracts | PASS | | Documentation/index/link checks | PASS - `tools/checks/docs_check.py` |
| Cache Via/narrow invalidation source contracts | PASS | | Browser-side JavaScript syntax/UI contracts | PASS - `./dev.sh test` pre-Cargo stages |
| Listener protocol-badge/header-editor UI source contracts | PASS | | SQLite schema-9 migration and release smoke | PASS - `tools/checks/release_smoke.py`, including legacy-token grant migration |
| ACME startup reporting contract (no speculative banner state; final INFO event) | PASS | | Docker Agent Go tests | PASS - `go test ./...` |
| External certificate path / Certbot-symlink / managed-vs-external source contracts | PASS | | Shell/Python/JSON syntax checks | PASS - release/static checks plus explicit shell/JSON validation |
| Documentation and language-pack checks | PASS | | `cargo fmt/check/test` | NOT VERIFIED - Rust/Cargo are not installed in this verification environment; `./dev.sh test` exits 127 only after all pre-Cargo gates pass |
| Web Server, security and dependency source contracts | PASS | | Live Certbot ACL + targeted API reload | NOT VERIFIED - requires a compiled binary and host certificate material |
| `tools/checks/check_source.py` | PASS (compiler/runtime explicitly not verified) | | Live HTTP/2 + HTTP/3 protocol tests | NOT VERIFIED - requires a compiled binary and QUIC-capable runtime |
| `tools/checks/release_smoke.py` | PASS |
| Python syntax (`py_compile`) and direct-CSV installer validation checks | PASS |
| Browser Web Service UI regression (`node tools/checks/web_service_ui_test.mjs`) | PASS |
| Docker Agent (`go test ./...`) | PASS |
| `./dev.sh test` static/pre-compiler stages | PASS; command then exits 127 at the Rust/Cargo gate |
| Rust `cargo fmt/check/test`, linking and Rust unit tests (including external-path/Certbot-symlink reload, synthetic standard/sapics/custom-MMDB lookup fixtures, GeoIP hot-swap, cache Via and cache-revision tests) | NOT VERIFIED - Cargo/Rust is not installed in this workspace |
| Live manual/automatic GeoIP direct-source download and real-world MMDB lookup | NOT VERIFIED - requires network access and a compiled 0.28.1 binary |
| Live external Certbot-path reload through `POST /api/certificates/{certificate_id}/reload` | NOT VERIFIED - requires a compiled 0.28.1 binary plus readable external certificate material |
| Live HTTP/2 + HTTP/3 protocol tests | NOT VERIFIED - requires a compiled 0.28.1 binary and QUIC-capable runtime |
`./dev.sh test` is expected to exit with status 127 only when it reaches the Rust toolchain gate; all earlier release/static checks must complete successfully for this report to remain valid. ## Security regression coverage
The source tests include Certbot-style `live/ -> archive/` symlink refresh behavior, acceptance of ACL-style group-mask bits, rejection of group/world-writable or world-readable private-key modes, and rejection of writable non-sticky parent directories. Administration tests cover targeted certificate-ID grants, denial of global reload for Certificate API tokens, rejection of non-allowlisted certificate IDs and independent token revocation.
See [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) for the operator ACL example and migration behavior.
+1 -1
View File
@@ -132,7 +132,7 @@ required when its selected certificate changed.
For an externally managed/manual certificate whose configured files were replaced in place, `POST /api/certificates/{certificate_id}/reload` provides the same revision-stable hot-publication contract while targeting that certificate ID. For absolute external paths RustProxy canonicalizes the path again on every reload, intentionally following Certbot `live/` symlinks to the current `archive/` generation before opening the final regular file. The TLS resolver is immutable, so RustProxy validates and republishes a complete snapshot atomically; this avoids partially updated SNI state. Managed ACME certificate IDs are not accepted by this manual reload path. For an externally managed/manual certificate whose configured files were replaced in place, `POST /api/certificates/{certificate_id}/reload` provides the same revision-stable hot-publication contract while targeting that certificate ID. For absolute external paths RustProxy canonicalizes the path again on every reload, intentionally following Certbot `live/` symlinks to the current `archive/` generation before opening the final regular file. The TLS resolver is immutable, so RustProxy validates and republishes a complete snapshot atomically; this avoids partially updated SNI state. Managed ACME certificate IDs are not accepted by this manual reload path.
External absolute-path files are not copied into RustProxy full backups. Their path strings remain in configuration; restore or mount the certificate material separately on the destination host before starting TLS. The `rustproxy` service identity must be able to traverse every parent directory and read both files. External private keys may be owner-only (`0600`) or read-only for a restricted service group (`0640`); group-write, execute and any world permission are rejected. External absolute-path files are not copied into RustProxy full backups. Their path strings remain in configuration; restore or mount the certificate material separately on the destination host before starting TLS. The `rustproxy` service identity must be able to traverse every parent directory and read both files. RustProxy accepts POSIX ACL read access and does not require an exact chmod value. External certificate/key files must not be group/world-writable, and private keys must not be world-readable. The configured and resolved parent chains must be root- or RustProxy-owned and not group/world-writable, except for root-owned sticky ancestors such as `/tmp`. The final file is opened with `O_NOFOLLOW` after path validation.
## WebSocket behavior ## WebSocket behavior
+45
View File
@@ -0,0 +1,45 @@
# RustProxy 0.28.2 implementation note
## External certificate path hardening
0.28.2 keeps the 0.28.1 direct-file workflow for Certbot/acme.sh certificates but changes validation from an exact-mode expectation to a security-policy check. POSIX ACLs are supported: a private key may be owner-readable or group/ACL-readable by the `rustproxy` service identity. RustProxy rejects a private key that is world-readable and rejects group/world write access on every external certificate/key file. Execute bits alone are not treated as key disclosure.
For an absolute external path RustProxy validates the configured parent chain before resolving the final file, resolves the final Certbot-style `live/*.pem` symlink, validates the resolved parent chain again, and opens the final regular file with `O_NOFOLLOW`. On Unix, parent directories must be owned by root or the running RustProxy service user and must not be group/world-writable. Root-owned sticky ancestors such as `/tmp` remain accepted for controlled test/custom trees.
This closes the writable-parent/path-redirection gap while preserving Certbot renewal semantics: after `certbot renew`, a targeted reload follows the current `live/ -> archive/` generation and publishes a new immutable TLS snapshot only after the pair validates.
## ACL example
The packaged systemd unit keeps RustProxy writable only in its data/log directories and explicitly marks `/etc/letsencrypt` and `/etc/ssl` read-only when those paths exist:
```ini
ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy
ReadOnlyPaths=-/etc/letsencrypt -/etc/ssl
```
`ReadOnlyPaths` does not grant Unix access. For traversal-only access to protected directories and read access to the actual PEM files, use narrow POSIX ACLs. Example:
```bash
setfacl -m u:rustproxy:--x /etc/ssl/
setfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/
setfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/
setfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem /etc/letsencrypt/archive/example.com/privkey*.pem
setfacl -m d:u:rustproxy:r-- /etc/letsencrypt/archive/example.com/
```
A default ACL on the archive lineage is useful, but a renewer may recreate a private key as `0600` and mask inherited named-user access. A renewal hook should therefore reapply `u:rustproxy:r--` to the current resolved `fullchain.pem` and `privkey.pem` targets before calling the reload API. Do not grant `w` to the `rustproxy` identity on the Certbot tree.
## Least-privilege Certificate API
SQLite schema 9 adds `certificate_api_grants`. New Certificate API credentials require 1-32 explicit certificate IDs and are reload-only unless **Also allow deploy** is enabled. Token authentication is accepted only for targeted `POST /api/certificates/{certificate_id}/reload`; the global `POST /api/certificates/reload` remains administrator-session only. Deploy tokens must have both explicit deploy permission and an allowlisted request certificate ID.
Credentials created before 0.28.2 migrate to reload-only compatibility grants covering all certificate IDs (`*`). Rotate those credentials in Settings -> Certificate API to replace the broad compatibility grant with explicit IDs.
Normal administrator session + CSRF access remains unchanged. Certificate API tokens still cannot read configuration or call unrelated administration APIs, and non-loopback token use still requires HTTPS.
## Compatibility
- SQLite schema: 9 (additive migration from schema 8).
- Saved proxy configuration: unchanged from 0.28.1.
- COLD cache format: 3, unchanged.
- External certificate paths/backups: same semantics as 0.28.1; external PEM bytes are not copied into RustProxy backups.
+1 -1
View File
@@ -19,7 +19,7 @@ classes, cache outcomes, capture-state gauges and configured-route traffic. Raw
are intentionally absent from Prometheus labels. are intentionally absent from Prometheus labels.
Import the JSON in Grafana and select a Prometheus datasource when prompted. The Import the JSON in Grafana and select a Prometheus datasource when prompted. The
dashboard expects RustProxy 0.28.1 metric names and a scrape interval around 15s. dashboard expects RustProxy 0.28.2 metric names and a scrape interval around 15s.
Collectors can be disabled in RustProxy settings; panels for disabled collectors Collectors can be disabled in RustProxy settings; panels for disabled collectors
will simply show no series. will simply show no series.
+2 -2
View File
@@ -4529,9 +4529,9 @@
}, },
"timepicker": {}, "timepicker": {},
"timezone": "browser", "timezone": "browser",
"title": "RustProxy 0.28.1 - Operations", "title": "RustProxy 0.28.2 - Operations",
"uid": "rustproxy-0170", "uid": "rustproxy-0170",
"version": 5, "version": 5,
"weekStart": "", "weekStart": "",
"description": "RustProxy 0.28.1 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.28.2 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
} }
+16 -3
View File
@@ -1690,14 +1690,14 @@
"cacheTabCold": "Cold cache", "cacheTabCold": "Cold cache",
"cacheTabDiagnostics": "Static & diagnostics", "cacheTabDiagnostics": "Static & diagnostics",
"certificateApiTitle": "Certificate API", "certificateApiTitle": "Certificate API",
"certificateApiHelp": "Create separate Key ID + Bearer Token credentials for certificate deploy/reload automation. Tokens cannot read configuration or use other administration APIs.", "certificateApiHelp": "Create separate Key ID + Bearer Token credentials for certificate automation. New tokens are reload-only by default and are restricted to the listed certificate IDs. Enable deploy only for automation that must upload certificate material.",
"certificateApiKeyId": "Key ID", "certificateApiKeyId": "Key ID",
"certificateApiToken": "Bearer token (optional custom value)", "certificateApiToken": "Bearer token (optional custom value)",
"certificateApiTokenHelp": "Leave the field empty to generate a strong token. Each cleartext token is shown only once; RustProxy stores only its hash.", "certificateApiTokenHelp": "Leave the field empty to generate a strong token. Each cleartext token is shown only once; RustProxy stores only its hash.",
"certificateApiRotate": "Generate / rotate token", "certificateApiRotate": "Generate / rotate token",
"certificateApiRevoke": "Revoke token", "certificateApiRevoke": "Revoke token",
"certificateApiOneTimeToken": "Copy this token now", "certificateApiOneTimeToken": "Copy this token now",
"certificateApiCurlHelp": "External automation sends the Key ID header and Bearer token to the certificate deploy/reload endpoints over HTTPS (or loopback HTTP).", "certificateApiCurlHelp": "External automation sends the Key ID header and Bearer token over HTTPS (or loopback HTTP). Reload tokens must use the targeted /api/certificates/{id}/reload endpoint; a token cannot call the global reload endpoint.",
"certificateApiUnknown": "Loading certificate API status…", "certificateApiUnknown": "Loading certificate API status…",
"certificateApiConfigured": "Certificate API enabled", "certificateApiConfigured": "Certificate API enabled",
"certificateApiDisabled": "Certificate API disabled", "certificateApiDisabled": "Certificate API disabled",
@@ -1721,6 +1721,14 @@
"connectionActivity": "CONNECTION ACTIVITY", "connectionActivity": "CONNECTION ACTIVITY",
"reloadCertificate": "Reload certificate", "reloadCertificate": "Reload certificate",
"certificateApiComment": "Comment", "certificateApiComment": "Comment",
"certificateApiCertificateIds": "Allowed certificate IDs",
"certificateApiCertificateIdsHelp": "One or more certificate IDs separated by commas or new lines. The token is rejected for every other certificate ID.",
"certificateApiCertificateIdsRequired": "Enter at least one certificate ID for this token.",
"certificateApiAllowDeploy": "Also allow deploy",
"certificateApiAllowDeployHelp": "Leave disabled for Certbot/acme.sh renewal hooks. Reload-only is the recommended least-privilege mode.",
"certificateApiScopeReloadOnly": "reload only",
"certificateApiScopeDeployReload": "deploy + reload",
"certificateApiAllowedIds": "allowed IDs",
"certificateApiConfiguredCount": "Certificate API tokens: {count} / {max}", "certificateApiConfiguredCount": "Certificate API tokens: {count} / {max}",
"certificateApiNeverUsed": "never used", "certificateApiNeverUsed": "never used",
"certificateApiRevokeAll": "Revoke all tokens", "certificateApiRevokeAll": "Revoke all tokens",
@@ -1729,6 +1737,10 @@
"certificate_api.invalid_comment": "Certificate API comments must be at most 256 characters and contain no control characters.", "certificate_api.invalid_comment": "Certificate API comments must be at most 256 characters and contain no control characters.",
"certificate_api.limit_reached": "The Certificate API token limit has been reached.", "certificate_api.limit_reached": "The Certificate API token limit has been reached.",
"certificate_api.not_found": "Certificate API token not found.", "certificate_api.not_found": "Certificate API token not found.",
"certificate_api.invalid_certificate_ids": "Certificate API tokens require 1-32 valid certificate IDs.",
"certificate_api.target_required": "Certificate API tokens cannot reload every certificate at once; use the targeted certificate reload endpoint.",
"certificate_api.certificate_forbidden": "This Certificate API token is not allowed to operate on that certificate ID.",
"certificate_api.deploy_forbidden": "This Certificate API token is reload-only. Rotate it with deploy permission only if upload/deploy automation is required.",
"networkInterfacesVisibleCount": "{visible} / {total} interfaces", "networkInterfacesVisibleCount": "{visible} / {total} interfaces",
"hideMetric": "Hide", "hideMetric": "Hide",
"showMetric": "Show", "showMetric": "Show",
@@ -2187,7 +2199,8 @@
"certificateSourceManaged": "RustProxy certificate directory (<data-dir>/certs)", "certificateSourceManaged": "RustProxy certificate directory (<data-dir>/certs)",
"certificateSourceExternal": "External files (absolute paths)", "certificateSourceExternal": "External files (absolute paths)",
"certificateSourceManagedHelp": "Enter filenames only, for example app.pem and app.key. RustProxy loads them from <data-dir>/certs (normally /var/lib/rustproxy/certs for a packaged service).", "certificateSourceManagedHelp": "Enter filenames only, for example app.pem and app.key. RustProxy loads them from <data-dir>/certs (normally /var/lib/rustproxy/certs for a packaged service).",
"certificateSourceExternalHelp": "Enter absolute server paths. Certbot example: /etc/letsencrypt/live/example.com/fullchain.pem and /etc/letsencrypt/live/example.com/privkey.pem. Certbot live/ symlinks are resolved again on every reload. The rustproxy service user needs directory traversal and file read access; external private keys may use 0600 when owned by the service or 0640 with a restricted group, never world access.", "certificateSourceExternalHelp": "Enter absolute server paths. Certbot example: /etc/letsencrypt/live/example.com/fullchain.pem and /etc/letsencrypt/live/example.com/privkey.pem. live/ symlinks are resolved on every reload. POSIX ACL read access is supported; RustProxy does not require an exact chmod. The private key must not be world-readable or group/world-writable, and the path must stay under root- or rustproxy-owned non-writable directories.",
"certificateExternalAclExample": "ACL example (grant traversal, not directory read):\nsetfacl -m u:rustproxy:--x /etc/ssl/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/\nsetfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem /etc/letsencrypt/archive/example.com/privkey*.pem\nAfter renewal, reapply read ACL to the current resolved files before API reload:\nsetfacl -m u:rustproxy:r-- \"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)\" \"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)\"",
"certificateExternalFiles": "External file paths" "certificateExternalFiles": "External file paths"
} }
} }
+16 -3
View File
@@ -1690,14 +1690,14 @@
"cacheTabCold": "Cold cache", "cacheTabCold": "Cold cache",
"cacheTabDiagnostics": "Statyczne i diagnostyka", "cacheTabDiagnostics": "Statyczne i diagnostyka",
"certificateApiTitle": "API certyfikatów", "certificateApiTitle": "API certyfikatów",
"certificateApiHelp": "Twórz osobne poświadczenia Key ID + Bearer Token dla automatyzacji wdrażania i przeładowywania certyfikatów. Tokeny nie dają dostępu do konfiguracji ani pozostałych API administracyjnych.", "certificateApiHelp": "Twórz osobne poświadczenia Key ID + Bearer Token dla automatyzacji certyfikatów. Nowe tokeny są domyślnie tylko do reload i są ograniczone do wskazanych ID certyfikatów. Włącz deploy tylko dla automatyzacji, która musi wysyłać materiał certyfikatu.",
"certificateApiKeyId": "Key ID", "certificateApiKeyId": "Key ID",
"certificateApiToken": "Bearer token (opcjonalna własna wartość)", "certificateApiToken": "Bearer token (opcjonalna własna wartość)",
"certificateApiTokenHelp": "Pozostaw pole puste, aby wygenerować silny token. Jawna wartość każdego tokenu jest pokazywana tylko raz; RustProxy zapisuje wyłącznie jego hash.", "certificateApiTokenHelp": "Pozostaw pole puste, aby wygenerować silny token. Jawna wartość każdego tokenu jest pokazywana tylko raz; RustProxy zapisuje wyłącznie jego hash.",
"certificateApiRotate": "Generuj / obróć token", "certificateApiRotate": "Generuj / obróć token",
"certificateApiRevoke": "Unieważnij token", "certificateApiRevoke": "Unieważnij token",
"certificateApiOneTimeToken": "Skopiuj ten token teraz", "certificateApiOneTimeToken": "Skopiuj ten token teraz",
"certificateApiCurlHelp": "Automatyzacja zewnętrzna wysyła nagłówek Key ID i Bearer Token do endpointów deploy/reload przez HTTPS (lub HTTP po loopback).", "certificateApiCurlHelp": "Automatyzacja zewnętrzna wysyła Key ID i Bearer Token przez HTTPS (lub HTTP po loopback). Token reload musi używać /api/certificates/{id}/reload; token API nie może wywołać globalnego reload wszystkich certyfikatów.",
"certificateApiUnknown": "Ładowanie statusu API certyfikatów…", "certificateApiUnknown": "Ładowanie statusu API certyfikatów…",
"certificateApiConfigured": "API certyfikatów włączone", "certificateApiConfigured": "API certyfikatów włączone",
"certificateApiDisabled": "API certyfikatów wyłączone", "certificateApiDisabled": "API certyfikatów wyłączone",
@@ -1721,6 +1721,14 @@
"connectionActivity": "AKTYWNOŚĆ POŁĄCZEŃ", "connectionActivity": "AKTYWNOŚĆ POŁĄCZEŃ",
"reloadCertificate": "Przeładuj certyfikat", "reloadCertificate": "Przeładuj certyfikat",
"certificateApiComment": "Komentarz", "certificateApiComment": "Komentarz",
"certificateApiCertificateIds": "Dozwolone ID certyfikatów",
"certificateApiCertificateIdsHelp": "Podaj jedno lub więcej ID certyfikatów oddzielonych przecinkiem lub nową linią. Dla każdego innego ID token zostanie odrzucony.",
"certificateApiCertificateIdsRequired": "Podaj co najmniej jedno ID certyfikatu dla tego tokenu.",
"certificateApiAllowDeploy": "Zezwól także na deploy",
"certificateApiAllowDeployHelp": "Dla hooka odnowienia Certbot/acme.sh pozostaw wyłączone. Tryb tylko reload jest zalecanym wariantem least-privilege.",
"certificateApiScopeReloadOnly": "tylko reload",
"certificateApiScopeDeployReload": "deploy + reload",
"certificateApiAllowedIds": "dozwolone ID",
"certificateApiConfiguredCount": "Tokeny API certyfikatów: {count} / {max}", "certificateApiConfiguredCount": "Tokeny API certyfikatów: {count} / {max}",
"certificateApiNeverUsed": "nigdy nie użyty", "certificateApiNeverUsed": "nigdy nie użyty",
"certificateApiRevokeAll": "Unieważnij wszystkie tokeny", "certificateApiRevokeAll": "Unieważnij wszystkie tokeny",
@@ -1729,6 +1737,10 @@
"certificate_api.invalid_comment": "Komentarz tokenu API certyfikatów może mieć maksymalnie 256 znaków i nie może zawierać znaków sterujących.", "certificate_api.invalid_comment": "Komentarz tokenu API certyfikatów może mieć maksymalnie 256 znaków i nie może zawierać znaków sterujących.",
"certificate_api.limit_reached": "Osiągnięto limit tokenów API certyfikatów.", "certificate_api.limit_reached": "Osiągnięto limit tokenów API certyfikatów.",
"certificate_api.not_found": "Nie znaleziono tokenu API certyfikatów.", "certificate_api.not_found": "Nie znaleziono tokenu API certyfikatów.",
"certificate_api.invalid_certificate_ids": "Token API certyfikatów wymaga 1-32 poprawnych ID certyfikatów.",
"certificate_api.target_required": "Token API certyfikatów nie może przeładować wszystkich certyfikatów naraz; użyj endpointu reload dla konkretnego ID.",
"certificate_api.certificate_forbidden": "Ten token API certyfikatów nie ma dostępu do wskazanego ID certyfikatu.",
"certificate_api.deploy_forbidden": "Ten token API certyfikatów ma tylko uprawnienie reload. Włącz deploy przy rotacji wyłącznie, jeśli automatyzacja musi wysyłać certyfikat.",
"networkInterfacesVisibleCount": "{visible} / {total} interfejsów", "networkInterfacesVisibleCount": "{visible} / {total} interfejsów",
"hideMetric": "Ukryj", "hideMetric": "Ukryj",
"showMetric": "Pokaż", "showMetric": "Pokaż",
@@ -2187,7 +2199,8 @@
"certificateSourceManaged": "Katalog certyfikatów RustProxy (<data-dir>/certs)", "certificateSourceManaged": "Katalog certyfikatów RustProxy (<data-dir>/certs)",
"certificateSourceExternal": "Pliki zewnętrzne (pełne ścieżki)", "certificateSourceExternal": "Pliki zewnętrzne (pełne ścieżki)",
"certificateSourceManagedHelp": "Podaj tylko nazwy plików, np. app.pem i app.key. RustProxy ładuje je z <data-dir>/certs (przy instalacji pakietowej zwykle /var/lib/rustproxy/certs).", "certificateSourceManagedHelp": "Podaj tylko nazwy plików, np. app.pem i app.key. RustProxy ładuje je z <data-dir>/certs (przy instalacji pakietowej zwykle /var/lib/rustproxy/certs).",
"certificateSourceExternalHelp": "Podaj pełne ścieżki na serwerze. Przykład Certbota: /etc/letsencrypt/live/example.com/fullchain.pem oraz /etc/letsencrypt/live/example.com/privkey.pem. Symlinki Certbota w live/ są rozwiązywane ponownie przy każdym przeładowaniu. Użytkownik usługi rustproxy musi mieć prawo przejścia przez katalogi i odczytu plików; zewnętrzny klucz może mieć 0600, gdy należy do usługi, albo 0640 z ograniczoną grupą, nigdy dostęp dla innych.", "certificateSourceExternalHelp": "Podaj pełne ścieżki na serwerze. Przykład Certbota: /etc/letsencrypt/live/example.com/fullchain.pem oraz /etc/letsencrypt/live/example.com/privkey.pem. Symlinki live/ są rozwiązywane przy każdym reloadzie. Odczyt przez POSIX ACL jest obsługiwany i RustProxy nie wymaga konkretnego chmod. Klucz prywatny nie może być czytelny dla innych ani zapisywalny przez grupę/innych, a katalogi ścieżki muszą być własnością root lub rustproxy i nie mogą być zapisywalne przez grupę/innych.",
"certificateExternalAclExample": "Przykład ACL (prawo przejścia bez listowania katalogu):\nsetfacl -m u:rustproxy:--x /etc/ssl/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/\nsetfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem /etc/letsencrypt/archive/example.com/privkey*.pem\nPo odnowieniu nadaj odczyt bieżącym plikom docelowym przed reloadem API:\nsetfacl -m u:rustproxy:r-- \"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)\" \"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)\"",
"certificateExternalFiles": "Zewnętrzne ścieżki plików" "certificateExternalFiles": "Zewnętrzne ścieżki plików"
} }
} }
+1 -1
View File
@@ -86,7 +86,7 @@ def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.28.1.zip') parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.28.2.zip')
output = parser.parse_args().output.resolve() output = parser.parse_args().output.resolve()
paths = files() paths = files()
checksum = ROOT / 'SHA256SUMS' checksum = ROOT / 'SHA256SUMS'
@@ -0,0 +1,11 @@
-- Least-privilege grants for certificate automation credentials. Existing
-- credentials are migrated to reload-only access for all manual certificate ids;
-- rotating them in 0.28.2 lets operators narrow them to explicit ids.
CREATE TABLE IF NOT EXISTS certificate_api_grants (
key_id TEXT PRIMARY KEY REFERENCES certificate_api_credentials(key_id) ON DELETE CASCADE,
allow_deploy INTEGER NOT NULL DEFAULT 0 CHECK (allow_deploy IN (0, 1)),
certificate_ids TEXT NOT NULL DEFAULT '["*"]' CHECK (length(certificate_ids) <= 4096)
);
INSERT OR IGNORE INTO certificate_api_grants(key_id, allow_deploy, certificate_ids)
SELECT key_id, 0, '["*"]' FROM certificate_api_credentials;
+1 -1
View File
@@ -85,7 +85,7 @@ def download(destination: Path) -> None:
"""TLS verification uses Python's default trust store; never executes a shell.""" """TLS verification uses Python's default trust store; never executes a shell."""
started = time.monotonic() started = time.monotonic()
opener = build_opener(HttpsRedirects()) opener = build_opener(HttpsRedirects())
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.28.1"}) request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.28.2"})
with opener.open(request, timeout=30) as response, destination.open("wb") as output: with opener.open(request, timeout=30) as response, destination.open("wb") as output:
length = response.headers.get("Content-Length") length = response.headers.get("Content-Length")
if length and int(length) > MAX_ARCHIVE: if length and int(length) > MAX_ARCHIVE:
+2 -1
View File
@@ -95,7 +95,7 @@ def digest(path: Path) -> str:
def fetch_https(url: str, max_bytes: int) -> bytes: def fetch_https(url: str, max_bytes: int) -> bytes:
if urllib.parse.urlparse(url).scheme != 'https': if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('GeoIP downloads require HTTPS.') raise ValueError('GeoIP downloads require HTTPS.')
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.28.1'}) request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.28.2'})
with urllib.request.urlopen(request, timeout=30) as response: with urllib.request.urlopen(request, timeout=30) as response:
if urllib.parse.urlparse(response.geturl()).scheme != 'https': if urllib.parse.urlparse(response.geturl()).scheme != 'https':
raise ValueError('GeoIP download redirected away from HTTPS.') raise ValueError('GeoIP download redirected away from HTTPS.')
@@ -380,6 +380,7 @@ ProtectSystem=strict
LogsDirectory=rustproxy LogsDirectory=rustproxy
LogsDirectoryMode=0770 LogsDirectoryMode=0770
ReadWritePaths={data} /var/log/rustproxy ReadWritePaths={data} /var/log/rustproxy
ReadOnlyPaths=-/etc/letsencrypt -/etc/ssl
ProtectHome=true ProtectHome=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true ProtectKernelModules=true
+3 -3
View File
@@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
for name in MANAGED: for name in MANAGED:
if (target / name).is_symlink(): if (target / name).is_symlink():
raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.") raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.")
backup = target / ".upgrade-backup" / ("before-0.28.1-" + uuid.uuid4().hex[:12]) backup = target / ".upgrade-backup" / ("before-0.28.2-" + uuid.uuid4().hex[:12])
backup.mkdir(parents=True, mode=0o700) backup.mkdir(parents=True, mode=0o700)
installed: list[str] = [] installed: list[str] = []
moved: list[str] = [] moved: list[str] = []
@@ -84,7 +84,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
remove_legacy = legacy.exists() remove_legacy = legacy.exists()
if remove_legacy and (legacy.is_symlink() or not legacy.is_file()): if remove_legacy and (legacy.is_symlink() or not legacy.is_file()):
raise ValueError("Obsolete renewal observation sidecar must be a regular 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.28.1", "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":"0.28.2", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
if remove_legacy: if remove_legacy:
legacy.unlink() legacy.unlink()
except BaseException: except BaseException:
@@ -115,7 +115,7 @@ def main() -> None:
backup = upgrade(Path(__file__).resolve().parents[2], args.target, stopped=args.stopped, data_dir=data_dir) backup = upgrade(Path(__file__).resolve().parents[2], args.target, stopped=args.stopped, data_dir=data_dir)
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
parser.exit(2, f"Upgrade refused or rolled back: {error}\n") parser.exit(2, f"Upgrade refused or rolled back: {error}\n")
print(f"Installed RustProxy 0.28.1. Previous managed source files: {backup}") print(f"Installed RustProxy 0.28.2. Previous managed source files: {backup}")
print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.") 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") print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh")
+5
View File
@@ -1,3 +1,8 @@
#[derive(Clone, Debug)]
struct CertificateApiAccess {
grant: crate::db::CertificateApiGrant,
}
fn valid_certificate_api_token(value: &str) -> bool { fn valid_certificate_api_token(value: &str) -> bool {
(32..=128).contains(&value.len()) (32..=128).contains(&value.len())
&& value && value
+9
View File
@@ -1,6 +1,7 @@
async fn certificate_deploy( async fn certificate_deploy(
State(state): State<Arc<App>>, State(state): State<Arc<App>>,
Extension(session): Extension<Session>, Extension(session): Extension<Session>,
certificate_api_access: Option<Extension<CertificateApiAccess>>,
Json(input): Json<CertificateDeploy>, Json(input): Json<CertificateDeploy>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let directory = state.policy.cert_dir.clone().ok_or_else(|| { let directory = state.policy.cert_dir.clone().ok_or_else(|| {
@@ -21,6 +22,14 @@ async fn certificate_deploy(
"invalid certificate id".into(), "invalid certificate id".into(),
)); ));
} }
if let Some(Extension(access)) = certificate_api_access {
if !access.grant.allows_certificate(&certificate_id) {
return Err(ApiError(
StatusCode::FORBIDDEN,
"certificate_api.certificate_forbidden".into(),
));
}
}
let snapshot = state.config.load_full(); let snapshot = state.config.load_full();
let expected_revision = snapshot.envelope.revision; let expected_revision = snapshot.envelope.revision;
+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md" "/docs/V0.25.1_AUDIT.md"
)), )),
), ),
reader_doc!("docs/V0.28.2_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"), reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.0_IMPLEMENTATION.md", "technical"), reader_doc!("docs/V0.28.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.3_IMPLEMENTATION.md", "technical"), reader_doc!("docs/V0.27.3_IMPLEMENTATION.md", "technical"),
+35 -11
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "RustProxy Administration API", "title": "RustProxy Administration API",
"version": "0.28.1", "version": "0.28.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." "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": [ "servers": [
@@ -34,7 +34,7 @@
"type": "http", "type": "http",
"scheme": "bearer", "scheme": "bearer",
"bearerFormat": "opaque", "bearerFormat": "opaque",
"description": "One of the scoped external certificate deployment/reload tokens configured in Settings > Certificate API. Tokens are identified by X-RustProxy-Certificate-Key and are accepted only for certificate deploy/reload operations." "description": "Scoped external certificate automation token configured in Settings > Certificate API. New tokens are reload-only by default and are restricted to explicit certificate IDs; optional deploy permission must be granted explicitly. The token is identified by X-RustProxy-Certificate-Key."
}, },
"CertificateApiKey": { "CertificateApiKey": {
"type": "apiKey", "type": "apiKey",
@@ -4457,8 +4457,25 @@
"maxLength": 256, "maxLength": 256,
"nullable": true, "nullable": true,
"description": "Human-readable non-secret note identifying the automation or owner." "description": "Human-readable non-secret note identifying the automation or owner."
},
"certificate_ids": {
"type": "array",
"minItems": 1,
"maxItems": 32,
"items": {
"type": "string"
},
"description": "Certificate IDs this token may operate on. Wildcards are not accepted for newly created credentials."
},
"allow_deploy": {
"type": "boolean",
"default": false,
"description": "Also authorize certificate deploy for the allowlisted IDs. Leave false for renewal/reload hooks."
} }
} },
"required": [
"certificate_ids"
]
}, },
"CertificateApiRotateResult": { "CertificateApiRotateResult": {
"type": "object", "type": "object",
@@ -4649,13 +4666,24 @@
"last_used_at": { "last_used_at": {
"type": "integer", "type": "integer",
"nullable": true "nullable": true
},
"allow_deploy": {
"type": "boolean"
},
"certificate_ids": {
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"required": [ "required": [
"key_id", "key_id",
"comment", "comment",
"created_at", "created_at",
"last_used_at" "last_used_at",
"allow_deploy",
"certificate_ids"
] ]
}, },
"CertificateReloadOneResult": { "CertificateReloadOneResult": {
@@ -9270,7 +9298,7 @@
"/api/certificates/reload": { "/api/certificates/reload": {
"post": { "post": {
"summary": "Reload configured certificate files from disk", "summary": "Reload configured certificate files from disk",
"description": "Rebuilds and hot-publishes the public TLS routing snapshot from the currently configured certificate/key files without changing the configuration revision. New public TLS/H2/H3 handshakes use the refreshed keys. Administration TLS remains startup-fixed and reports when a restart is required. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback.", "description": "Rebuilds and hot-publishes the public TLS routing snapshot from the currently configured certificate/key files without changing the configuration revision. New public TLS/H2/H3 handshakes use the refreshed keys. Administration TLS remains startup-fixed and reports when a restart is required. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback. Certificate API tokens are intentionally not accepted here; automation must use the targeted per-certificate reload endpoint.",
"tags": [ "tags": [
"Certificates" "Certificates"
], ],
@@ -9331,10 +9359,6 @@
{ {
"AdminSession": [], "AdminSession": [],
"CsrfToken": [] "CsrfToken": []
},
{
"CertificateApiBearer": [],
"CertificateApiKey": []
} }
] ]
} }
@@ -9342,7 +9366,7 @@
"/api/certificates/deploy": { "/api/certificates/deploy": {
"post": { "post": {
"summary": "Reconcile an externally issued pair with a manual certificate id", "summary": "Reconcile an externally issued pair with a manual certificate id",
"description": "Maximum body 300 KiB. Convergently reconciles an externally issued PEM pair: creates a missing manual certificate id, atomically rotates an existing manual id through immutable generation-specific files, or returns a no-op when the selected RustProxy-owned generation already contains the exact pair. All vhosts already assigned to that id use changed material for new public TLS/H2/H3 handshakes. RustProxy-managed ACME ids are rejected. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback.", "description": "Maximum body 300 KiB. Convergently reconciles an externally issued PEM pair for one manual certificate id. Administrator session+CSRF is always accepted. Certificate API authentication is accepted only when that credential explicitly grants deploy permission and allowlists the requested certificate ID. New Certificate API credentials default to reload-only.",
"tags": [ "tags": [
"Certificates" "Certificates"
], ],
@@ -9754,7 +9778,7 @@
"/api/certificates/{certificate_id}/reload": { "/api/certificates/{certificate_id}/reload": {
"post": { "post": {
"summary": "Reload one manual/file-backed certificate from disk", "summary": "Reload one manual/file-backed certificate from disk",
"description": "Targets one configured certificate ID that is not ACME-managed. RustProxy revalidates and atomically republishes its immutable TLS snapshot without changing the configuration revision. The scoped Certificate API token is accepted for this operation, making it suitable for external certificate renewal hooks. Managed ACME certificate IDs are rejected because their renewal pipeline owns those files.", "description": "Targets one configured certificate ID that is not ACME-managed. RustProxy revalidates and atomically republishes its immutable TLS snapshot without changing the configuration revision. The scoped Certificate API token is accepted for this operation, making it suitable for external certificate renewal hooks. Managed ACME certificate IDs are rejected because their renewal pipeline owns those files. Certificate API tokens must be explicitly allowlisted for this certificate ID.",
"tags": [ "tags": [
"Certificates" "Certificates"
], ],
+31 -1
View File
@@ -319,6 +319,10 @@ struct CertificateApiRotate {
token: Option<String>, token: Option<String>,
#[serde(default)] #[serde(default)]
comment: Option<String>, comment: Option<String>,
#[serde(default)]
certificate_ids: Vec<String>,
#[serde(default)]
allow_deploy: bool,
} }
fn valid_certificate_api_comment(value: &str) -> bool { fn valid_certificate_api_comment(value: &str) -> bool {
@@ -377,10 +381,36 @@ async fn certificate_api_rotate(
"certificate_api.invalid_comment".into(), "certificate_api.invalid_comment".into(),
)); ));
} }
let mut certificate_ids: Vec<String> = input
.certificate_ids
.into_iter()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.collect();
certificate_ids.sort();
certificate_ids.dedup();
if certificate_ids.is_empty()
|| certificate_ids.len() > 32
|| certificate_ids
.iter()
.any(|value| value == "*" || !crate::config::valid_id(value))
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_certificate_ids".into(),
));
}
let token_hash = auth::secret_hash(&token); let token_hash = auth::secret_hash(&token);
let credential = state let credential = state
.store .store
.upsert_certificate_api_credential(&key_id, &token_hash, &comment, &session.username) .upsert_certificate_api_credential(
&key_id,
&token_hash,
&comment,
input.allow_deploy,
&certificate_ids,
&session.username,
)
.await .await
.map_err(|error| { .map_err(|error| {
if error.to_string().contains("credential limit reached") { if error.to_string().contains("credential limit reached") {
+21 -11
View File
@@ -470,15 +470,25 @@ fn anonymous_allowed(method: &Method, path: &str) -> bool {
const CERTIFICATE_API_KEY_HEADER: &str = "x-rustproxy-certificate-key"; const CERTIFICATE_API_KEY_HEADER: &str = "x-rustproxy-certificate-key";
fn certificate_api_operation(method: &Method, path: &str) -> bool { #[derive(Clone, Debug)]
if method != Method::POST { enum CertificateApiOperation {
return false; Deploy,
} ReloadAll,
matches!( ReloadOne(String),
path, }
"/api/certificates/deploy" | "/api/certificates/reload"
) || path fn certificate_api_operation(method: &Method, path: &str) -> Option<CertificateApiOperation> {
.strip_prefix("/api/certificates/") if method != Method::POST {
.and_then(|tail| tail.strip_suffix("/reload")) return None;
.is_some_and(crate::config::valid_id) }
if path == "/api/certificates/deploy" {
return Some(CertificateApiOperation::Deploy);
}
if path == "/api/certificates/reload" {
return Some(CertificateApiOperation::ReloadAll);
}
path.strip_prefix("/api/certificates/")
.and_then(|tail| tail.strip_suffix("/reload"))
.filter(|certificate_id| crate::config::valid_id(certificate_id))
.map(|certificate_id| CertificateApiOperation::ReloadOne(certificate_id.to_owned()))
} }
+41 -10
View File
@@ -40,7 +40,9 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
&& request.method() != Method::HEAD && request.method() != Method::HEAD
&& request.method() != Method::OPTIONS; && request.method() != Method::OPTIONS;
let secret = auth::cookie_secret(headers); let secret = auth::cookie_secret(headers);
let certificate_api_session = if certificate_api_operation(request.method(), &path) { let certificate_api_operation = certificate_api_operation(request.method(), &path);
let mut certificate_api_access = None;
let certificate_api_session = if let Some(operation) = certificate_api_operation.as_ref() {
match certificate_api_credentials(headers) { match certificate_api_credentials(headers) {
Ok(None) => None, Ok(None) => None,
Ok(Some((key_id, token))) => { Ok(Some((key_id, token))) => {
@@ -51,26 +53,52 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
)); ));
} }
let token_hash = auth::secret_hash(&token); let token_hash = auth::secret_hash(&token);
match state let grant = match state
.store .store
.verify_certificate_api_credential(&key_id, &token_hash) .verify_certificate_api_credential(&key_id, &token_hash)
.await .await
{ {
Ok(true) => Some(Session { Ok(Some(grant)) => grant,
token_hash, Ok(None) => {
csrf: String::new(),
username: format!("certificate-api:{key_id}"),
expires_at: auth::now().saturating_add(60),
generation: 0,
}),
Ok(false) => {
return secure(reject( return secure(reject(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"certificate_api.invalid_token", "certificate_api.invalid_token",
)) ))
} }
Err(error) => return secure(auth::internal(error).into_response()), Err(error) => return secure(auth::internal(error).into_response()),
};
match operation {
CertificateApiOperation::ReloadAll => {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.target_required",
));
} }
CertificateApiOperation::ReloadOne(certificate_id) => {
if !grant.allows_certificate(certificate_id) {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.certificate_forbidden",
));
}
}
CertificateApiOperation::Deploy => {
if !grant.allow_deploy {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.deploy_forbidden",
));
}
}
}
certificate_api_access = Some(CertificateApiAccess { grant });
Some(Session {
token_hash,
csrf: String::new(),
username: format!("certificate-api:{key_id}"),
expires_at: auth::now().saturating_add(60),
generation: 0,
})
} }
Err(()) => { Err(()) => {
return secure(reject( return secure(reject(
@@ -132,6 +160,9 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
if let Some(session) = session.clone() { if let Some(session) = session.clone() {
request.extensions_mut().insert(session); request.extensions_mut().insert(session);
} }
if let Some(access) = certificate_api_access {
request.extensions_mut().insert(access);
}
let admin_origin = AdminOrigin { let admin_origin = AdminOrigin {
secure: scheme == "https", secure: scheme == "https",
}; };
+69 -12
View File
@@ -185,6 +185,8 @@
"file-renewer", "file-renewer",
&crate::auth::secret_hash(&api_token), &crate::auth::secret_hash(&api_token),
"external file renewal hook", "external file renewal hook",
false,
&["manual-file".to_owned()],
"owner", "owner",
) )
.await .await
@@ -223,7 +225,7 @@
.header("x-rustproxy-request", "1") .header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf) .header("x-csrf-token", &csrf)
.header("content-type", "application/json") .header("content-type", "application/json")
.body(Body::from(json!({"key_id":"x","token":token}).to_string())) .body(Body::from(json!({"key_id":"x","token":token,"certificate_ids":["shared-cert"]}).to_string()))
.unwrap(), .unwrap(),
) )
.await .await
@@ -245,7 +247,13 @@
.header("x-csrf-token", &csrf) .header("x-csrf-token", &csrf)
.header("content-type", "application/json") .header("content-type", "application/json")
.body(Body::from( .body(Body::from(
json!({"key_id":key_id,"token":value,"comment":comment}).to_string(), json!({
"key_id":key_id,
"token":value,
"comment":comment,
"certificate_ids":[if key_id == "certbot-main" { "shared-cert" } else { "deploy-cert" }],
"allow_deploy": key_id == "deploy-hook"
}).to_string(),
)) ))
.unwrap(), .unwrap(),
) )
@@ -256,6 +264,8 @@
assert_eq!(rotated["configured"], true); assert_eq!(rotated["configured"], true);
assert_eq!(rotated["credential"]["key_id"], key_id); assert_eq!(rotated["credential"]["key_id"], key_id);
assert_eq!(rotated["credential"]["comment"], comment); assert_eq!(rotated["credential"]["comment"], comment);
assert_eq!(rotated["credential"]["allow_deploy"], key_id == "deploy-hook");
assert_eq!(rotated["credential"]["certificate_ids"].as_array().unwrap().len(), 1);
assert_eq!(rotated["token"], value); assert_eq!(rotated["token"], value);
} }
@@ -274,8 +284,8 @@
assert_eq!(status["credentials"].as_array().unwrap().len(), 2); assert_eq!(status["credentials"].as_array().unwrap().len(), 2);
assert!(status.get("token").is_none()); assert!(status.get("token").is_none());
let external = |key_id: &'static str, value: String| { let external = |path: &'static str, key_id: &'static str, value: String| {
request("/api/certificates/reload") request(path)
.method("POST") .method("POST")
.header("authorization", format!("Bearer {value}")) .header("authorization", format!("Bearer {value}"))
.header("x-rustproxy-certificate-key", key_id) .header("x-rustproxy-certificate-key", key_id)
@@ -283,18 +293,65 @@
.body(Body::from("{}")) .body(Body::from("{}"))
.unwrap() .unwrap()
}; };
// Certificate API credentials cannot trigger an all-certificate reload.
let response = router let response = router
.clone() .clone()
.oneshot(external("certbot-main", token.clone())) .oneshot(external("/api/certificates/reload", "certbot-main", token.clone()))
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::FORBIDDEN);
// The allowlisted ID passes the auth gate and reaches the handler; this
// fixture intentionally has no such configured certificate, so it is 404.
let response = router let response = router
.clone() .clone()
.oneshot(external("deploy-hook", token_two.clone())) .oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::NOT_FOUND);
// A reload-only token cannot use the deploy endpoint at all.
let response = router
.clone()
.oneshot(
request("/api/certificates/deploy")
.method("POST")
.header("authorization", format!("Bearer {token}"))
.header("x-rustproxy-certificate-key", "certbot-main")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// A different ID is rejected by the credential grant before the handler.
let response = router
.clone()
.oneshot(external("/api/certificates/deploy-cert/reload", "certbot-main", token.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = router
.clone()
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = router
.clone()
.oneshot(
request("/api/certificates/deploy")
.method("POST")
.header("authorization", format!("Bearer {token_two}"))
.header("x-rustproxy-certificate-key", "deploy-hook")
.header("content-type", "application/json")
.body(Body::from(
json!({"certificate_id":"shared-cert","cert_pem":"bad","key_pem":"bad"}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let missing_key = router let missing_key = router
.clone() .clone()
@@ -340,16 +397,16 @@
assert_eq!(revoked.status(), StatusCode::OK); assert_eq!(revoked.status(), StatusCode::OK);
let response = router let response = router
.clone() .clone()
.oneshot(external("certbot-main", token.clone())) .oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = router let response = router
.clone() .clone()
.oneshot(external("deploy-hook", token_two.clone())) .oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::NOT_FOUND);
let revoked_all = router let revoked_all = router
.clone() .clone()
@@ -367,7 +424,7 @@
.unwrap(); .unwrap();
assert_eq!(revoked_all.status(), StatusCode::OK); assert_eq!(revoked_all.status(), StatusCode::OK);
let response = router let response = router
.oneshot(external("deploy-hook", token_two)) .oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two))
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+1
View File
@@ -36,6 +36,7 @@ function syncCertificateFileSource() {
const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp"; const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp";
$("certificate-file-source-help").dataset.i18n = helpKey; $("certificate-file-source-help").dataset.i18n = helpKey;
$("certificate-file-source-help").textContent = t(helpKey); $("certificate-file-source-help").textContent = t(helpKey);
$("certificate-external-acl-example").hidden = !external;
} }
$("certificate-file-source").addEventListener("change", syncCertificateFileSource); $("certificate-file-source").addEventListener("change", syncCertificateFileSource);
+13 -6
View File
@@ -1186,6 +1186,7 @@
<option data-i18n="certificateSourceExternal" value="external"></option> <option data-i18n="certificateSourceExternal" value="external"></option>
</select> </select>
<p class="fine" id="certificate-file-source-help" data-i18n="certificateSourceManagedHelp"></p> <p class="fine" id="certificate-file-source-help" data-i18n="certificateSourceManagedHelp"></p>
<pre class="metrics-example" data-i18n="certificateExternalAclExample" hidden id="certificate-external-acl-example"></pre>
<label data-i18n="certificateFile" for="certificate-file"></label><input <label data-i18n="certificateFile" for="certificate-file"></label><input
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+" id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
placeholder="app.pem" required="" /> placeholder="app.pem" required="" />
@@ -2728,14 +2729,20 @@
class="pill live">SCOPED TOKENS</span> class="pill live">SCOPED TOKENS</span>
</div> </div>
<p class="fine" data-i18n="certificateApiHelp">Separate Key ID + Bearer Token <p class="fine" data-i18n="certificateApiHelp">Separate Key ID + Bearer Token
credentials can only deploy or reload externally managed certificates. They cannot credentials are reload-only by default and restricted to explicit certificate IDs.
read configuration or use other administration APIs.</p> Deploy is optional; tokens cannot read configuration or use other administration APIs.</p>
<p class="fine" id="certificate-api-status" role="status"></p> <p class="fine" id="certificate-api-status" role="status"></p>
<label><span data-i18n="certificateApiKeyId">Key ID</span><input <label><span data-i18n="certificateApiKeyId">Key ID</span><input
id="certificate-api-key-id" maxlength="64" placeholder="certbot-main" /></label> id="certificate-api-key-id" maxlength="64" placeholder="certbot-main" /></label>
<label><span data-i18n="certificateApiComment">Comment</span><input <label><span data-i18n="certificateApiComment">Comment</span><input
id="certificate-api-comment" maxlength="256" id="certificate-api-comment" maxlength="256"
placeholder="Certbot on edge-01" /></label> placeholder="Certbot on edge-01" /></label>
<label><span data-i18n="certificateApiCertificateIds">Allowed certificate IDs</span><textarea
id="certificate-api-certificate-ids" maxlength="2048" placeholder="shared-cert" required rows="3"></textarea></label>
<p class="fine" data-i18n="certificateApiCertificateIdsHelp">List one or more certificate IDs separated by commas or new lines. The token can act only on these IDs.</p>
<label class="check"><input id="certificate-api-allow-deploy" type="checkbox" /><span
data-i18n="certificateApiAllowDeploy">Also allow deploy</span></label>
<p class="fine" data-i18n="certificateApiAllowDeployHelp">Leave disabled for Certbot renewal hooks. Reload-only is the recommended least-privilege mode.</p>
<label><span data-i18n="certificateApiToken">Bearer token (optional custom <label><span data-i18n="certificateApiToken">Bearer token (optional custom
value)</span><input autocomplete="off" id="certificate-api-token" value)</span><input autocomplete="off" id="certificate-api-token"
maxlength="128" minlength="32" maxlength="128" minlength="32"
@@ -2752,8 +2759,8 @@
</div> </div>
<div class="certificate-api-credentials" id="certificate-api-credentials"></div> <div class="certificate-api-credentials" id="certificate-api-credentials"></div>
<p class="fine" data-i18n="certificateApiCurlHelp">External automation sends the Key ID <p class="fine" data-i18n="certificateApiCurlHelp">External automation sends the Key ID
header and Bearer token to the certificate deploy/reload endpoints over HTTPS (or header and Bearer token over HTTPS (or loopback HTTP). Reload tokens use the targeted
loopback HTTP).</p> /api/certificates/{id}/reload endpoint; token access cannot trigger global reload.</p>
<pre class="metrics-example" id="certificate-api-curl"></pre> <pre class="metrics-example" id="certificate-api-curl"></pre>
</form> </form>
</div> </div>
@@ -2884,7 +2891,7 @@
</section> </section>
</div> </div>
</section> </section>
<footer class="page-footer"><span>RustProxy <b>0.28.1</b></span><span id="footer-status">API - / <footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span id="footer-status">API - /
dropped: 0</span></footer> dropped: 0</span></footer>
</div> </div>
</main> </main>
@@ -3628,7 +3635,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card"> <article class="card about-card">
<div class="about-product"> <div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span> <span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.28.1</strong></div> <div><span class="small-label">RustProxy</span><strong>0.28.2</strong></div>
</div> </div>
<dl class="about-meta"> <dl class="about-meta">
<div> <div>
+26 -11
View File
@@ -88,18 +88,25 @@
} }
function certificateApiExample() { function certificateApiExample() {
const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main"; const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main";
$("certificate-api-curl").textContent = `curl --fail --show-error --request POST \ const enteredIds = $("certificate-api-certificate-ids").value.split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
const selected = enteredIds[0] || certificateApiCredentials()[0]?.certificate_ids?.find(value => value !== "*") || "CERTIFICATE_ID";
const reload = `curl --fail --show-error --request POST \
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
--header 'X-RustProxy-Certificate-Key: ${key}' \
${location.origin}/api/certificates/${selected}/reload`;
if (!$("certificate-api-allow-deploy").checked) {
$("certificate-api-curl").textContent = reload;
return;
}
$("certificate-api-curl").textContent = `${reload}
# Optional higher-privilege deploy for the same allowlisted certificate IDs:
curl --fail --show-error --request POST \
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \ --header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
--header 'X-RustProxy-Certificate-Key: ${key}' \ --header 'X-RustProxy-Certificate-Key: ${key}' \
--header 'Content-Type: application/json' \ --header 'Content-Type: application/json' \
--data @deploy.json \ --data @deploy.json \
${location.origin}/api/certificates/deploy ${location.origin}/api/certificates/deploy`;
# Reload one manual/file-backed certificate without changing its configuration:
curl --fail --show-error --request POST \
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
--header 'X-RustProxy-Certificate-Key: ${key}' \
${location.origin}/api/certificates/CERTIFICATE_ID/reload`;
} }
function renderCertificateApi() { function renderCertificateApi() {
const state = certificateApiState, target = $("certificate-api-credentials"); const state = certificateApiState, target = $("certificate-api-credentials");
@@ -116,6 +123,9 @@ curl --fail --show-error --request POST \
const info = el("div", undefined, "certificate-api-credential-info"); const info = el("div", undefined, "certificate-api-credential-info");
info.append(el("b", credential.key_id || "-", "mono")); info.append(el("b", credential.key_id || "-", "mono"));
if (credential.comment) info.append(el("span", credential.comment, "fine")); if (credential.comment) info.append(el("span", credential.comment, "fine"));
const scope = credential.allow_deploy ? t("certificateApiScopeDeployReload") : t("certificateApiScopeReloadOnly");
const ids = Array.isArray(credential.certificate_ids) ? credential.certificate_ids.join(", ") : "-";
info.append(el("span", `${scope} · ${t("certificateApiAllowedIds")}: ${ids}`, "fine mono"));
const lastUsed = credential.last_used_at ? formatDateTime(Number(credential.last_used_at) * 1000) : t("certificateApiNeverUsed"); const lastUsed = credential.last_used_at ? formatDateTime(Number(credential.last_used_at) * 1000) : t("certificateApiNeverUsed");
info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine")); info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine"));
const remove = action(t("certificateApiRevoke"), async () => { const remove = action(t("certificateApiRevoke"), async () => {
@@ -267,9 +277,12 @@ curl --fail --show-error --request POST \
event.preventDefault(); const button = event.submitter || $("certificate-api-form").querySelector("button[type=submit]"); button.disabled = true; event.preventDefault(); const button = event.submitter || $("certificate-api-form").querySelector("button[type=submit]"); button.disabled = true;
try { try {
const keyId = $("certificate-api-key-id").value.trim(), token = $("certificate-api-token").value.trim(), comment = $("certificate-api-comment").value.trim(); const keyId = $("certificate-api-key-id").value.trim(), token = $("certificate-api-token").value.trim(), comment = $("certificate-api-comment").value.trim();
const payload = await api("/api/certificate-api", { method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment }) }); const certificateIds = $("certificate-api-certificate-ids").value.split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
if (!certificateIds.length) throw fieldError($("certificate-api-certificate-ids"), t("certificateApiCertificateIdsRequired"));
const allowDeploy = $("certificate-api-allow-deploy").checked;
const payload = await api("/api/certificate-api", { method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment, certificate_ids: certificateIds, allow_deploy: allowDeploy }) });
const clearToken = payload.token || ""; certificateApiState = { ...payload }; delete certificateApiState.token; delete certificateApiState.credential; const clearToken = payload.token || ""; certificateApiState = { ...payload }; delete certificateApiState.token; delete certificateApiState.credential;
$("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = clearToken; $("certificate-api-secret-wrap").hidden = !clearToken; renderCertificateApi(); toast(t("certificateApiRotated")); $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = clearToken; $("certificate-api-secret-wrap").hidden = !clearToken; renderCertificateApi(); toast(t("certificateApiRotated"));
} catch (error) { toast(error.message, true); } finally { button.disabled = false; } } catch (error) { toast(error.message, true); } finally { button.disabled = false; }
}); });
$("certificate-api-revoke-all").addEventListener("click", async () => { $("certificate-api-revoke-all").addEventListener("click", async () => {
@@ -279,6 +292,8 @@ curl --fail --show-error --request POST \
catch (error) { toast(error.message, true); } finally { button.disabled = false; } catch (error) { toast(error.message, true); } finally { button.disabled = false; }
}); });
$("certificate-api-key-id").addEventListener("input", certificateApiExample); $("certificate-api-key-id").addEventListener("input", certificateApiExample);
$("certificate-api-certificate-ids").addEventListener("input", certificateApiExample);
$("certificate-api-allow-deploy").addEventListener("change", certificateApiExample);
$("admin-tls").addEventListener("change", () => { $("admin-certificate").disabled = !$("admin-tls").checked; }); $("admin-tls").addEventListener("change", () => { $("admin-certificate").disabled = !$("admin-tls").checked; });
$("prometheus-form").addEventListener("input", example); $("prometheus-form").addEventListener("input", example);
$("prom-preview").addEventListener("click", async () => { $("prom-preview").addEventListener("click", async () => {
@@ -288,6 +303,6 @@ curl --fail --show-error --request POST \
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false; const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; } } catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
}); });
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } }; window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render(); select(active); render();
})(); })();
+28 -147
View File
@@ -1,9 +1,10 @@
//! SQLite stores desired configuration, administrator state and bounded audit history. //! SQLite stores desired configuration, administrator state and bounded audit history.
//! No database call is made from the public HTTP forwarding path. //! No database call is made from the public HTTP forwarding path.
use crate::config::{Config, Envelope}; use crate::config::{Config, Envelope};
mod certificate_api;
mod history; mod history;
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
use anyhow::{ensure, Context, Result}; use anyhow::{ensure, Context, Result};
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
use serde::Serialize; use serde::Serialize;
use sqlx::{ use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
@@ -11,7 +12,7 @@ use sqlx::{
}; };
use std::{path::Path, time::Duration}; use std::{path::Path, time::Duration};
pub const SCHEMA_VERSION: i64 = 8; pub const SCHEMA_VERSION: i64 = 9;
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32; pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
pub const AUDIT_CAPACITY: i64 = 10_000; pub const AUDIT_CAPACITY: i64 = 10_000;
@@ -25,6 +26,22 @@ pub struct CertificateApiCredential {
pub comment: String, pub comment: String,
pub created_at: i64, pub created_at: i64,
pub last_used_at: Option<i64>, pub last_used_at: Option<i64>,
pub allow_deploy: bool,
pub certificate_ids: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct CertificateApiGrant {
pub allow_deploy: bool,
pub certificate_ids: Vec<String>,
}
impl CertificateApiGrant {
pub fn allows_certificate(&self, certificate_id: &str) -> bool {
self.certificate_ids
.iter()
.any(|value| value == "*" || value == certificate_id)
}
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -90,7 +107,9 @@ impl Store {
sqlx::raw_sql(include_str!("../migrations/0005_certificate_api.sql")) sqlx::raw_sql(include_str!("../migrations/0005_certificate_api.sql"))
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::raw_sql(include_str!("../migrations/0006_certificate_api_credentials.sql")) sqlx::raw_sql(include_str!(
"../migrations/0006_certificate_api_credentials.sql"
))
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::raw_sql(include_str!("../migrations/0007_secret_store.sql")) sqlx::raw_sql(include_str!("../migrations/0007_secret_store.sql"))
@@ -99,6 +118,11 @@ impl Store {
sqlx::raw_sql(include_str!("../migrations/0008_config_history.sql")) sqlx::raw_sql(include_str!("../migrations/0008_config_history.sql"))
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::raw_sql(include_str!(
"../migrations/0009_certificate_api_grants.sql"
))
.execute(&mut *tx)
.await?;
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)") sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
.bind(serde_json::to_string(initial)?) .bind(serde_json::to_string(initial)?)
.execute(&mut *tx) .execute(&mut *tx)
@@ -109,7 +133,7 @@ impl Store {
.bind(crate::auth::now()) .bind(crate::auth::now())
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::query("PRAGMA user_version = 8") sqlx::query("PRAGMA user_version = 9")
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?; tx.commit().await?;
@@ -313,149 +337,6 @@ impl Store {
Ok(()) Ok(())
} }
pub async fn certificate_api_credentials(&self) -> Result<Vec<CertificateApiCredential>> {
let rows = sqlx::query(
"SELECT key_id, comment, created_at, last_used_at FROM certificate_api_credentials ORDER BY key_id",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(CertificateApiCredential {
key_id: row.try_get("key_id")?,
comment: row.try_get("comment")?,
created_at: row.try_get("created_at")?,
last_used_at: row.try_get("last_used_at")?,
})
})
.collect()
}
pub async fn upsert_certificate_api_credential(
&self,
key_id: &str,
token_hash: &str,
comment: &str,
actor: &str,
) -> Result<CertificateApiCredential> {
ensure!(
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
"invalid certificate API key id"
);
ensure!(
token_hash.len() == 64 && token_hash.bytes().all(|b| b.is_ascii_hexdigit()),
"invalid certificate API token hash"
);
ensure!(
comment.len() <= 256 && !comment.chars().any(char::is_control),
"invalid certificate API comment"
);
let now = crate::auth::now();
let mut tx = self.pool.begin().await?;
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM certificate_api_credentials WHERE key_id = ?",
)
.bind(key_id)
.fetch_one(&mut *tx)
.await?;
if exists == 0 {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials")
.fetch_one(&mut *tx)
.await?;
ensure!(
count < CERTIFICATE_API_CREDENTIAL_CAPACITY,
"certificate API credential limit reached"
);
}
sqlx::query(
"INSERT INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) VALUES(?, ?, ?, ?, NULL) \
ON CONFLICT(key_id) DO UPDATE SET token_hash=excluded.token_hash, comment=excluded.comment, created_at=excluded.created_at, last_used_at=NULL",
)
.bind(key_id)
.bind(token_hash)
.bind(comment)
.bind(now)
.execute(&mut *tx)
.await?;
audit_tx(
&mut tx,
actor,
"certificate_api.rotate",
&format!("key_id={key_id}"),
)
.await?;
tx.commit().await?;
Ok(CertificateApiCredential {
key_id: key_id.to_owned(),
comment: comment.to_owned(),
created_at: now,
last_used_at: None,
})
}
pub async fn revoke_certificate_api_credential(&self, key_id: &str, actor: &str) -> Result<bool> {
ensure!(
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
"invalid certificate API key id"
);
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM certificate_api_credentials WHERE key_id = ?")
.bind(key_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() > 0 {
audit_tx(
&mut tx,
actor,
"certificate_api.revoke",
&format!("key_id={key_id}"),
)
.await?;
}
tx.commit().await?;
Ok(result.rows_affected() > 0)
}
pub async fn revoke_all_certificate_api_credentials(&self, actor: &str) -> Result<u64> {
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM certificate_api_credentials")
.execute(&mut *tx)
.await?;
if result.rows_affected() > 0 {
audit_tx(
&mut tx,
actor,
"certificate_api.revoke_all",
&format!("count={}", result.rows_affected()),
)
.await?;
}
tx.commit().await?;
Ok(result.rows_affected())
}
pub async fn verify_certificate_api_credential(&self, key_id: &str, token_hash: &str) -> Result<bool> {
let row = sqlx::query(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?",
)
.bind(key_id)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else { return Ok(false); };
let expected: String = row.try_get("token_hash")?;
if !crate::auth::equal_secret(token_hash, &expected) {
return Ok(false);
}
sqlx::query(
"UPDATE certificate_api_credentials SET last_used_at = ? WHERE key_id = ?",
)
.bind(crate::auth::now())
.bind(key_id)
.execute(&self.pool)
.await?;
Ok(true)
}
pub async fn recent_audit(&self) -> Result<Vec<AuditEntry>> { pub async fn recent_audit(&self) -> Result<Vec<AuditEntry>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, time, actor, action, detail FROM admin_audit ORDER BY id DESC LIMIT 100", "SELECT id, time, actor, action, detail FROM admin_audit ORDER BY id DESC LIMIT 100",
+217
View File
@@ -0,0 +1,217 @@
use super::{
audit_tx, CertificateApiCredential, CertificateApiGrant, Store,
CERTIFICATE_API_CREDENTIAL_CAPACITY,
};
use anyhow::{ensure, Context, Result};
use sqlx::Row;
impl Store {
pub async fn certificate_api_credentials(&self) -> Result<Vec<CertificateApiCredential>> {
let rows = sqlx::query(
"SELECT c.key_id, c.comment, c.created_at, c.last_used_at, \
COALESCE(g.allow_deploy, 0) AS allow_deploy, \
COALESCE(g.certificate_ids, '[\"*\"]') AS certificate_ids \
FROM certificate_api_credentials c \
LEFT JOIN certificate_api_grants g ON g.key_id = c.key_id \
ORDER BY c.key_id",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
let raw_ids: String = row.try_get("certificate_ids")?;
let certificate_ids: Vec<String> =
serde_json::from_str(&raw_ids).context("invalid certificate API grant ids")?;
Ok(CertificateApiCredential {
key_id: row.try_get("key_id")?,
comment: row.try_get("comment")?,
created_at: row.try_get("created_at")?,
last_used_at: row.try_get("last_used_at")?,
allow_deploy: row.try_get::<i64, _>("allow_deploy")? != 0,
certificate_ids,
})
})
.collect()
}
pub async fn upsert_certificate_api_credential(
&self,
key_id: &str,
token_hash: &str,
comment: &str,
allow_deploy: bool,
certificate_ids: &[String],
actor: &str,
) -> Result<CertificateApiCredential> {
ensure!(
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
"invalid certificate API key id"
);
ensure!(
token_hash.len() == 64 && token_hash.bytes().all(|b| b.is_ascii_hexdigit()),
"invalid certificate API token hash"
);
ensure!(
comment.len() <= 256 && !comment.chars().any(char::is_control),
"invalid certificate API comment"
);
ensure!(
!certificate_ids.is_empty() && certificate_ids.len() <= 32,
"certificate API token must allow between 1 and 32 certificate ids"
);
let mut normalized_ids = certificate_ids.to_vec();
normalized_ids.sort();
normalized_ids.dedup();
ensure!(
normalized_ids
.iter()
.all(|value| value != "*" && crate::config::valid_id(value)),
"invalid certificate API certificate id"
);
let certificate_ids_json = serde_json::to_string(&normalized_ids)?;
ensure!(
certificate_ids_json.len() <= 4096,
"certificate API certificate id list is too large"
);
let now = crate::auth::now();
let mut tx = self.pool.begin().await?;
let exists: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials WHERE key_id = ?")
.bind(key_id)
.fetch_one(&mut *tx)
.await?;
if exists == 0 {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials")
.fetch_one(&mut *tx)
.await?;
ensure!(
count < CERTIFICATE_API_CREDENTIAL_CAPACITY,
"certificate API credential limit reached"
);
}
sqlx::query(
"INSERT INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) VALUES(?, ?, ?, ?, NULL) \
ON CONFLICT(key_id) DO UPDATE SET token_hash=excluded.token_hash, comment=excluded.comment, created_at=excluded.created_at, last_used_at=NULL",
)
.bind(key_id)
.bind(token_hash)
.bind(comment)
.bind(now)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO certificate_api_grants(key_id, allow_deploy, certificate_ids) VALUES(?, ?, ?) \
ON CONFLICT(key_id) DO UPDATE SET allow_deploy=excluded.allow_deploy, certificate_ids=excluded.certificate_ids",
)
.bind(key_id)
.bind(if allow_deploy { 1_i64 } else { 0_i64 })
.bind(&certificate_ids_json)
.execute(&mut *tx)
.await?;
audit_tx(
&mut tx,
actor,
"certificate_api.rotate",
&format!(
"key_id={key_id}; scope={}; certificate_ids={}",
if allow_deploy {
"deploy+reload"
} else {
"reload"
},
normalized_ids.join(",")
),
)
.await?;
tx.commit().await?;
Ok(CertificateApiCredential {
key_id: key_id.to_owned(),
comment: comment.to_owned(),
created_at: now,
last_used_at: None,
allow_deploy,
certificate_ids: normalized_ids,
})
}
pub async fn revoke_certificate_api_credential(
&self,
key_id: &str,
actor: &str,
) -> Result<bool> {
ensure!(
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
"invalid certificate API key id"
);
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM certificate_api_credentials WHERE key_id = ?")
.bind(key_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() > 0 {
audit_tx(
&mut tx,
actor,
"certificate_api.revoke",
&format!("key_id={key_id}"),
)
.await?;
}
tx.commit().await?;
Ok(result.rows_affected() > 0)
}
pub async fn revoke_all_certificate_api_credentials(&self, actor: &str) -> Result<u64> {
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM certificate_api_credentials")
.execute(&mut *tx)
.await?;
if result.rows_affected() > 0 {
audit_tx(
&mut tx,
actor,
"certificate_api.revoke_all",
&format!("count={}", result.rows_affected()),
)
.await?;
}
tx.commit().await?;
Ok(result.rows_affected())
}
pub async fn verify_certificate_api_credential(
&self,
key_id: &str,
token_hash: &str,
) -> Result<Option<CertificateApiGrant>> {
let row = sqlx::query(
"SELECT c.token_hash, COALESCE(g.allow_deploy, 0) AS allow_deploy, \
COALESCE(g.certificate_ids, '[\"*\"]') AS certificate_ids \
FROM certificate_api_credentials c \
LEFT JOIN certificate_api_grants g ON g.key_id = c.key_id \
WHERE c.key_id = ?",
)
.bind(key_id)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else {
return Ok(None);
};
let expected: String = row.try_get("token_hash")?;
if !crate::auth::equal_secret(token_hash, &expected) {
return Ok(None);
}
let raw_ids: String = row.try_get("certificate_ids")?;
let certificate_ids: Vec<String> =
serde_json::from_str(&raw_ids).context("invalid certificate API grant ids")?;
sqlx::query("UPDATE certificate_api_credentials SET last_used_at = ? WHERE key_id = ?")
.bind(crate::auth::now())
.bind(key_id)
.execute(&self.pool)
.await?;
Ok(Some(CertificateApiGrant {
allow_deploy: row.try_get::<i64, _>("allow_deploy")? != 0,
certificate_ids,
}))
}
}
+21 -5
View File
@@ -105,12 +105,21 @@ mod tests {
let token = "b".repeat(64); let token = "b".repeat(64);
let hash = crate::auth::secret_hash(&token); let hash = crate::auth::secret_hash(&token);
let saved = store let saved = store
.upsert_certificate_api_credential("certbot-main", &hash, "production certbot", "owner") .upsert_certificate_api_credential(
"certbot-main",
&hash,
"production certbot",
false,
&["shared-cert".to_owned()],
"owner",
)
.await .await
.unwrap(); .unwrap();
assert_eq!(saved.key_id, "certbot-main"); assert_eq!(saved.key_id, "certbot-main");
assert_eq!(saved.comment, "production certbot"); assert_eq!(saved.comment, "production certbot");
assert!(saved.last_used_at.is_none()); assert!(saved.last_used_at.is_none());
assert!(!saved.allow_deploy);
assert_eq!(saved.certificate_ids, vec!["shared-cert"]);
let raw: String = sqlx::query_scalar( let raw: String = sqlx::query_scalar(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?", "SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?",
@@ -128,23 +137,30 @@ mod tests {
"deploy-hook", "deploy-hook",
&second_hash, &second_hash,
"secondary deploy hook", "secondary deploy hook",
true,
&["deploy-cert".to_owned()],
"owner", "owner",
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(store.certificate_api_credentials().await.unwrap().len(), 2); assert_eq!(store.certificate_api_credentials().await.unwrap().len(), 2);
assert!(store let grant = store
.verify_certificate_api_credential("certbot-main", &hash) .verify_certificate_api_credential("certbot-main", &hash)
.await .await
.unwrap()); .unwrap()
assert!(!store .unwrap();
assert!(!grant.allow_deploy);
assert!(grant.allows_certificate("shared-cert"));
assert!(!grant.allows_certificate("other-cert"));
assert!(store
.verify_certificate_api_credential( .verify_certificate_api_credential(
"certbot-main", "certbot-main",
&crate::auth::secret_hash(&"c".repeat(64)), &crate::auth::secret_hash(&"c".repeat(64)),
) )
.await .await
.unwrap()); .unwrap()
.is_none());
let credentials = store.certificate_api_credentials().await.unwrap(); let credentials = store.certificate_api_credentials().await.unwrap();
assert!(credentials assert!(credentials
.iter() .iter()
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command( #[command(
name = "rust-proxy", name = "rust-proxy",
version, version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.1" about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.2"
)] )]
struct Cli { struct Cli {
#[arg( #[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
} }
}); });
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal(); let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.28.1 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits."); println!("RustProxy live logs 0.28.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; } if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default(); let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new(); let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+1
View File
@@ -4,6 +4,7 @@
//! written to audit/API response payloads. //! written to audit/API response payloads.
include!("tls/types.rs"); include!("tls/types.rs");
include!("tls/external_paths.rs");
include!("tls/server_config.rs"); include!("tls/server_config.rs");
include!("tls/certificates.rs"); include!("tls/certificates.rs");
+174
View File
@@ -0,0 +1,174 @@
pub fn certificate_files_are_external(cert_file: &str, key_file: &str) -> Result<bool> {
let cert_external = Path::new(cert_file).is_absolute();
let key_external = Path::new(key_file).is_absolute();
ensure!(
cert_external == key_external,
"certificate and private key must both use filenames from the RustProxy certificate directory or both use absolute external paths"
);
if cert_external {
ensure!(
!cert_file.is_empty()
&& !key_file.is_empty()
&& cert_file.len() <= MAX_CERTIFICATE_PATH
&& key_file.len() <= MAX_CERTIFICATE_PATH,
"external certificate paths must be absolute paths up to 4096 bytes"
);
Ok(true)
} else {
safe_name(cert_file)?;
safe_name(key_file)?;
Ok(false)
}
}
#[cfg(unix)]
fn validate_external_parent_chain(path: &Path) -> Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let parent = path
.parent()
.context("external certificate path has no parent directory")?;
for directory in parent.ancestors() {
if directory.as_os_str().is_empty() {
continue;
}
let meta = fs::symlink_metadata(directory).with_context(|| {
format!(
"cannot inspect external certificate parent directory {}",
directory.display()
)
})?;
ensure!(
meta.is_dir(),
"external certificate parent path must contain real directories, not symlinks or special files: {}",
directory.display()
);
let mode = meta.permissions().mode();
let owner = meta.uid();
let service_uid = unsafe { libc::geteuid() };
ensure!(
owner == 0 || owner == service_uid,
"external certificate parent directory must be owned by root or the RustProxy service user: {}",
directory.display()
);
if mode & 0o022 != 0 {
// Root-owned sticky directories such as /tmp and /var/tmp cannot have
// entries renamed/unlinked by arbitrary users, so they are safe as an
// ancestor of a private service-owned test/custom directory.
ensure!(
owner == 0 && mode & 0o1000 != 0,
"external certificate parent directory must not be group/world-writable unless it is a root-owned sticky directory: {}",
directory.display()
);
}
}
Ok(())
}
#[cfg(not(unix))]
fn validate_external_parent_chain(_path: &Path) -> Result<()> {
Ok(())
}
fn configured_certificate_path(directory: &Path, value: &str) -> Result<PathBuf> {
if Path::new(value).is_absolute() {
ensure!(
!value.is_empty() && value.len() <= MAX_CERTIFICATE_PATH,
"external certificate path must be an absolute path up to 4096 bytes"
);
let configured = Path::new(value);
// The configured path itself may be Certbot's final live/*.pem symlink, but
// every parent directory must be a stable, non-writable directory. This
// prevents a writable parent from redirecting canonicalize() to attacker-
// controlled material between validation and open.
validate_external_parent_chain(configured)?;
let resolved = fs::canonicalize(configured)
.with_context(|| format!("cannot resolve external certificate path {value}"))?;
// Validate the resolved archive/custom path as well. Certbot's final file
// symlink is intentionally followed on each reload; intermediate symlinks
// and group/world-writable parent directories are rejected.
validate_external_parent_chain(&resolved)?;
return Ok(resolved);
}
safe_name(value)?;
Ok(directory.join(value))
}
fn read_configured_pem(directory: &Path, value: &str, private: bool) -> Result<Zeroizing<Vec<u8>>> {
let external = Path::new(value).is_absolute();
let path = configured_certificate_path(directory, value)?;
read_pem_with_policy(&path, private, external)
}
fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
read_pem_with_policy(path, private, false)
}
fn read_pem_with_policy(
path: &Path,
private: bool,
external_group_read: bool,
) -> Result<Zeroizing<Vec<u8>>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options
.open(path)
.with_context(|| format!("cannot open certificate input {}", path.display()))?;
let meta = file.metadata()?;
ensure!(
meta.is_file() && meta.len() <= MAX_PEM,
"certificate inputs must be regular files up to 128 KiB"
);
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let mode = meta.permissions().mode();
if external_group_read {
let owner = meta.uid();
let service_uid = unsafe { libc::geteuid() };
ensure!(
owner == 0 || owner == service_uid,
"external certificate/key must be owned by root or the RustProxy service user: {}",
path.display()
);
// POSIX ACL masks are reflected in the traditional group mode bits.
// Therefore do not require an exact chmod such as 0600/0640 and do
// not reject harmless execute-mask bits. What matters here is that
// an untrusted group/other identity cannot modify the material and
// that the private key is never readable by "other". A named ACL
// such as `u:rustproxy:r--` is intentionally supported.
ensure!(
mode & 0o022 == 0,
"external certificate/key must not be group/world-writable: {}",
path.display()
);
if private {
ensure!(
mode & 0o004 == 0,
"external private key must not be world-readable; use a restricted group or POSIX ACL for the rustproxy user: {}",
path.display()
);
}
} else if private {
ensure!(
mode & 0o077 == 0,
"private key permissions must not grant group/world access: {}",
path.display()
);
}
}
#[cfg(not(unix))]
let _ = (private, external_group_read);
let mut data = Zeroizing::new(Vec::new());
file.take(MAX_PEM + 1).read_to_end(&mut data)?;
ensure!(
data.len() <= MAX_PEM as usize,
"PEM file grew beyond its limit"
);
Ok(data)
}
+1 -61
View File
@@ -162,68 +162,8 @@ mod tests {
assert!(certificate_files_are_external("app.pem", "/etc/tls/app.key").is_err()); assert!(certificate_files_are_external("app.pem", "/etc/tls/app.key").is_err());
} }
#[cfg(unix)] include!("tests/external_paths.rs");
#[test]
fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
use std::os::unix::fs::{symlink, PermissionsExt};
let root = tempfile::tempdir().unwrap();
let cert_dir = root.path().join("rustproxy-certs");
fs::create_dir(&cert_dir).unwrap();
initialize_default(&cert_dir).unwrap();
let archive = root.path().join("archive/example.test");
let live = root.path().join("live/example.test");
fs::create_dir_all(&archive).unwrap();
fs::create_dir_all(&live).unwrap();
let first = rcgen::generate_simple_self_signed(vec!["example.test".into()]).unwrap();
fs::write(archive.join("fullchain1.pem"), first.cert.pem()).unwrap();
fs::write(archive.join("privkey1.pem"), first.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o640)).unwrap();
symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap();
let cert_path = live.join("fullchain.pem").display().to_string();
let key_path = live.join("privkey.pem").display().to_string();
assert!(certificate_files_are_external(&cert_path, &key_path).unwrap());
let settings = Settings {
default_certificate: Some("letsencrypt".into()),
certificates: vec![Certificate {
issuer: None,
id: "letsencrypt".into(),
covered_hosts: Vec::new(),
hosts: vec!["example.test".into()],
cert_file: cert_path.clone(),
key_file: key_path.clone(),
}],
..Settings::default()
};
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test"]
);
let second = rcgen::generate_simple_self_signed(vec![
"example.test".into(),
"www.example.test".into(),
])
.unwrap();
fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap();
fs::write(archive.join("privkey2.pem"), second.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey2.pem"), fs::Permissions::from_mode(0o640)).unwrap();
fs::remove_file(live.join("fullchain.pem")).unwrap();
fs::remove_file(live.join("privkey.pem")).unwrap();
symlink("../../archive/example.test/fullchain2.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey2.pem", live.join("privkey.pem")).unwrap();
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test", "www.example.test"]
);
}
#[test] #[test]
fn default_is_persistent_and_tls_advertises_both_protocols() { fn default_is_persistent_and_tls_advertises_both_protocols() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+98
View File
@@ -0,0 +1,98 @@
#[cfg(unix)]
#[test]
fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
use std::os::unix::fs::{symlink, PermissionsExt};
let root = tempfile::tempdir().unwrap();
let cert_dir = root.path().join("rustproxy-certs");
fs::create_dir(&cert_dir).unwrap();
initialize_default(&cert_dir).unwrap();
let archive = root.path().join("archive/example.test");
let live = root.path().join("live/example.test");
fs::create_dir_all(&archive).unwrap();
fs::create_dir_all(&live).unwrap();
let first = rcgen::generate_simple_self_signed(vec!["example.test".into()]).unwrap();
fs::write(archive.join("fullchain1.pem"), first.cert.pem()).unwrap();
fs::write(archive.join("privkey1.pem"), first.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o650)).unwrap();
symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap();
let cert_path = live.join("fullchain.pem").display().to_string();
let key_path = live.join("privkey.pem").display().to_string();
assert!(certificate_files_are_external(&cert_path, &key_path).unwrap());
let settings = Settings {
default_certificate: Some("letsencrypt".into()),
certificates: vec![Certificate {
issuer: None,
id: "letsencrypt".into(),
covered_hosts: Vec::new(),
hosts: vec!["example.test".into()],
cert_file: cert_path.clone(),
key_file: key_path.clone(),
}],
..Settings::default()
};
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test"]
);
let second = rcgen::generate_simple_self_signed(vec![
"example.test".into(),
"www.example.test".into(),
])
.unwrap();
fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap();
fs::write(archive.join("privkey2.pem"), second.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey2.pem"), fs::Permissions::from_mode(0o640)).unwrap();
fs::remove_file(live.join("fullchain.pem")).unwrap();
fs::remove_file(live.join("privkey.pem")).unwrap();
symlink("../../archive/example.test/fullchain2.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey2.pem", live.join("privkey.pem")).unwrap();
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test", "www.example.test"]
);
}
#[cfg(unix)]
#[test]
fn external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_or_world_read() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let key = root.path().join("external.key");
fs::write(&key, b"not-a-real-key").unwrap();
// POSIX ACL masks can surface through the traditional group class bits.
// Group read/execute is not itself a disclosure when `other` has no read
// and neither group nor other can write the file.
fs::set_permissions(&key, fs::Permissions::from_mode(0o650)).unwrap();
assert!(read_pem_with_policy(&key, true, true).is_ok());
fs::set_permissions(&key, fs::Permissions::from_mode(0o660)).unwrap();
assert!(read_pem_with_policy(&key, true, true).is_err());
fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
assert!(read_pem_with_policy(&key, true, true).is_err());
}
#[cfg(unix)]
#[test]
fn external_paths_reject_writable_non_sticky_parent_directories() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let unsafe_parent = root.path().join("unsafe");
fs::create_dir(&unsafe_parent).unwrap();
fs::set_permissions(&unsafe_parent, fs::Permissions::from_mode(0o770)).unwrap();
let cert = unsafe_parent.join("cert.pem");
fs::write(&cert, b"dummy").unwrap();
assert!(configured_certificate_path(root.path(), cert.to_str().unwrap()).is_err());
}
-100
View File
@@ -330,106 +330,6 @@ pub fn safe_name(name: &str) -> Result<()> {
/// remain private to `<data-dir>/certs`; an explicitly selected external pair uses /// remain private to `<data-dir>/certs`; an explicitly selected external pair uses
/// two absolute paths. Mixing the two modes is rejected so the source is never /// two absolute paths. Mixing the two modes is rejected so the source is never
/// ambiguous. /// ambiguous.
pub fn certificate_files_are_external(cert_file: &str, key_file: &str) -> Result<bool> {
let cert_external = Path::new(cert_file).is_absolute();
let key_external = Path::new(key_file).is_absolute();
ensure!(
cert_external == key_external,
"certificate and private key must both use filenames from the RustProxy certificate directory or both use absolute external paths"
);
if cert_external {
ensure!(
!cert_file.is_empty()
&& !key_file.is_empty()
&& cert_file.len() <= MAX_CERTIFICATE_PATH
&& key_file.len() <= MAX_CERTIFICATE_PATH,
"external certificate paths must be absolute paths up to 4096 bytes"
);
Ok(true)
} else {
safe_name(cert_file)?;
safe_name(key_file)?;
Ok(false)
}
}
fn configured_certificate_path(directory: &Path, value: &str) -> Result<PathBuf> {
if Path::new(value).is_absolute() {
ensure!(
!value.is_empty() && value.len() <= MAX_CERTIFICATE_PATH,
"external certificate path must be an absolute path up to 4096 bytes"
);
// Certbot's live/ entries are symlinks to archive/. Resolve them on every
// reload, then open the final regular file with O_NOFOLLOW in read_pem.
return fs::canonicalize(value)
.with_context(|| format!("cannot resolve external certificate path {value}"));
}
safe_name(value)?;
Ok(directory.join(value))
}
fn read_configured_pem(
directory: &Path,
value: &str,
private: bool,
) -> Result<Zeroizing<Vec<u8>>> {
let external = Path::new(value).is_absolute();
let path = configured_certificate_path(directory, value)?;
read_pem_with_policy(&path, private, external)
}
fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
read_pem_with_policy(path, private, false)
}
fn read_pem_with_policy(
path: &Path,
private: bool,
external_group_read: bool,
) -> Result<Zeroizing<Vec<u8>>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options
.open(path)
.with_context(|| format!("cannot open certificate input {}", path.display()))?;
let meta = file.metadata()?;
ensure!(
meta.is_file() && meta.len() <= MAX_PEM,
"certificate inputs must be regular files up to 128 KiB"
);
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if external_group_read {
ensure!(
mode & 0o137 == 0,
"external private key may be owner/group-readable but must not be executable, group-writable, or world-accessible: {}",
path.display()
);
} else {
ensure!(
mode & 0o077 == 0,
"private key permissions must not grant group/world access: {}",
path.display()
);
}
}
#[cfg(not(unix))]
let _ = (private, external_group_read);
let mut data = Zeroizing::new(Vec::new());
file.take(MAX_PEM + 1).read_to_end(&mut data)?;
ensure!(
data.len() <= MAX_PEM as usize,
"PEM file grew beyond its limit"
);
Ok(data)
}
fn der_tlv<'a>(input: &'a [u8], offset: &mut usize) -> Result<(u8, &'a [u8])> { fn der_tlv<'a>(input: &'a [u8], offset: &mut usize) -> Result<(u8, &'a [u8])> {
ensure!(*offset < input.len(), "truncated DER object"); ensure!(*offset < input.len(), "truncated DER object");
let tag = input[*offset]; let tag = input[*offset];
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(), if context.tls { "on" } else { "off" }.into(),
); );
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into()); params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.1".into()); params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.2".into());
params.insert("REDIRECT_STATUS".into(), "200".into()); params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers if let Some(value) = headers
.get(CONTENT_TYPE) .get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,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>" "<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.28.1</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#); html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes() html.into_bytes()
} }
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap(); let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt")); assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING")); assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.28.1</b>")); assert!(body.contains("RustProxy <b>0.28.2</b>"));
} }
#[tokio::test] #[tokio::test]
+65 -31
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root) preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text()) manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.28.1" assert manifest["package"]["version"] == "0.28.2"
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
h3dep = manifest["dependencies"]["h3"] h3dep = manifest["dependencies"]["h3"]
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"] 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" lock_path = root / "Cargo.lock"
if lock_path.exists(): if lock_path.exists():
lock = lock_path.read_text() lock = lock_path.read_text()
assert 'name = "rust-proxy"\nversion = "0.28.1"' in lock assert 'name = "rust-proxy"\nversion = "0.28.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" 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 { for release_doc, marker in {
"README.md": "# RustProxy 0.28.1", "README.md": "# RustProxy 0.28.2",
"CHANGELOG.md": "## 0.28.1", "CHANGELOG.md": "## 0.28.2",
"ROADMAP.md": "## 0.28.1", "ROADMAP.md": "## 0.28.2",
"AGENTS.md": "Current source version: 0.28.1.", "AGENTS.md": "Current source version: 0.28.2.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.28.1", "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.28.2",
}.items(): }.items():
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" 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" assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
@@ -124,6 +124,7 @@ assert 'cargo generate-lockfile' in dev and r'version = "0\.9\.0"' not in dev
assert dev.count('cargo generate-lockfile') == 1, "dev.sh must only create Cargo.lock when it is missing" assert dev.count('cargo generate-lockfile') == 1, "dev.sh must only create Cargo.lock when it is missing"
unit = (root / "deploy/rustproxy.service").read_text() unit = (root / "deploy/rustproxy.service").read_text()
assert "LogsDirectory=rustproxy" in unit and "/var/log/rustproxy" in unit assert "LogsDirectory=rustproxy" in unit and "/var/log/rustproxy" in unit
assert "ReadOnlyPaths=-/etc/letsencrypt -/etc/ssl" in unit
assert "Environment=MALLOC_ARENA_MAX=2" in unit assert "Environment=MALLOC_ARENA_MAX=2" in unit
assert "Environment=MALLOC_ARENA_MAX=2" in read_deploy_source() assert "Environment=MALLOC_ARENA_MAX=2" in read_deploy_source()
assert not re.search(r"\b20\d{2}[-/]\d{1,2}[-/]\d{1,2}\b", (root / "CHANGELOG.md").read_text()), "changelog contains a release date" assert not re.search(r"\b20\d{2}[-/]\d{1,2}[-/]\d{1,2}\b", (root / "CHANGELOG.md").read_text()), "changelog contains a release date"
@@ -284,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 '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
assert '"StreamMetrics"' in openapi and '"streams"' in openapi assert '"StreamMetrics"' in openapi and '"streams"' in openapi
for marker in ['"version": "0.28.1"', '"/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.28.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 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"']: 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 assert marker in openapi, marker
@@ -678,7 +679,7 @@ assert 'Ok(line) if command_tx.send(line).is_ok()' not in main_source, 'do not m
assert 'Ok(line) => {' in main_source and 'command_tx.send(line).is_err()' in main_source assert 'Ok(line) => {' in main_source and 'command_tx.send(line).is_err()' in main_source
for marker in ['LIVE_STREAM_CAPACITY', 'broadcast::Sender<LogEntry>', 'pub fn subscribe']: for marker in ['LIVE_STREAM_CAPACITY', 'broadcast::Sender<LogEntry>', 'pub fn subscribe']:
assert marker in logging_source, marker assert marker in logging_source, marker
assert 'SCHEMA_VERSION: i64 = 8' in db_source and 'certificate_renewals' in db_source and '0004_admin_preferences.sql' in db_source and '0005_certificate_api.sql' in db_source and '0006_certificate_api_credentials.sql' in db_source and '0007_secret_store.sql' in db_source and '0008_config_history.sql' in db_source assert 'SCHEMA_VERSION: i64 = 9' in db_source and 'certificate_renewals' in db_source and '0004_admin_preferences.sql' in db_source and '0005_certificate_api.sql' in db_source and '0006_certificate_api_credentials.sql' in db_source and '0007_secret_store.sql' in db_source and '0008_config_history.sql' in db_source and '0009_certificate_api_grants.sql' in db_source
for marker in ['replace_as_restore', 'config.restore', 'source_revision']: for marker in ['replace_as_restore', 'config.restore', 'source_revision']:
assert marker in db_source, marker assert marker in db_source, marker
for marker in ['restore_config_history', '/api/config/history/restore', 'expected_revision']: for marker in ['restore_config_history', '/api/config/history/restore', 'expected_revision']:
@@ -1300,12 +1301,13 @@ connection.executescript((root / "migrations/0005_certificate_api.sql").read_tex
connection.executescript((root / "migrations/0006_certificate_api_credentials.sql").read_text()) connection.executescript((root / "migrations/0006_certificate_api_credentials.sql").read_text())
connection.executescript((root / "migrations/0007_secret_store.sql").read_text()) connection.executescript((root / "migrations/0007_secret_store.sql").read_text())
connection.executescript((root / "migrations/0008_config_history.sql").read_text()) connection.executescript((root / "migrations/0008_config_history.sql").read_text())
connection.execute("PRAGMA user_version = 8") connection.executescript((root / "migrations/0009_certificate_api_grants.sql").read_text())
connection.execute("PRAGMA user_version = 9")
assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok" assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok"
assert connection.execute("SELECT COUNT(*) FROM auth_throttle WHERE id=1").fetchone()[0] == 1 assert connection.execute("SELECT COUNT(*) FROM auth_throttle WHERE id=1").fetchone()[0] == 1
assert connection.execute("SELECT COUNT(*) FROM certificate_renewals").fetchone()[0] == 0 assert connection.execute("SELECT COUNT(*) FROM certificate_renewals").fetchone()[0] == 0
assert connection.execute("SELECT COUNT(*) FROM admin_preferences").fetchone()[0] == 0 assert connection.execute("SELECT COUNT(*) FROM admin_preferences").fetchone()[0] == 0
assert connection.execute("PRAGMA user_version").fetchone()[0] == 8 assert connection.execute("PRAGMA user_version").fetchone()[0] == 9
connection.close() connection.close()
for marker_0223 in [ for marker_0223 in [
'"/api/certificates/reload"', 'certificate_reload', '"/api/certificates/reload"', 'certificate_reload',
@@ -1394,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 '"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 '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 'location.pathname === "/logging"' in settings_js_0241
assert '"RuntimeInformation"' in openapi and '"version": "0.28.1"' in openapi assert '"RuntimeInformation"' in openapi and '"version": "0.28.2"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions. # 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs") tls_0241 = rust_source(root, "src/tls.rs")
@@ -1516,33 +1518,65 @@ 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 '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 assert 'location-header-direction location-header-editor' in vhost_core_0280
# 0.28.1 external certificate paths and certificate-source UX. # 0.28.2 external-certificate ACL/path hardening and least-privilege Certificate API.
tls_types_0281 = (root / "src/tls/types.rs").read_text() tls_types_0282 = (root / "src/tls/types.rs").read_text()
tls_server_0281 = (root / "src/tls/server_config.rs").read_text() tls_external_0282 = (root / "src/tls/external_paths.rs").read_text()
tls_tests_0281 = (root / "src/tls/tests.rs").read_text() tls_server_0282 = (root / "src/tls/server_config.rs").read_text()
backup_snapshot_0281 = (root / "src/backup/snapshot.rs").read_text() tls_tests_0282 = (root / "src/tls/tests.rs").read_text() + (root / "src/tls/tests/external_paths.rs").read_text()
certificate_admin_0281 = (root / "src/admin/certificates.rs").read_text() backup_snapshot_0282 = (root / "src/backup/snapshot.rs").read_text()
for marker_0281 in [ certificate_admin_0282 = (root / "src/admin/certificates.rs").read_text()
certificate_api_db_0282 = (root / "src/db/certificate_api.rs").read_text()
certificate_session_0282 = (root / "src/admin/session.rs").read_text()
deploy_core_0282 = (root / "scripts/deploy/deploy_core.py").read_text()
for marker_0282 in [
'pub fn certificate_files_are_external', 'pub fn certificate_files_are_external',
'cert_external == key_external', 'cert_external == key_external',
'fs::canonicalize(value)',
'Certbot\'s live/ entries are symlinks',
'read_configured_pem',
'external private key may be owner/group-readable',
]: ]:
assert marker_0281 in tls_types_0281, marker_0281 assert marker_0282 in tls_external_0282, marker_0282
assert 'certificate_files_are_external(cert, key)?;' in tls_server_0281 for marker_0282 in [
assert 'external_absolute_paths_follow_certbot_style_symlinks_on_each_reload' in tls_tests_0281 'validate_external_parent_chain',
assert 'certificate_files_are_external(' in backup_snapshot_0281 and 'External certificate material is intentionally outside' in backup_snapshot_0281 'fs::canonicalize(configured)',
assert 'uses external file paths; renew those files in place and call the certificate reload endpoint instead of deploy' in certificate_admin_0281 'libc::O_NOFOLLOW',
for marker_0281 in [ 'mode & 0o022 == 0',
'mode & 0o004 == 0',
'POSIX ACL masks are reflected',
]:
assert marker_0282 in tls_external_0282, marker_0282
assert 'certificate_files_are_external(cert, key)?;' in tls_server_0282
for marker_0282 in [
'external_absolute_paths_follow_certbot_style_symlinks_on_each_reload',
'external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_or_world_read',
'external_paths_reject_writable_non_sticky_parent_directories',
]:
assert marker_0282 in tls_tests_0282, marker_0282
assert 'certificate_files_are_external(' in backup_snapshot_0282 and 'External certificate material is intentionally outside' in backup_snapshot_0282
assert 'uses external file paths; renew those files in place and call the certificate reload endpoint instead of deploy' in certificate_admin_0282
for marker_0282 in [
'certificate_api_grants',
'allow_deploy',
'certificate_ids',
'value != "*"',
]:
assert marker_0282 in certificate_api_db_0282, marker_0282
for marker_0282 in [
'certificate_api.target_required',
'certificate_api.certificate_forbidden',
'certificate_api.deploy_forbidden',
]:
assert marker_0282 in certificate_session_0282, marker_0282
assert 'ReadOnlyPaths=-/etc/letsencrypt -/etc/ssl' in deploy_core_0282
for marker_0282 in [
'id="certificate-file-source"', 'id="certificate-file-source"',
'certificateSourceManaged', 'certificateSourceManaged',
'certificateSourceExternal', 'certificateSourceExternal',
'/etc/letsencrypt/live/example.com/fullchain.pem', '/etc/letsencrypt/live/example.com/fullchain.pem',
'certificate-external-acl-example',
'certificate-api-certificate-ids',
'certificate-api-allow-deploy',
]: ]:
assert marker_0281 in html or marker_0281 in runtime_web_0280 or marker_0281 in (root / 'langs/en_US.json').read_text(), marker_0281 assert marker_0282 in html or marker_0282 in runtime_web_0280 or marker_0282 in (root / 'langs/en_US.json').read_text(), marker_0282
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text() 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.28.1 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-v8 migrations.") print("PASS: 0.28.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.") print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
+6 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.28.1 release.""" """Rust-independent documentation/Reader consistency checks for the 0.28.2 release."""
from pathlib import Path from pathlib import Path
import re import re
from rust_source import rust_source from rust_source import rust_source
@@ -14,6 +14,7 @@ required = [
DOCS / "README.md", DOCS / "README.md",
DOCS / "WEB_SERVER.md", DOCS / "WEB_SERVER.md",
DOCS / "TODO.md", DOCS / "TODO.md",
DOCS / "V0.28.2_IMPLEMENTATION.md",
DOCS / "V0.28.1_IMPLEMENTATION.md", DOCS / "V0.28.1_IMPLEMENTATION.md",
DOCS / "V0.27.3_IMPLEMENTATION.md", DOCS / "V0.27.3_IMPLEMENTATION.md",
DOCS / "V0.27.2_IMPLEMENTATION.md", DOCS / "V0.27.2_IMPLEMENTATION.md",
@@ -61,16 +62,16 @@ assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
assert readme.startswith("# RustProxy 0.28.1") assert readme.startswith("# RustProxy 0.28.2")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme 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 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 "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
assert roadmap.startswith("# RustProxy 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.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 "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
assert changelog.startswith("# Changelog\n") assert changelog.startswith("# Changelog\n")
for release in ["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.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 f"## {release}" in changelog, release
assert 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.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 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"]: 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}" assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
+2 -2
View File
@@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") 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/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"]:
if not (root / path).is_file(): if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.28.1 source tree: missing {path}") raise ValueError(f"Incomplete RustProxy 0.28.2 source tree: missing {path}")
text = (root / "src/lib.rs").read_text(encoding="utf-8") text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text: if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.28.1 entry point; extract the complete package, not individual files.") raise ValueError("src/lib.rs is not the 0.28.2 entry point; extract the complete package, not individual files.")
return messages return messages
+19 -10
View File
@@ -62,17 +62,22 @@ def sql_and_backup_smoke(tmp: Path) -> None:
) )
connection.execute("PRAGMA user_version = 3") connection.execute("PRAGMA user_version = 3")
connection.commit() connection.commit()
# Simulate additive startup migrations through the current schema-v8. # Simulate additive startup migrations through the current schema-v9.
connection.executescript((ROOT / "migrations/0004_admin_preferences.sql").read_text(encoding="utf-8")) connection.executescript((ROOT / "migrations/0004_admin_preferences.sql").read_text(encoding="utf-8"))
connection.executescript((ROOT / "migrations/0005_certificate_api.sql").read_text(encoding="utf-8")) connection.executescript((ROOT / "migrations/0005_certificate_api.sql").read_text(encoding="utf-8"))
connection.executescript((ROOT / "migrations/0006_certificate_api_credentials.sql").read_text(encoding="utf-8")) connection.executescript((ROOT / "migrations/0006_certificate_api_credentials.sql").read_text(encoding="utf-8"))
connection.executescript((ROOT / "migrations/0007_secret_store.sql").read_text(encoding="utf-8")) connection.executescript((ROOT / "migrations/0007_secret_store.sql").read_text(encoding="utf-8"))
connection.executescript((ROOT / "migrations/0008_config_history.sql").read_text(encoding="utf-8")) connection.executescript((ROOT / "migrations/0008_config_history.sql").read_text(encoding="utf-8"))
connection.execute(
"INSERT INTO certificate_api_credentials(key_id,token_hash,comment,created_at,last_used_at) VALUES(?,?,?,?,NULL)",
("legacy-hook", "0" * 64, "pre-0.28.2", 1),
)
connection.executescript((ROOT / "migrations/0009_certificate_api_grants.sql").read_text(encoding="utf-8"))
connection.execute( connection.execute(
"INSERT INTO admin_preferences(administrator_id,document) VALUES(1,?)", "INSERT INTO admin_preferences(administrator_id,document) VALUES(1,?)",
(json.dumps({"live_log_columns": ["time", "status"]}),), (json.dumps({"live_log_columns": ["time", "status"]}),),
) )
connection.execute("PRAGMA user_version = 8") connection.execute("PRAGMA user_version = 9")
connection.commit() connection.commit()
assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok" assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok"
tables = { tables = {
@@ -81,8 +86,11 @@ def sql_and_backup_smoke(tmp: Path) -> None:
"SELECT name FROM sqlite_master WHERE type='table'" "SELECT name FROM sqlite_master WHERE type='table'"
) )
} }
for table in ("configuration", "administrator", "admin_sessions", "admin_audit", "auth_throttle", "certificate_renewals", "admin_preferences", "certificate_api_credential", "certificate_api_credentials", "secret_store", "configuration_history"): for table in ("configuration", "administrator", "admin_sessions", "admin_audit", "auth_throttle", "certificate_renewals", "admin_preferences", "certificate_api_credential", "certificate_api_credentials", "certificate_api_grants", "secret_store", "configuration_history"):
assert table in tables, table assert table in tables, table
assert connection.execute(
"SELECT allow_deploy, certificate_ids FROM certificate_api_grants WHERE key_id='legacy-hook'"
).fetchone() == (0, '["*"]')
finally: finally:
connection.close() connection.close()
os.chmod(db, 0o600) os.chmod(db, 0o600)
@@ -92,7 +100,7 @@ def sql_and_backup_smoke(tmp: Path) -> None:
assert copied.is_file() assert copied.is_file()
metadata = json.loads((destination / "backup.json").read_text(encoding="utf-8")) metadata = json.loads((destination / "backup.json").read_text(encoding="utf-8"))
assert metadata["project"] == "RustProxy" assert metadata["project"] == "RustProxy"
assert metadata["schema"] == 8 assert metadata["schema"] == 9
assert metadata["configuration_revision"] == 4 assert metadata["configuration_revision"] == 4
restored = sqlite3.connect(copied) restored = sqlite3.connect(copied)
try: try:
@@ -127,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir() assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file() assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists() assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.28.1"' in (target / "Cargo.toml").read_text(encoding="utf-8") assert 'version = "0.28.2"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n" 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 / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
assert (target / "langs/site.json").is_file() assert (target / "langs/site.json").is_file()
@@ -138,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert (target / "docker-agent/certificate.go").is_file() assert (target / "docker-agent/certificate.go").is_file()
assert (target / "docker-agent/server.go").is_file() assert (target / "docker-agent/server.go").is_file()
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
assert manifest["to_version"] == "0.28.1" assert manifest["to_version"] == "0.28.2"
assert manifest["removed_obsolete_runtime_state"] is True assert manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists() assert not legacy.exists()
assert not (backup / "legacy-data").exists() assert not (backup / "legacy-data").exists()
@@ -177,6 +185,7 @@ def deployment_gate_smoke(tmp: Path) -> None:
remote_unit = deploy.service_unit({"prefix":"/opt/rustproxy", "data_dir":"/var/lib/rustproxy", "user":"rustproxy", "group":"rustproxy", "admin":"0.0.0.0:9090", "remote_admin":True, "http":"0.0.0.0:80", "https":"0.0.0.0:443"}) remote_unit = deploy.service_unit({"prefix":"/opt/rustproxy", "data_dir":"/var/lib/rustproxy", "user":"rustproxy", "group":"rustproxy", "admin":"0.0.0.0:9090", "remote_admin":True, "http":"0.0.0.0:80", "https":"0.0.0.0:443"})
assert "ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin" in remote_unit assert "ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin" in remote_unit
assert "ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy" in remote_unit assert "ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy" in remote_unit
assert "ReadOnlyPaths=-/etc/letsencrypt -/etc/ssl" in remote_unit
assert "TimeoutStopSec=120" in remote_unit assert "TimeoutStopSec=120" in remote_unit
for marker in ["PrivateDevices=true", "ProtectKernelLogs=true", "RestrictNamespaces=true", "RestrictRealtime=true", "SystemCallArchitectures=native"]: for marker in ["PrivateDevices=true", "ProtectKernelLogs=true", "RestrictNamespaces=true", "RestrictRealtime=true", "SystemCallArchitectures=native"]:
assert marker in remote_unit, marker assert marker in remote_unit, marker
@@ -610,7 +619,7 @@ def source_contract_smoke() -> None:
assert marker in admin, marker assert marker in admin, marker
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] 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 '"/api/status"' not in anonymous_block
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.28.1"' in openapi_0241 assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.28.2"' in openapi_0241
assert 'id="panel-logging"' not in html 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"']: for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
assert marker in html, marker assert marker in html, marker
@@ -997,7 +1006,7 @@ def source_contract_smoke() -> None:
app_runtime = (ROOT / 'src/admin/web/app/runtime.js').read_text(encoding='utf-8') app_runtime = (ROOT / 'src/admin/web/app/runtime.js').read_text(encoding='utf-8')
for marker in ['const protocols = ["H1"]', 'protocols.push("H2")', 'protocols.push("H2C")', 'protocols.push("H3/UDP")']: for marker in ['const protocols = ["H1"]', 'protocols.push("H2")', 'protocols.push("H2C")', 'protocols.push("H3/UDP")']:
assert marker in app_runtime, marker assert marker in app_runtime, marker
assert "SCHEMA_VERSION: i64 = 8" in db_source and "certificate_renewals" in db_source and "0004_admin_preferences.sql" in db_source and "0005_certificate_api.sql" in db_source and "0006_certificate_api_credentials.sql" in db_source and "0007_secret_store.sql" in db_source and "0008_config_history.sql" in db_source assert "SCHEMA_VERSION: i64 = 9" in db_source and "certificate_renewals" in db_source and "0004_admin_preferences.sql" in db_source and "0005_certificate_api.sql" in db_source and "0006_certificate_api_credentials.sql" in db_source and "0007_secret_store.sql" in db_source and "0008_config_history.sql" in db_source and "0009_certificate_api_grants.sql" in db_source
runtime_source = rust_source(ROOT, "src/runtime.rs") runtime_source = rust_source(ROOT, "src/runtime.rs")
assert 'replace_as_restore' in db_source and 'config.restore' in db_source and 'source_revision' in db_source assert 'replace_as_restore' in db_source and 'config.restore' in db_source and 'source_revision' in db_source
assert 'restore_config_history' in admin and '/api/config/history/restore' in admin assert 'restore_config_history' in admin and '/api/config/history/restore' in admin
@@ -1100,7 +1109,7 @@ def source_contract_smoke() -> None:
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main 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") openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
assert '"/api/config/history/restore"' in openapi assert '"/api/config/history/restore"' in openapi
for marker in ['"version": "0.28.1"', '"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.28.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 marker in openapi, marker
assert '"software_update"' in openapi and '"update_available"' in openapi assert '"software_update"' in openapi and '"update_available"' in openapi
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
@@ -1305,7 +1314,7 @@ def main() -> None:
source_contract_smoke() source_contract_smoke()
container_contract_smoke() container_contract_smoke()
grafana_contract_smoke() grafana_contract_smoke()
print("PASS: 0.28.1 SQLite/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.28.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__": if __name__ == "__main__":
+1 -1
View File
@@ -96,7 +96,7 @@ need(web,
'HeaderValue::from_static("no-store")', 'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)", "builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing", "empty_index_can_go_directly_to_directory_listing",
"RustProxy <b>0.28.1</b>", "RustProxy <b>0.28.2</b>",
"traversal_and_symlink_escape_are_not_served", "traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work", "try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics", "precompressed_sidecars_keep_mime_validators_and_range_semantics",