This commit is contained in:
Mateusz Gruszczyński committed 2026-10-23 13:07:11 +02:00
1 parent 66ff9dfaa2
commit ee54aa6875
29 files changed
+936 -65

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy source and documentation maintenance
Current source version: **0.34.9**. The Cargo package and binary are named `rust-proxy`.
Current source version: **0.34.10**. The Cargo package and binary are named `rust-proxy`.
Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
+9 -1
View File
@@ -1,6 +1,14 @@
# Changelog
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.9.
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.10.
## 0.34.10 — path permission helpers and automatic certificate SNI
- **Permission helper appearance:** keep the original compact ACL code-block styling for both certificate paths and Web Server roots, with a quiet copy action. Account selection and extended guidance are collapsed under **Options and help**; generated commands remain below the path fields.
- **External certificate files:** replace the fixed example ACL block with commands generated from the entered certificate/key paths, placed below both path fields. The helper resolves symlink targets, removes unsafe write/world-read file permissions, grants directory traversal and adds read access for the `rustproxy` service account. Commands remain operator-run; external-path ownership and private-key checks remain enforced.
- **Certificate SNI:** inspect the actual public certificate after a path/source change and fill legacy SNI names from DNS SAN entries. Preserve manually entered or cleared values and ignore stale asynchronous results. **Read names from certificate** retries a failed read or explicitly refreshes the field after fixing permissions. Inspection does not read the private key or save configuration.
- **Web Server:** generate document-root permission commands directly below its path, updating them for input, edits, resets and presets. Include parent traversal, read access to the served tree and default ACLs for new content without granting RustProxy write access. The permission walk does not follow child symlinks. PHP-FPM and systemd/MAC policies remain separately managed.
- **API and documentation:** add a session-authenticated, CSRF-protected certificate inspection endpoint with matching OpenAPI/access regressions; update English/Polish panel text and filesystem guidance. General administration tokens and certificate-only tokens cannot use the inspection endpoint. Package and lockfile are aligned to **0.34.10**; native Cargo and protocol acceptance are still required for this source candidate.
## 0.34.9 — experimental TLS post-quantum key exchange (source candidate)
Generated
+1 -1
View File
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.34.9"
version = "0.34.10"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.34.9"
version = "0.34.10"
edition = "2021"
rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration"
+7 -2
View File
@@ -1,4 +1,4 @@
# RustProxy 0.34.9
# RustProxy 0.34.10
RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language.
@@ -16,6 +16,11 @@ RustProxy combines a reverse proxy, static/PHP web server, certificate managemen
Features and default settings vary by component; configure only the capabilities you need. The [documentation index](docs/README.md) covers each workflow, including prerequisites, security boundaries, troubleshooting and API access.
## 0.34.10 — filesystem permissions and certificate names
- **Certificates → Existing files:** permission commands are generated from the entered external certificate/key paths and displayed **below both path fields**. The command block keeps the previous panel styling, with account selection and guidance under **Options and help**. Commands grant directory traversal and read access to the resolved files for the `rustproxy` service account. After the public certificate can be read, its DNS SAN names automatically fill the legacy SNI field; manual edits are preserved, and **Read names from certificate** retries after permissions are corrected. See [Certificates](docs/ACME_PROVIDERS.md).
- **Web Server:** the document-root field now has generated permission commands directly below it, updated as the path changes. The helper covers parent traversal and read/traversal of the served tree. Review and run the commands as an administrator on the server; the panel does not execute them. See [Web Server](docs/WEB_SERVER.md).
## 0.34.9 — experimental PQC (source candidate)
- **Settings → TLS policy:** opt-in experimental TLS 1.3 hybrid post-quantum key exchange (X25519MLKEM768), using aws-lc-rs only for enabled frontend TLS policies. Classic key-exchange groups remain enabled for client compatibility. Off by default; cipher-suite choices remain separate. HTTP/3 and per-vhost TLS overrides follow the selected policy. Details and rollback: [TLS PQC guide](docs/TLS_POST_QUANTUM.md) (also in the authenticated panel Reader).
@@ -114,4 +119,4 @@ ownership, cascade rules, duplicate checks and browser regression commands.
## Status of this source archive
Version **0.34.9** is provided as a **source-only experimental candidate**. PQC dependencies were provisionally locked using published crate metadata, not checked by Cargo in the packaging environment. Run `cargo metadata --locked`, `cargo check --locked --all-targets`, `cargo test --locked --all-targets` and protocol compatibility checks on your target system before production deployment. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
Version **0.34.10** is provided as a **source-only release candidate**. It retains the experimental PQC implementation introduced in 0.34.9, whose dependencies were provisionally locked using published crate metadata. Native build and protocol acceptance remain required. Run `cargo metadata --locked`, `cargo check --locked --all-targets`, `cargo test --locked --all-targets` and protocol compatibility checks on your target system before production deployment. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
+34 -36
View File
@@ -1,12 +1,12 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
18ea9db93171d23888334488151554d52d9a3af701581b89ccbe4150a5b81e7f .gitignore
237ed6e2c0d4fa1f125180c349856749c13820b72593b5fc58e1b58cce47c840 AGENTS.md
608cc471556fa6e29fefae1d5ea18194d6ef94b5d32410965b0140a4fcf7018c CHANGELOG.md
310d60da6c7f501ccdde86b93ee1d5b0102033ba71ac9bb42a4e3ce184692ef9 Cargo.lock
c18c0ba1a22a8534a4cf3b6db907f41f04e0b6cb47cd305a21af139a06e303fb Cargo.toml
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
7f4a44314648305efc26a4b3335cfcdeafc401df69c19e7fdcf2c4382a28d16b AGENTS.md
f3169e64988b073686380b48e2d0f7a3858caaafba3f54a4bc35ba97c22b83fb CHANGELOG.md
8ed9d763e18ae167df3b738c5c467e3a581c3b14c151a1dce7404dffcb7094fc Cargo.lock
d1b10459313cf8c33e80d38da05e1ad6b57df6f4dbe9d0e1f18aacf66955822e Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
a288a9c873bfe2e866a41b116af8cf3dac7661db68164bbe6b57706e2fca7bc6 README.md
59bc6f91c6f766799e85e35fb44499c8cb0a4d9880bdcc08402ba6a68a703945 README.md
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs
@@ -28,8 +28,8 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
5664af2afde7f6c88d44e02aa8a9cd719a6019db15b570510185e4114c83799e docs/ACCESS.md
30542f4c60dc2738ea6b7f8214c7d3dc493c470d632e302681225f208b2fea79 docs/ACME_PROVIDERS.md
9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md
dd186c91244be2d1dde47626d5022c2b24ce7a6834e0f08b1b401cec71017b4f docs/ACME_PROVIDERS.md
989293ade34917b0ab3718da3b6c84e897b45cd6fe22345757f151e1cbd510fd docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
6dcfa83377d10169a3b6cd7e6f79c500f0707242874f2d21212be7ca6a81d3fa docs/CACHE.md
@@ -38,21 +38,21 @@ f24767c2f9d4db4db5005b700d03323dd20f0a1d180ae939be4fe54a126aaf32 docs/CONFIGURA
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
302a9db0a5a4e5f6c49684ef0ea9ad9f1796fdbcb5e28bb5a98c75fffd4374f4 docs/DASHBOARD.md
3e2f7670d136911c1b7bfe8571c60590932f74a60bcac308add4ccb0446bf970 docs/DOCKER_DISCOVERY.md
f1baabe34e8a1b204d7c78e2f558c019c156b03adfa6ddecb931b4b8a5501f78 docs/INSTALL.md
c891ee5272e2e47e1a8f97133a413e2330844103d543fb5cbcc4d0724d59a1a5 docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
694cb7b15c5bbdc94cecd15dc516c05dcb3467849322cdd125b872d36dee4699 docs/LOGS.md
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
140b95ad778261455f269f2144a1c61c2315bd3b85300fb613822eacddf3cfc2 docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
bcd8c4eff9d57342d75441f5941a5828275d6b320317668e727467bc0d876f66 docs/README.md
61606429f533b23cb991332459f4c88799cd2e3d0344701a65c1b04422e1b008 docs/README.md
490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md
e2b36152c13d65621a367c69dd400118dae1e4426a4d2b2139d2f63fa30442d6 docs/SECURITY.md
bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md
11157c4bd43f51ea84ef7bde040cca3e818f2ada04a43b6bf1620c477abb53f7 docs/TLS_POST_QUANTUM.md
374fc461eb9700cad532addbf2a440d69162d0e9237b6c4f9ee494a3df0e67d4 docs/TLS_POST_QUANTUM.md
43a2c6230f95f81bf4efd1575a4f7cdc8b02244df38e19b1ec9c3cd736d3afe6 docs/TRANSPORT.md
8542c4a5c9d5dfe474459099546ae590b4c4d3f363f0bba9721c6107643c2d3a docs/TROUBLESHOOTING.md
25b32d0d906333969df9e6a0a8c1b399cd34ad177b601219ba8ade8e164d3887 docs/VHOSTS.md
05efc113d33be361bac25c90064b1865cf2f574dabd0eaa73bdddda229b2946e docs/WEB_SERVER.md
25fd98fd7257bb371472ac32cb8d508f5ee3d785a6feb01fc45cc80a3d3afc54 docs/WEB_SERVER.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json
@@ -77,8 +77,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
9f83bd975c041421a42ecd00bedcfd3bd6285d9397412644a9375ffdf4bcff5a langs/en_US.json
28f5738f0635a71b5a13cdc1647aa281c0d124ef12561ef28e9e51cbafbd2ee3 langs/pl_pl.json
32d376bbd80d77656c2a64b460e75486bb2af5d5360f18b3d45e3d237791b6eb langs/en_US.json
74090ff358a36220dcfa090abc8ccc576a890b889716181096c0ac40a4ab936f langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -110,7 +110,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
8f9f2dc7e6d6688b915a7dfbfc05643a80e5814819926cf43458a54965cfc83c scripts/preflight.py
f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
@@ -135,7 +135,8 @@ b28a08ea996befe10949676f40fab5f1036a274c7d0ab1175cf5c43c59849dc3 src/admin/acce
bcf131abdae425a85b5a54ade51346f623de1e10fa08e18f7c10f1fd2dd0aec1 src/admin/api_token_access.rs
5f5b150bb8f51f57462e519650093354ae7b40fcb80c4380fd3391a33f4b30f2 src/admin/api_tokens.rs
34e851f725e8605e3c5d9946e56ac035996f3bd9d608169cba3408060d229c9b src/admin/certificate_api.rs
a402209141667fdce69698a60dc0b867248effa191494d0a0d93d468d7df9476 src/admin/certificates.rs
c767e3efa645c34ec1713840fa61967251c16a1e845895643b19028321e28f5b src/admin/certificate_inspection_tests.rs
00fa148123112d15917cdc750c3c466e67fa38af0e22571942b3cdd0d02733ef src/admin/certificates.rs
521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs
159c3970d25fa29acf2483fc9422b003e5d63884713f62baa8375e4a4020730e src/admin/dashboard_presets.rs
aae1ca0e70d987830bd63948d431f54302083148177c1b551565be12df7fe771 src/admin/documentation.rs
@@ -147,7 +148,7 @@ d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file
282352eda7957477e7da44f16b90984b26a4a67a8755833bc0a6b34eaf93e3af src/admin/file_logs_query.rs
f66ff718f82a326f80808b0a5088bd05a48625be802e5ba305fff3c344b59948 src/admin/logging_api.rs
1ba0520c6348246cbb35b60e596e99dc600137c0696de86508cf06587bd86563 src/admin/mod.rs
8e1012a8ec776c3525310b03a70f274955dce9de3528109070ea11de0ff5c16c src/admin/openapi.json
a6f8687a9655032da676bdec97c50f5dbe9759fe4871ad65de7a7d3e464dd3ec src/admin/openapi.json
7cfd6593ac9bcea3d1b9e61bbcb7ed98d65ed3190f54f94c1e104541f4fbb4f5 src/admin/operations.rs
257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs
f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs
@@ -157,30 +158,33 @@ f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/pane
62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs
00b5dd3090792986217b28c69bf826748145129e5517b260d95019b1d7b2ebb2 src/admin/router.rs
6d104cd21ff86c55e80b9a5039be6b61f0d82c7ba9e35748515d73e9249441b3 src/admin/router.rs
e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/session.rs
09cc50a86960897371730cc1abed83f4f640c86b680ec7ff6d6b84c7e96a3c0b src/admin/status.rs
2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs
8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
dacdf1b948991698f16640a375e3af90c3a18bb2247b0ad51aa97f763ee3f7bd src/admin/tests.rs
26dbfdf3b6971dfdc2646710e1cb3dd6980b1fa1fbb10fe8558b12320db5262c src/admin/tests.rs
c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
6a7d88a5ad574c684b52758693da8e5d90d9b5379bc96f983e5c130ca351a8b3 src/admin/web/app/certificate-inspection.js
a085ee336e6669229f3ad2f74536f19bd088412ca79bcaff30dff293f1e1baf9 src/admin/web/app/chart-scales.js
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
c8e1d31a7bf77d9e4f4be979bf6a7474369aae3e1e46e6b30d1a76677fa3d762 src/admin/web/app/core.js
622d34e1616fee9a480d208192fc5a84d62d84ef4233d6a7d95e2bfb8cef415b src/admin/web/app/dashboard-inline-layout.js
851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js
a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js
bfb3fbac62f9ff762a298d9c06808ae81ae55de7a41e0bb35ce5c167f333ffe2 src/admin/web/app/overview-extra-metrics.js
5e62cccf6a9507fe208e9f41bbd08453c3d8c6a6b83c56d93a2bb22550ea6e9a src/admin/web/app/overview-masonry.js
b52456b41451f156edf5f58492119c22726f2cf40cee938527748997e8ee7412 src/admin/web/app/overview-samples.js
d997f62e8bd7ac9d4f16bb302800c98ad943afab663bb0b212ea26c35b58de11 src/admin/web/app/overview-wide-metrics.js
29c80fda23531dcbf0bcd079d0cd6992c1e89eced4e3ce4b0f16a795692366f2 src/admin/web/app/path-permissions.js
5bde5f0881320464eb133cd44de9eff5211e6816e79f7f7e9574b16784fab126 src/admin/web/app/preferences.js
8e75b0915edfbadefc4388701431541f4d35fd5d717363297b63740b3afd34d2 src/admin/web/app/rate-sampling.js
43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js
f1a764691f3e2cdb7c6c43e37dae78249561c017a1b74e973a378cb780b72835 src/admin/web/app/runtime.js
5230db4a345406c45203cd066f1d7096c7e663f7ff8ebfce7b3a4711dd7f56d9 src/admin/web/app/runtime.js
31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js
fa00bce2651308a4e0b1303cc0146be17ed4794b67b2f0cebfaf861558d8c78c src/admin/web/app/settings.js
8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js
@@ -188,10 +192,10 @@ fa00bce2651308a4e0b1303cc0146be17ed4794b67b2f0cebfaf861558d8c78c src/admin/web/
849b2ca9d183bdf6cb1e04651cda52da55b8a631e11ab1d827a052c8436bbd76 src/admin/web/cache-panel.js
e9788a9d1e0bce8775f612a5c842afea8a0dcf01301acdef4b69ff6a52d2ef45 src/admin/web/certificate-api.js
9d000e98497b3387641500d5273d23eda45d532c714f22e187572928da152a97 src/admin/web/css/00-tokens.css
11093f12033d0c549c2d2b0dfdcbc660ed2318a4ab17a9dc562b5902d7ddfb84 src/admin/web/css/10-base.css
791f36e09e0203bf12cac91781e0b7b2af543ca1b344bb029ad77920590db9d0 src/admin/web/css/10-base.css
4aae1657e8f5faa755cab0399769e6179364148f80243e742fc7a5e6e237b3e6 src/admin/web/css/20-shell.css
31834acc1fc234ed314b0ca069658bfab175d782bcfaa5e6ee07d46fef689457 src/admin/web/css/30-overview.css
c89c877479c6fc8fc810766ea4787448953665764d18566fc550a119796121d5 src/admin/web/css/40-editors.css
3936faa2f6e17f07874640256b99a5eece5513b3265ccd8d5fbdcae4fc5b15ca src/admin/web/css/40-editors.css
86f7e08a9973ebddd67aaeca92d2b5e2c0f7f53dee45d65a93bb88b35b207cdc src/admin/web/css/50-settings.css
d019d9741951c9d652fab9e520693e0dd3f00b395e32169e5c7ca3ccaf04132c src/admin/web/css/60-monitoring.css
6afa10207938787013f5a4731910630b19bec4a22682a07c3e67707d17cfd97e src/admin/web/css/70-responsive.css
@@ -220,7 +224,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
66af48d25f91f6dfe64887e20bf75709b9ca2efeb0f6dab3b139f11d8a7e9ee5 src/admin/web/index.html
85bbe657a9a190f29788e61bd98cb8d2f8d4eef967947ddff86020eb7116eb02 src/admin/web/index.html
7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -242,7 +246,7 @@ b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/
8c3da49973c62333559b0e3588c02beb0b425c025eac5cfa82a1bb968f70d9dd src/admin/web/vhosts/rates.js
69c8848159dcc7c4d93f49de3be75553cbecd0731d64302e2b644ae61196e704 src/admin/web/vhosts/render.js
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js
a9466c7b291a1f436c0531709458b22a453ec2251f554ed09ea8065df02411a8 src/admin/web/web-server.js
2d40c2cb32d7365df0c9cd6c8cb6f0483c7a799786c648f4836a0d9740c6c5ef src/auth/files/mod.rs
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs
@@ -459,8 +463,8 @@ e7ed064a1706de9a7345e41fb8b6288b4467c12d1653e8afba6dc392c7a3a36a src/tinyauth/m
df98becda799b6bf4ff5e2fec7d0e0a486e6b7eca6f9f5ad49ffdafd49a4f498 src/tls.rs
2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/acme_tests.rs
7425ee6c839f85d792e9251c6b9039e72fb49a5820a2266f751e6f4967a5871c src/tls/certificates.rs
2ff3c219f87bd82677045a4809d3d94921c5b230bbc2f3e7131b07548f73ea7e src/tls/external_paths.rs
f88a6c8c2e35c3aa58b7763c71a5a2cec82a34f24478a98b0960b81659a1ec4c src/tls/external_paths_tests.rs
b59a62d723c8683ebd7bb2d2d777d9451064af20b254b98a8c4b9eedfd74bd86 src/tls/external_paths.rs
8d14216afdae33b9dffeb5145d92b8c6fd224c3f8f2baca0cf007b70541daa86 src/tls/external_paths_tests.rs
7b4c0f58b3a1e1791bb3c682a001b78bfdbbdafdbc143db8063b3f22dc168616 src/tls/server_config.rs
7c0c06c3009c726043168ae47254a84642dbabbbce1b2e718544414312ca3e9b src/tls/tests.rs
d625e2e3f669484432ffc0bf7c1aba94aa5bb709dc4d080daa3bfa8004dee9f2 src/tls/types.rs
@@ -520,20 +524,14 @@ f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs
df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs
a0670941539ea3d4189ea2631753d7f99ed6efdd77e43511bec25a4c0da9bb53 tests/certificate-inspection.test.js
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
577302f167c9d76655f12852ce57a272712044b67a76823cf1ec625116d461e3 tools/audit/CSS_REFACTOR_REPORT.md
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
3f5c3654e9667a60d2e93558ee3f6872a4fe6839b8efcc64671c4a36fb3d85a2 tools/audit/css-refactor-results.json
915de79d40e79bb34fd3dc45eb39028442e8023ab0dedbf0028ec4e36bc65591 tools/dev/README-dashboard-css.md
c49367cb5f7a4ebb93d6d9f374af7d0722eb2bd3220f6c4cd6632034b73da699 tools/README.md
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
18f9d60dd78b6d7f6cbdc21d4cb579d4b48fbda2557c9c1f84370ac02af80a57 tools/dev/package.json
538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py
8147bbc6f6e87768f47cf7279471ad283786bdf01eeddcc2fd3582d62886bdbd tools/dev/test_dashboard_css.cjs
91937cf81f6667590e119be04c11bfcf438a77b278f97b8ac1785a17062db3b8 tools/dev/test_dashboard_css_motion.cjs
68aed10c460bd7df52aa142ffb9f97eff37b23d979158a1c281297c74ec57957 tools/dev/test_panel_css.py
4ec76e8c28aa473c43e4c01e7d47ba0c99c1325240fa5930e7d5b4de948b04b8 tools/dev/test_path_permissions.cjs
c2d47306a1a47bebd327914da8843fefa7eec366838e201cb83de33454560d5d tools/dev/test_web_server_permissions.cjs
c711154a9df425a56607bd88f03815ffb408213a31a2b66ed1c1c9a6301a2fc6 tools/legacy/backup.py
89d2b4e60a2f4cb93e8943b74b1b5ca84374b01fa74ea07bec07bdff315ac0e9 update.sh
56c702111022e1ca2c8f6b72c635c749b05936ffe6387bdb0a202691614cbc96 upgrade.sh
+13
View File
@@ -58,6 +58,18 @@ When another service owns the certificate, add a manual certificate with the act
/etc/letsencrypt/live/example.com/privkey.pem
```
### Permission commands and automatic SNI names
Select **External files (absolute paths)** and enter both paths. A permission-command block is generated **below the certificate and private-key fields** and changes as the paths change. It uses the supplied paths, resolves symlink targets on the server when run, removes group/other write permission on the resolved PEM files and other-user read permission on the private key, then grants traversal of the relevant parent directories and read access to the files for `rustproxy`. Install the Linux `acl` tools if needed and review/run the commands as an administrator on that server. The panel generates commands; it does not execute them or change filesystem ownership.
The paths must be absolute, without `.`/`..` components or control characters, and resolve to existing regular files when the commands run. For a custom deployment, change **Service user** under **Options and help** before copying the commands; the default is `rustproxy`. This field only changes the generated instructions, not RustProxy's process identity or saved configuration.
The existing trusted-path rules still apply: real PEM files and ancestors must be owned by root or the RustProxy service user, ancestors must not be group/world-writable, and a private key must not be world-readable or group/world-writable. ACLs alone do not make an untrusted path acceptable. Container mounts and mandatory access-control rules need matching deployment settings; see [Installation](INSTALL.md#linux-debrpm-read-only-access-to-external-paths).
After a public certificate becomes readable, the editor automatically fills **Legacy SNI names** from its DNS Subject Alternative Name (SAN) entries. Wildcard SANs are retained; filenames and a certificate's Common Name are not used to invent DNS names. Inspection applies to managed certificate filenames and external paths, reads only the public PEM, and does not activate or save the certificate. The certificate/key pair is still validated when applied.
Automatic reads preserve manually edited names, intentional clearing and existing saved mappings. Results from an earlier path or form are ignored. Use **Read names from certificate** to retry after fixing filesystem permissions or to deliberately replace the current field with the certificate's names. Prefer explicit certificate assignment in the intended Vhost; auto-filled legacy SNI names do not create Vhosts or replace their assignments.
Keep the original Certbot renewal system and use the RustProxy certificate reload/deploy API or local CLI after rotation. RustProxy resolves relevant symlinks again during reload. The service account must have directory traverse and narrow read permissions to the **current** key file; `ReadOnlyPaths` alone does not grant filesystem permission. Renewal hooks may need to reapply ACLs to replaced target files.
### Restricted certificate API
@@ -71,5 +83,6 @@ Create an appropriately scoped credential in **Settings → Certificate API**. I
- **Certificate available but wrong site certificate displayed:** Vhost assignment, listener binding, hostname and restart/reload state.
- **Renewal stops:** Certbot runtime, private profile files, DNS secrets, clock skew, permissions and CA limits.
- **External file reload fails:** rotated symlink targets, certificate/key match, SAN coverage and read permissions.
- **SNI field does not auto-fill:** check the read status below the paths, grant access to the public certificate, then use **Read names from certificate**. A manually edited/cleared field is intentionally preserved until this explicit action; a certificate without DNS SAN entries supplies no names.
Keep a private encrypted application backup and a separate copy of any external certificate/key material. See [Backup and recovery](BACKUP.md) and [Security](SECURITY.md).
+3
View File
@@ -26,6 +26,7 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot
| `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). |
| `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. |
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
| `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. |
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
| `GET/PUT /api/preferences` | Personal UI settings. |
| `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. |
@@ -33,6 +34,8 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot
Some paths have additional DELETE/PATCH operations, job-status resources, query parameters or restricted actions. Use OpenAPI for the **actual fields/methods**, not guesses based on a table. `GET /metrics` here is not a replacement for the separate Prometheus exporter.
Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed file or an absolute external path and returns `{ "cert_file": "...", "covered_hosts": ["example.com"] }`. It applies the existing certificate-file trust and size checks and reads no private key. It makes no configuration or permission changes. This POST requires the signed-in administrator's normal Origin, request-marker and CSRF checks; general RO/RW API tokens and certificate-only credentials cannot call it.
## Authentication and safe writes
- Ordinary API calls require an administrator browser/session cookie on the admin listener, or a scoped general API Bearer token for explicitly allowlisted API methods/paths. Logging in also provides CSRF context. Writes require correct Host/Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1` as enforced by the server.
+18
View File
@@ -98,6 +98,18 @@ modify these files merely because a group or ACL permits it. No systemd
drop-in or extra `ReadOnlyPaths=` entry is needed to **read** site content or
an external certificate. This also applies when paths are stored in the panel.
In **0.34.10**, the panel generates Linux permission commands from the actual
paths you enter: below the two external certificate/key fields, and directly
below a Web Server's document root. The commands target the standard
`rustproxy` account and resolve symlink targets when run. Review them and run
them as an administrator on the server; the panel never executes them.
Install your distribution's `acl` package for `setfacl`. If you use a different
service identity, set **Service user** under **Options and help** to that account before
copying. This field changes only the commands, not the process identity.
No command can replace
the certificate trusted-owner checks or bypass systemd/SELinux/AppArmor
restrictions.
The service runs as the unprivileged `rustproxy` user. To use a dedicated web
group, give that group directory traversal and file read permissions on the
site, then add RustProxy to it:
@@ -144,6 +156,12 @@ are suitable for static websites but **not** for external keys under the
current trusted-path validation. Use root-owned certificate storage instead.
Keep all key access narrow and read-only.
After granting certificate read access, return to the form and use
**Read names from certificate**. The editor reads the public certificate's
DNS SAN entries into the legacy SNI field; this does not read the private key
or save the certificate. Manual name edits are preserved by background reads.
See [Certificates](ACME_PROVIDERS.md#permission-commands-and-automatic-sni-names).
To diagnose unresolved errors, run `systemctl cat rustproxy` and
`systemctl show rustproxy -p ProtectHome -p ProtectSystem -p User -p Group`,
check parent permissions with `namei -l /path/to/file`, and examine
+3 -3
View File
@@ -1,6 +1,6 @@
# RustProxy documentation
Practical guides for **RustProxy 0.34.9**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive.
Practical guides for **RustProxy 0.34.10**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive.
## Getting started
@@ -14,7 +14,7 @@ Practical guides for **RustProxy 0.34.9**. This index and the listed pages are a
- [Vhosts and backends](VHOSTS.md) — domains, listener bindings, request Content-Type filtering, balancing, health and redirects.
- [Custom locations](CUSTOM_LOCATIONS.md) — path routing, rewrites, local responses and error fallbacks.
- [Access and authentication](ACCESS.md) — ACL, country/DDNS, Basic Auth, Tinyauth and rate limits.
- [Static Web Server](WEB_SERVER.md) — document roots, downloads, rules, built-in error pages and file delivery.
- [Static Web Server](WEB_SERVER.md) — document roots, generated filesystem permission commands, downloads, rules, built-in error pages and file delivery.
- [PHP-FPM](PHP_FPM.md) — PHP service profiles, security and application examples.
- [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL and invalidation.
- [Response delivery](RESPONSE_DELIVERY.md) — compression, static transformations and response headers.
@@ -24,7 +24,7 @@ Practical guides for **RustProxy 0.34.9**. This index and the listed pages are a
## Integrations and operations
- [Certificates and automatic renewal](ACME_PROVIDERS.md) — PEM, HTTP-01, DNS-01, Certbot and external certificates.
- [Certificates and automatic renewal](ACME_PROVIDERS.md) — PEM, automatic SNI names, path permission commands, HTTP-01, DNS-01, Certbot and external certificates.
- [Docker discovery](DOCKER_DISCOVERY.md) — optional Linux agent DEB/RPM or manual installation, TLS/token CLI, networking, generated Vhosts and their per-service live HTTP logs.
- [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools.
- [Dashboard](DASHBOARD.md) — metrics panels, HIT/MISS/BYPASS/OTHER request mix, presets, configuration history and applying stylesheet updates.
+2 -2
View File
@@ -1,6 +1,6 @@
# Experimental post-quantum TLS key exchange (PQC)
**Version:** RustProxy 0.34.9 (source candidate). **Default: off.** This is an experimental opt-in feature and not a production compatibility guarantee.
**Version:** introduced in RustProxy 0.34.9; included in the 0.34.10 source candidate. **Default: off.** This is an experimental opt-in feature and not a production compatibility guarantee.
## What it does
@@ -50,4 +50,4 @@ If your TLS connection ends at a CDN, load balancer or TLS-terminating proxy, th
- Enabling the switch changes the incoming TLS cryptographic provider to `aws-lc-rs`; some uncommon certificates, algorithms or hardware platforms may behave differently. `aws-lc-rs` requires a native C/C++ build toolchain; CMake may be needed for some build modes (non-FIPS commonly does not require it). The `ring` provider remains used where the switch is off and for existing outbound integrations.
- A non-empty cipher-suite allowlist must contain suites supported by the selected provider; invalid combinations are rejected. PQC requires TLS 1.3.
- For immediate rollback, **uncheck PQC in Settings → TLS policy and save**, and if necessary remove per-Vhost overrides that require PQC. Confirm new connections negotiate classic X25519. Existing sessions cannot renegotiate in place; close/reconnect them to retest. Keep the prior binary and backups available for a full rollback.
- **Release verification:** this 0.34.9 source candidate has not undergone native Cargo build, TLS handshake, QUIC interoperability or production load testing. Verify the provisionally updated `Cargo.lock` with `cargo metadata --locked` (and refresh it with Cargo if needed), then run `cargo check --all-targets`, `cargo test --all-targets`, `cargo build --release`, and protocol tests before deployment. See the release changelog and source verification notes.
- **Release verification:** the PQC implementation inherited from 0.34.9 still requires native Cargo build, TLS handshake, QUIC interoperability and production load acceptance. Verify the provisionally updated `Cargo.lock` with `cargo metadata --locked` (and refresh it with Cargo if needed), then run `cargo check --all-targets`, `cargo test --all-targets`, `cargo build --release`, and protocol tests before deployment. See the release changelog and source verification notes.
+10
View File
@@ -21,6 +21,16 @@ service outside its private state/log/config paths. Grant execute/traversal
on each parent directory and read permission on served files; see
[Installation: external paths](INSTALL.md#linux-debrpm-read-only-access-to-external-paths).
### Generated filesystem permission commands
As you enter an absolute **Document root**, the editor generates permission commands immediately below the path. They refresh when opening or clearing a service, applying a preset or changing the root, so the displayed commands refer to the current field. The path must not contain `.`/`..` components or control characters and must resolve to an existing directory when the commands run; `/` is not accepted. Install the Linux `acl` tools if needed, review the commands and run them as an administrator on the web server. Saving a Web Service does not execute shell commands or grant filesystem permissions.
The helper grants the `rustproxy` service account traversal (`--x`) of parent directories, read/traversal (`r-x`) on the website's directories and read (`r--`) on regular files. Symlinked document roots are resolved when the commands run, so both the configured path and the target's ancestors can be traversed. Symlinks within the served tree are not followed by the permission walk. Read-only ACLs do not grant writes or relax the Web Service's own symlink policy. Keep private content outside the document root.
Default read/traverse ACLs are added to the existing website directories for newly created content. Default ACL inheritance takes precedence over the creator's `umask`; missing defaults are seeded from the directory's existing permissions. Explicit restrictive creation modes can still mask RustProxy's read access. Files moved into the tree or installed with preserved permissions may need the commands reapplied. Repeat the commands after a deployment changes the document-root symlink target. For a custom service account, change **Service user** under **Options and help** before copying; this changes only the instructions, not the process identity or saved configuration.
For PHP applications, the separate **PHP-FPM pool user** needs its own access to scripts and any writable application directories. The generated RustProxy ACLs do not configure that user. Files must also be visible within the service's container/systemd namespace and permitted by SELinux/AppArmor if used; filesystem ACLs cannot override those policies.
## File serving options
| Feature | Behavior / caution |
+17 -2
View File
@@ -2378,7 +2378,6 @@
"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).",
"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",
"vhWwwRedirect": "Redirect www ↔ non-www",
"vhWwwRedirectTarget": "Choose the domain and redirect direction (308 destination)",
@@ -2852,7 +2851,23 @@
"cacheMixSample": "Since service start · {count} HTTP requests",
"cacheMixWaiting": "No HTTP requests since service start",
"cacheMixAria": "Since service start: {hit} HIT, {miss} MISS, {bypass} BYPASS, {other} OTHER",
"cacheMixInvalid": "Totals unavailable: request and cache counters disagree"
"cacheMixInvalid": "Totals unavailable: request and cache counters disagree",
"pathPermissionsTitle": "Permission commands",
"pathPermissionsUser": "Service user",
"pathPermissionsCopy": "Copy commands",
"pathPermissionsCopied": "Commands copied.",
"pathPermissionsCopyFailed": "Copy failed. Select and copy the commands manually.",
"pathPermissionsCertificateHelp": "Linux: run these commands in a server terminal with sudo; the acl package is required. They grant traversal and read access to the actual files, including symlink targets, and restrict private-key permissions. Files and parent directories must be owned by root or the service user; parents cannot be group/world-writable. After renewal, repeat the commands for new targets and reload the certificate.",
"pathPermissionsWebHelp": "Linux: run these commands in a server terminal with sudo; the acl package is required. They grant traversal of parent directories, read access to this website and default ACLs for new content. Symlinks within the site are skipped. Reapply after moving files or preserving restrictive modes. PHP-FPM has its own user and write permissions.",
"pathPermissionsCertificatePrompt": "Enter both absolute certificate/key paths and a valid service username to generate commands. Paths cannot contain . or .. components or control characters.",
"pathPermissionsWebPrompt": "Enter an absolute website directory (not /) and a valid service username to generate commands. Paths cannot contain . or .. components or control characters.",
"certificateReadNames": "Read names from certificate",
"certificateInspectReading": "Reading certificate…",
"certificateInspectReady": "DNS names read from certificate.",
"certificateInspectKept": "Certificate read; your SNI names were preserved.",
"certificateInspectNoNames": "Certificate contains no DNS SAN names.",
"certificateInspectFailed": "Cannot read certificate. Check the file and its permissions, then read again.",
"pathPermissionsOptions": "Options and help"
},
"overviewLayoutNarrow": "Decrease card width",
"overviewLayoutWiden": "Increase card width",
+17 -2
View File
@@ -2378,7 +2378,6 @@
"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).",
"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",
"vhWwwRedirect": "Przekieruj www ↔ bez www",
"vhWwwRedirectTarget": "Wybierz domenę i kierunek przekierowania (308)",
@@ -2852,7 +2851,23 @@
"cacheMixSample": "Od startu usługi · {count} żądań HTTP",
"cacheMixWaiting": "Brak żądań HTTP od startu usługi",
"cacheMixAria": "Od startu usługi: {hit} HIT, {miss} MISS, {bypass} BYPASS, {other} INNE",
"cacheMixInvalid": "Suma niedostępna: niespójne liczniki żądań i cache"
"cacheMixInvalid": "Suma niedostępna: niespójne liczniki żądań i cache",
"pathPermissionsTitle": "Polecenia nadania uprawnień",
"pathPermissionsUser": "Użytkownik usługi",
"pathPermissionsCopy": "Kopiuj polecenia",
"pathPermissionsCopied": "Skopiowano polecenia.",
"pathPermissionsCopyFailed": "Kopiowanie nie powiodło się. Zaznacz i skopiuj polecenia ręcznie.",
"pathPermissionsCertificateHelp": "Linux: wykonaj polecenia w terminalu serwera z sudo; wymagany jest pakiet acl. Nadają przejście przez katalogi i odczyt właściwych plików, także celów dowiązań, oraz ograniczają uprawnienia klucza. Właścicielem plików i katalogów nadrzędnych musi być root lub użytkownik usługi; katalogi nie mogą być zapisywalne przez grupę/innych. Po odnowieniu powtórz polecenia dla nowych plików i przeładuj certyfikat.",
"pathPermissionsWebHelp": "Linux: wykonaj polecenia w terminalu serwera z sudo; wymagany jest pakiet acl. Nadają przejście przez katalogi nadrzędne, odczyt witryny i domyślne ACL dla nowej zawartości. Dowiązania wewnątrz witryny są pomijane. Powtórz po przeniesieniu plików lub zachowaniu ograniczających trybów dostępu. PHP-FPM ma osobnego użytkownika i uprawnienia zapisu.",
"pathPermissionsCertificatePrompt": "Podaj pełne ścieżki certyfikatu i klucza oraz poprawną nazwę użytkownika usługi, aby wygenerować polecenia. Ścieżki nie mogą zawierać segmentów . lub .. ani znaków sterujących.",
"pathPermissionsWebPrompt": "Podaj pełną ścieżkę katalogu witryny (inną niż /) oraz poprawną nazwę użytkownika usługi, aby wygenerować polecenia. Ścieżki nie mogą zawierać segmentów . lub .. ani znaków sterujących.",
"certificateReadNames": "Odczytaj nazwy z certyfikatu",
"certificateInspectReading": "Odczytywanie certyfikatu…",
"certificateInspectReady": "Odczytano nazwy DNS z certyfikatu.",
"certificateInspectKept": "Odczytano certyfikat; zachowano wpisane nazwy SNI.",
"certificateInspectNoNames": "Certyfikat nie zawiera nazw DNS w SAN.",
"certificateInspectFailed": "Nie można odczytać certyfikatu. Sprawdź plik i jego uprawnienia, a następnie ponów odczyt.",
"pathPermissionsOptions": "Opcje i pomoc"
},
"overviewLayoutNarrow": "Zmniejsz szerokość karty",
"overviewLayoutWiden": "Zwiększ szerokość karty",
+3
View File
@@ -139,6 +139,9 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
if not (root / path).is_file():
raise ValueError(f"Incomplete modular panel CSS source tree: missing {path}")
validate_panel_css_build(root)
for path in ("src/admin/web/app/path-permissions.js", "src/admin/web/app/certificate-inspection.js"):
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.34.10 path-permissions source tree: missing {path}")
if __package__:
from .build.panel_css import bundle
else:
+182
View File
@@ -0,0 +1,182 @@
#[tokio::test]
async fn certificate_inspection_reads_public_san_without_private_key_or_config_write() {
let (router, store, dir, secret, csrf) =
fixture_with_certificate_storage(Config::default()).await;
let certificate = rcgen::generate_simple_self_signed(vec![
"www.example.test".into(),
"*.example.test".into(),
"example.test".into(),
])
.unwrap();
std::fs::write(dir.path().join("certs/inspect.pem"), certificate.cert.pem()).unwrap();
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let config_before = json_body(
router
.clone()
.oneshot(
request("/api/config")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
)
.await;
let response = router
.clone()
.oneshot(
request("/api/certificates/inspect")
.method("POST")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(r#"{"cert_file":"inspect.pem"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
json_body(response).await,
json!({
"cert_file": "inspect.pem",
"covered_hosts": ["*.example.test", "example.test", "www.example.test"]
})
);
let config_after = json_body(
router
.oneshot(
request("/api/config")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
)
.await;
assert_eq!(config_before["revision"], config_after["revision"]);
assert_eq!(config_before["config"], config_after["config"]);
store.close().await;
}
#[tokio::test]
async fn certificate_inspection_rejects_traversal_non_pem_and_unknown_fields() {
let (router, store, dir, secret, csrf) =
fixture_with_certificate_storage(Config::default()).await;
std::fs::write(dir.path().join("certs/not-pem"), b"not a certificate").unwrap();
std::fs::write(
dir.path().join("certs/large.pem"),
vec![b'x'; 128 * 1024 + 1],
)
.unwrap();
for body in [
json!({"cert_file": "../db"}),
json!({"cert_file": "not-pem"}),
json!({"cert_file": "large.pem"}),
json!({"cert_file": "missing.pem"}),
json!({"cert_file": ""}),
json!({"cert_file": "inspect.pem", "key_file": "secret.key"}),
] {
let response = router
.clone()
.oneshot(
request("/api/certificates/inspect")
.method("POST")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNPROCESSABLE_ENTITY,
"{body}"
);
}
store.close().await;
}
#[cfg(unix)]
#[tokio::test]
async fn certificate_inspection_tracks_external_symlink_and_keeps_trust_checks() {
use std::os::unix::fs::{symlink, PermissionsExt};
let (router, store, dir, secret, csrf) =
fixture_with_certificate_storage(Config::default()).await;
let external = dir.path().join("external");
std::fs::create_dir(&external).unwrap();
let first = rcgen::generate_simple_self_signed(vec!["old.example.test".into()]).unwrap();
let second = rcgen::generate_simple_self_signed(vec!["new.example.test".into()]).unwrap();
std::fs::write(external.join("cert1.pem"), first.cert.pem()).unwrap();
std::fs::write(external.join("cert2.pem"), second.cert.pem()).unwrap();
let configured = external.join("fullchain.pem");
let inspect = || {
request("/api/certificates/inspect")
.method("POST")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(json!({"cert_file": configured}).to_string()))
.unwrap()
};
for (filename, expected) in [
("cert1.pem", "old.example.test"),
("cert2.pem", "new.example.test"),
] {
symlink(filename, &configured).unwrap();
let response = router.clone().oneshot(inspect()).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
json_body(response).await["covered_hosts"],
json!([expected])
);
std::fs::remove_file(&configured).unwrap();
}
symlink("cert2.pem", &configured).unwrap();
std::fs::set_permissions(&external, std::fs::Permissions::from_mode(0o770)).unwrap();
assert_eq!(
router.oneshot(inspect()).await.unwrap().status(),
StatusCode::UNPROCESSABLE_ENTITY
);
store.close().await;
}
#[tokio::test]
async fn certificate_inspection_is_not_a_certificate_token_operation() {
let (router, store, _dir, _, _) = fixture().await;
let response = router
.oneshot(
request("/api/certificates/inspect")
.method("POST")
.header("x-rustproxy-certificate-key", "external-hook")
.header("authorization", "Bearer external-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"cert_file":"test.pem"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
json_body(response).await,
json!({"error": "certificate_api.invalid_route"})
);
for permission in ["ro", "rw"] {
assert!(!general_api_token_allowed(
&Method::POST,
"/api/certificates/inspect",
permission
));
}
store.close().await;
}
+46
View File
@@ -1,3 +1,49 @@
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateInspection {
cert_file: String,
}
async fn certificate_inspect(
State(state): State<Arc<App>>,
Json(input): Json<CertificateInspection>,
) -> Result<Json<Value>, ApiError> {
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
ApiError(
StatusCode::CONFLICT,
"certificate storage is unavailable; configure the certificate directory".into(),
)
})?;
let permit = state
.certificate_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"certificate inspection queue is full".into(),
)
})?;
let cert_file = input.cert_file;
let (cert_file, metadata) = tokio::task::spawn_blocking(move || {
let _permit = permit;
crate::tls::certificate_metadata(&directory, &cert_file)
.map(|metadata| (cert_file, metadata))
})
.await
.map_err(|_| {
ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"certificate inspection task failed".into(),
)
})?
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
Ok(Json(json!({
"cert_file": cert_file,
"covered_hosts": metadata.covered_hosts,
})))
}
async fn certificate_deploy(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
+176 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.9",
"version": "0.34.10",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -2405,6 +2405,41 @@
"key_file"
]
},
"CertificateInspection": {
"type": "object",
"additionalProperties": false,
"required": [
"cert_file"
],
"properties": {
"cert_file": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"description": "Simple managed filename (up to 96 ASCII bytes) or absolute external certificate path (up to 4096 bytes). Reads only the public certificate; external paths retain root/service ownership, parent trust and file permission checks."
}
}
},
"CertificateInspectionResult": {
"type": "object",
"additionalProperties": false,
"required": [
"cert_file",
"covered_hosts"
],
"properties": {
"cert_file": {
"type": "string"
},
"covered_hosts": {
"type": "array",
"items": {
"type": "string"
},
"description": "Normalized, sorted, unique DNS SubjectAltName values from the leaf certificate. No Common Name fallback. Empty when the leaf has no DNS SAN names."
}
}
},
"CertificateUploadResult": {
"type": "object",
"properties": {
@@ -8834,6 +8869,146 @@
]
}
},
"/api/certificates/inspect": {
"post": {
"summary": "Read DNS names from a certificate file",
"tags": [
"Certificates"
],
"operationId": "post_api_certificates_inspect",
"description": "Browser-session-only read-only inspection for the manual certificate editor. Maximum request body 32 KiB; public PEM reads are limited to 128 KiB on a bounded blocking worker. Existing managed filenames or absolute external certificate paths are accepted with the same trust, owner, symlink and permissions checks as certificate inventory. Returns DNS SAN coverage without reading a private key, staging files, changing permissions or updating configuration. General API and certificate-scoped tokens cannot use this endpoint.",
"security": [
{
"AdminSession": [],
"CsrfToken": []
}
],
"parameters": [
{
"name": "Origin",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "Exact browser origin accepted by the panel origin policy."
},
{
"name": "X-RustProxy-Request",
"in": "header",
"required": true,
"schema": {
"type": "string",
"enum": [
"1"
]
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CertificateInspection"
}
}
}
},
"responses": {
"200": {
"description": "Public leaf certificate DNS names",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CertificateInspectionResult"
}
}
}
},
"400": {
"description": "Invalid request JSON",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Administrator session required or revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Host, origin or CSRF rejected; API token scope is not supported",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Certificate storage is not configured",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"413": {
"description": "Request exceeds the 32 KiB body limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"422": {
"description": "Invalid certificate path, PEM, file size, trust/permission policy or request fields",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Bounded certificate worker capacity exhausted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Certificate inspection worker failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/certificates/upload": {
"post": {
"summary": "Validate and stage a PEM pair",
+6
View File
@@ -57,6 +57,7 @@ fn admin_app_javascript() -> &'static str {
");",
include_str!("web/app/overview-samples.js"),
include_str!("web/app/core.js"),
include_str!("web/app/path-permissions.js"),
include_str!("web/app/session-resume.js"),
include_str!("web/app/refresh.js"),
include_str!("web/app/rate-sampling.js"),
@@ -74,6 +75,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/web-overview.js"),
include_str!("web/app/overview-masonry.js"),
include_str!("web/app/config-history.js"),
include_str!("web/app/certificate-inspection.js"),
include_str!("web/app/runtime.js"),
include_str!("web/app/settings.js"),
include_str!("web/management.js"),
@@ -168,6 +170,10 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/docker", get(docker_status))
.route("/api/docker/refresh", post(docker_refresh))
.route("/api/certificates", get(certificate_inventory))
.route(
"/api/certificates/inspect",
post(certificate_inspect).layer(DefaultBodyLimit::max(32 * 1024)),
)
.route(
"/api/basic-auth/hash",
post(basic_hash).layer(DefaultBodyLimit::max(4096)),
+10 -1
View File
@@ -980,6 +980,10 @@ mod tests {
"/api/certificates/upload",
r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#,
),
(
"/api/certificates/inspect",
r#"{"cert_file":"example.pem"}"#,
),
(
"/api/certificates/deploy",
r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#,
@@ -2298,6 +2302,7 @@ mod tests {
("/api/cache/cold/cleanup", "POST"),
("/api/cache/cold/cleanup/1", "GET"),
("/api/certificates/upload", "POST"),
("/api/certificates/inspect", "POST"),
("/api/certificates/deploy", "POST"),
("/api/certificates/reload", "POST"),
("/api/certificates/manual-file/reload", "POST"),
@@ -2985,7 +2990,10 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["content-type"], "text/css; charset=utf-8");
assert_eq!(
response.headers()["content-type"],
"text/css; charset=utf-8"
);
assert_eq!(response.headers()["vary"], "Accept-Encoding");
let bytes = axum::body::to_bytes(response.into_body(), css.len() + 1)
.await
@@ -3874,5 +3882,6 @@ mod tests {
));
}
include!("certificate_inspection_tests.rs");
include!("preferences_tests.rs");
}
@@ -0,0 +1,94 @@
"use strict";
(() => {
const form = $("certificate-form"), file = $("certificate-file"), source = $("certificate-file-source");
const names = $("certificate-hosts"), button = $("certificate-inspect"), status = $("certificate-inspect-status");
let timer = null, sequence = 0, namesRevision = 0, resetGeneration = 0, applying = false;
let manuallyEdited = !!names.value.trim(), pending = null, lastSuccess = "";
const identity = () => JSON.stringify([source.value, file.value.trim(), authEpoch]);
function validFile() {
const value = file.value.trim();
return source.value === "external"
? value.startsWith("/") && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value)
: value.length <= 96 && value !== "." && value !== ".." && /^[A-Za-z0-9_.-]+$/.test(value);
}
function message(key = "", detail = "") {
if (key && !detail) status.dataset.i18n = key;
else delete status.dataset.i18n;
status.textContent = key ? `${t(key)}${detail ? ` ${detail}` : ""}` : "";
status.classList.toggle("status-error", !!detail);
}
function refresh() { button.disabled = !validFile() || !!pending; }
function invalidate() {
clearTimeout(timer); timer = null; sequence++;
pending?.controller.abort(); pending = null; lastSuccess = "";
refresh();
}
function writeNames(value) {
names.value = value; applying = true;
try { names.dispatchEvent(new Event("input", { bubbles: true })); }
finally { applying = false; }
}
function reset({ preserveNames = false } = {}) {
invalidate(); manuallyEdited = preserveNames && !!names.value.trim(); namesRevision++; resetGeneration++;
message(); refresh();
}
async function read(force = false) {
clearTimeout(timer); timer = null;
if (!authenticated || !validFile()) { refresh(); return; }
const id = identity();
if (!force && pending?.id === id) return pending.promise;
if (!force && lastSuccess === id) return;
invalidate();
const current = sequence, epoch = authEpoch, revision = namesRevision, previousNames = names.value;
const certFile = file.value.trim(), controller = new AbortController();
message("certificateInspectReading");
const active = { id, controller, promise: null };
pending = active; refresh();
active.promise = (async () => {
try {
const result = await api("/api/certificates/inspect", {
method: "POST", body: JSON.stringify({ cert_file: certFile }), signal: controller.signal,
});
if (current !== sequence || epoch !== authEpoch || !authenticated || identity() !== id) return;
const hosts = Array.isArray(result.covered_hosts) ? result.covered_hosts.filter(host => typeof host === "string") : [];
lastSuccess = id;
const update = revision === namesRevision && names.value === previousNames && (force || !manuallyEdited);
if (update) { writeNames(hosts.join(", ")); manuallyEdited = false; }
message(!hosts.length ? "certificateInspectNoNames" : update ? "certificateInspectReady" : "certificateInspectKept");
} catch (error) {
if (current === sequence && epoch === authEpoch && authenticated && identity() === id && error.name !== "AbortError") {
message("certificateInspectFailed", error.message);
}
} finally {
if (pending === active) { pending = null; refresh(); }
}
})();
return active.promise;
}
function schedule() {
invalidate();
if (!manuallyEdited && names.value) writeNames("");
message();
if (validFile()) timer = setTimeout(() => { void read(); }, 400);
}
async function beforeSave() {
const generation = resetGeneration;
let id;
do { id = identity(); await read(); }
while (authenticated && generation === resetGeneration && id !== identity());
return generation === resetGeneration;
}
names.addEventListener("input", () => {
if (applying) return;
manuallyEdited = true; namesRevision++;
});
file.addEventListener("input", schedule);
file.addEventListener("change", () => { void read(); });
file.addEventListener("blur", () => { void read(); });
source.addEventListener("change", schedule);
form.addEventListener("reset", () => reset());
button.addEventListener("click", () => { void read(true); });
window.rustProxyCertificateInspection = { reset, schedule, read, beforeSave, refresh };
refresh();
})();
+164
View File
@@ -0,0 +1,164 @@
"use strict";
(() => {
// These are reviewable shell instructions only. The panel never executes them.
const views = new WeakMap();
const quote = value => `'${String(value).replaceAll("'", "'\"'\"'")}'`;
function absolutePath(value, directory) {
const raw = String(value || "");
if (/[\x00-\x1f\x7f]/.test(raw)) return null;
const path = raw.trim();
if (!path.startsWith("/") || path.length > 4096) return null;
if (path.split("/").some(part => part === "." || part === "..")) return null;
if (!directory && path.endsWith("/")) return null;
const normalized = path.replace(/\/{2,}/g, "/").replace(/\/$/, "");
return normalized && normalized !== "/" ? normalized : null;
}
function commands({ kind, paths = [], user = "rustproxy" } = {}) {
if (!["certificate", "web"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_.-]*\$?$/.test(user) || /[\x00-\x1f\x7f]/.test(user) || user.length > 64) return "";
const selected = paths.map(path => absolutePath(path, kind === "web"));
if (selected.length !== (kind === "web" ? 1 : 2) || selected.some(path => !path)) return "";
const lines = ["sudo sh <<'RUSTPROXY_ACL'", "set -eu", `rp_user=${quote(user)}`];
if (kind === "web") lines.push(`rp_root_path=${quote(selected[0])}`);
else lines.push(`rp_cert_path=${quote(selected[0])}`, `rp_key_path=${quote(selected[1])}`);
lines.push(
"command -v setfacl >/dev/null || { printf '%s\\n' 'Install the acl package (setfacl) first.' >&2; exit 1; }",
'id "$rp_user" >/dev/null',
"",
"rp_traverse() {",
' [ "$1" = / ] && return 0',
' rp_traverse "$(dirname -- "$1")" || return 1',
' rp_real_dir=$(readlink -e -- "$1") || return 1',
' if [ "$rp_real_dir" != "$1" ]; then rp_traverse "$rp_real_dir" || return 1; fi',
' if ! sudo -u "$rp_user" -- test -x "$1"; then',
' rp_access=1',
' if sudo -u "$rp_user" -- test -r "$1"; then rp_access=$((rp_access | 4)); fi',
' if sudo -u "$rp_user" -- test -w "$1"; then rp_access=$((rp_access | 2)); fi',
' rp_mode=$(stat -Lc %a -- "$1") || return 1',
' rp_mask=$(((0$rp_mode / 8 % 8) | rp_access))',
' setfacl -n -m "u:$rp_user:$rp_access,m::$rp_mask" -- "$1" || return 1',
" fi",
"}",
""
);
if (kind === "web") {
const grantWebTree = [
"set -eu",
"rp_user=$1; shift",
'for rp_path do',
' [ ! -L "$rp_path" ] || continue',
' rp_required=4',
' if [ -d "$rp_path" ]; then rp_required=5; elif [ ! -f "$rp_path" ]; then continue; fi',
' rp_mode=$(stat -Lc %a -- "$rp_path")',
' rp_old_mask=$((0$rp_mode / 8 % 8))',
' rp_mask=$((rp_old_mask | rp_required))',
' rp_entries="u:$rp_user:$rp_required,m::$rp_mask"',
' if [ "$rp_required" = 5 ]; then',
' rp_acl=$(getfacl -cp -- "$rp_path")',
' rp_default=$(printf "%s\\n" "$rp_acl" | awk -F: \'$1 == "default" && $2 == "mask" && $3 == "" {mask=substr($4,1,3)} $1 == "default" && $2 == "group" && $3 == "" {group=substr($4,1,3)} END {print (mask != "" ? mask : group)}\')',
' rp_default_mask=$rp_old_mask',
' if [ -n "$rp_default" ]; then',
' rp_default_mask=0',
' case "$rp_default" in r??) rp_default_mask=$((rp_default_mask | 4));; esac',
' case "$rp_default" in ?w?) rp_default_mask=$((rp_default_mask | 2));; esac',
' case "$rp_default" in ??x) rp_default_mask=$((rp_default_mask | 1));; esac',
' fi',
' rp_entries="$rp_entries,d:u:$rp_user:5,d:m::$((rp_default_mask | 5))"',
" fi",
' setfacl -n -m "$rp_entries" -- "$rp_path"',
"done"
].join("\n");
lines.push(
"command -v getfacl >/dev/null || { printf '%s\\n' 'Install the acl package (getfacl) first.' >&2; exit 1; }",
'rp_root=$(readlink -e -- "$rp_root_path")',
'[ "$rp_root" != / ] && [ -d "$rp_root" ] || { printf \'%s\\n\' \'Choose an existing website directory, not /.\' >&2; exit 1; }',
'rp_traverse "$(dirname -- "$rp_root_path")"',
'rp_traverse "$(dirname -- "$rp_root")"',
`find -P "$rp_root" \\( -type d -o -type f \\) -exec sh -c ${quote(grantWebTree)} rustproxy-web-acl "$rp_user" {} +`,
'sudo -u "$rp_user" -- test -r "$rp_root"',
'sudo -u "$rp_user" -- test -x "$rp_root"'
);
} else {
lines.push(
'rp_cert=$(readlink -e -- "$rp_cert_path")',
'rp_key=$(readlink -e -- "$rp_key_path")',
'[ -f "$rp_cert" ] && [ -f "$rp_key" ] || { printf \'%s\\n\' \'Certificate and key must be existing regular files.\' >&2; exit 1; }',
'chmod go-w -- "$rp_cert" "$rp_key"',
'chmod o-r -- "$rp_key"',
'rp_traverse "$(dirname -- "$rp_cert_path")"',
'rp_traverse "$(dirname -- "$rp_key_path")"',
'rp_traverse "$(dirname -- "$rp_cert")"',
'rp_traverse "$(dirname -- "$rp_key")"',
'setfacl -n -m "u:$rp_user:r--,m::r--" -- "$rp_cert" "$rp_key"',
'sudo -u "$rp_user" -- test -r "$rp_cert_path"',
'sudo -u "$rp_user" -- test -r "$rp_key_path"'
);
}
lines.push("RUSTPROXY_ACL");
return lines.join("\n");
}
function update(view) {
const { target, options, summary, help, accountLabel, account, output, empty, copy } = view;
target.hidden = options.enabled === false;
summary.textContent = t("pathPermissionsOptions");
help.textContent = t(options.kind === "web" ? "pathPermissionsWebHelp" : "pathPermissionsCertificateHelp");
accountLabel.textContent = t("pathPermissionsUser");
copy.textContent = t("pathPermissionsCopy");
output.setAttribute("aria-label", t("pathPermissionsTitle"));
output.textContent = target.hidden ? "" : commands({ ...options, user: account.value });
output.hidden = !output.textContent;
copy.disabled = !output.textContent;
empty.hidden = !!output.textContent;
empty.textContent = t(options.kind === "web" ? "pathPermissionsWebPrompt" : "pathPermissionsCertificatePrompt");
}
function render(target, options) {
if (!target) return;
let view = views.get(target);
if (!view) {
target.classList.add("path-permissions");
const actions = el("div", undefined, "form-actions path-permissions-actions");
const copy = el("button", undefined, "quiet");
copy.type = "button";
const details = el("details", undefined, "path-permissions-options");
const summary = el("summary", undefined, "fine");
const help = el("p", undefined, "fine");
const label = el("label", undefined, "path-permissions-account"), accountLabel = el("span"), account = el("input");
account.type = "text"; account.defaultValue = "rustproxy"; account.value = "rustproxy"; account.maxLength = 64;
account.autocomplete = "off"; account.spellcheck = false;
label.append(accountLabel, account);
const output = el("pre", undefined, "metrics-example path-permissions-commands");
output.tabIndex = 0;
const empty = el("p", undefined, "fine");
details.append(summary, label, help);
actions.append(copy, details);
target.replaceChildren(output, empty, actions);
view = { target, options, summary, help, accountLabel, account, output, empty, copy };
views.set(target, view);
account.addEventListener("input", () => update(view));
const selectOutput = () => {
output.focus();
const selection = window.getSelection?.();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(output);
selection.removeAllRanges(); selection.addRange(range);
};
copy.addEventListener("click", async () => {
try {
if (!output.textContent) return;
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(output.textContent);
else {
selectOutput();
if (!document.execCommand("copy")) throw new Error("clipboard unavailable");
}
toast(t("pathPermissionsCopied"));
} catch {
selectOutput();
toast(t("pathPermissionsCopyFailed"), true);
}
});
}
view.options = options;
update(view);
}
window.rustProxyPermissions = { commands, render };
})();
+54 -5
View File
@@ -7,6 +7,8 @@ function verifyAdminModules() {
["rustProxyTrafficPanel", "refresh"],
["rustProxyPreferences", "load"],
["rustProxyRefresh", "start"],
["rustProxyPermissions", "render"],
["rustProxyCertificateInspection", "read"],
];
const missing = required.find(([module, method]) => typeof window[module]?.[method] !== "function");
if (missing) throw new Error(`Panel resources incomplete (${missing[0]}). Reload to retry.`);
@@ -34,6 +36,12 @@ else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true
function certificateUsesExternalFiles(cert) {
return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/");
}
function syncCertificatePermissions() {
window.rustProxyPermissions?.render($("certificate-external-acl-example"), {
kind: "certificate", paths: [$("certificate-file").value, $("certificate-key").value],
enabled: $("certificate-file-source").value === "external",
});
}
function syncCertificateFileSource() {
const external = $("certificate-file-source").value === "external";
const cert = $("certificate-file"), key = $("certificate-key");
@@ -47,9 +55,14 @@ function syncCertificateFileSource() {
const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp";
$("certificate-file-source-help").dataset.i18n = helpKey;
$("certificate-file-source-help").textContent = t(helpKey);
$("certificate-external-acl-example").hidden = !external;
syncCertificatePermissions();
window.rustProxyCertificateInspection?.refresh();
}
$("certificate-file-source").addEventListener("change", syncCertificateFileSource);
for (const id of ["certificate-file", "certificate-key"]) {
$(id).addEventListener("input", syncCertificatePermissions);
$(id).addEventListener("change", syncCertificatePermissions);
}
function renderCertificates() {
if (!envelope) return;
const settings = envelope.config.tls || {};
@@ -148,7 +161,15 @@ function renderCertificates() {
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
} catch (error) { toast(error.message, true); } finally { busy = false; }
}));
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed"; syncCertificateFileSource(); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
buttons.append(action(t("edit"), () => {
window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true;
$("certificate-id").value = cert.id; $("certificate-hosts").value = (cert.hosts || []).join(", ");
$("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed";
$("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file;
$("certificate-default").checked = settings.default_certificate === cert.id;
window.rustProxyCertificateInspection?.reset({ preserveNames: true });
syncCertificateFileSource(); window.rustProxyCertificateInspection?.schedule();
}));
}
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
@@ -157,8 +178,38 @@ function renderCertificates() {
});
$("certificate-list").replaceChildren(...entries);
if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty"));
syncCertificatePermissions();
}
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
$("certificate-form").addEventListener("submit", async event => {
event.preventDefault(); const form = event.currentTarget;
if (!envelope || busy) return;
try {
await withFormSubmitState(form, event.submitter, async () => {
const epoch = authEpoch;
if (await window.rustProxyCertificateInspection?.beforeSave() === false) return;
if (!authenticated || epoch !== authEpoch || !envelope || busy) return;
const next = structuredClone(envelope.config);
next.tls ||= {}; next.tls.certificates ||= [];
const cert = {
id: $("certificate-id").value.trim(),
hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean),
cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim(),
};
const index = next.tls.certificates.findIndex(v => v.id === cert.id);
if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly"));
if (index < 0) next.tls.certificates.push(cert);
else {
const old = next.tls.certificates[index], sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file;
next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null };
}
if ($("certificate-default").checked) next.tls.default_certificate = cert.id;
else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate;
if (await saveSection("tls", next.tls)) {
form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close();
}
});
} catch (error) { toast(error.message, true); }
});
$("reload-certificates").addEventListener("click", async () => {
if (!envelope || busy) return;
try {
@@ -331,8 +382,6 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
}
}
// Cumulative process counters, not rate-sampler deltas. Never invent OTHER
// or force a 100% mix if independently read totals are inconsistent.
function cacheOutcomeMix(totals) {
if (!Array.isArray(totals) || totals.length !== 4 ||
totals.some(value => !Number.isSafeInteger(value) || value < 0)) return null;
+27
View File
@@ -1478,3 +1478,30 @@ html[data-table-density=ultra] :is(table th, table td) {
min-height: var(--button-min-height);
align-items: center;
}
/* Keep generated ACL instructions in the original metrics-example code style. */
.path-permissions {
min-width: 0;
}
.path-permissions-commands {
max-height: 180px;
}
.path-permissions-actions {
margin-top: 0;
}
.path-permissions-options>summary {
cursor: pointer;
}
.path-permissions-options[open] {
flex-basis: 100%;
}
.path-permissions-account {
display: grid;
gap: 6px;
max-width: 260px;
}
+2 -1
View File
@@ -553,7 +553,8 @@ p,
.cache-operation-dialog .card,
.file-log-search-help-popover p,
.file-logs-table-toolbar .fine,
.cache-maintenance-help .subheading {
.cache-maintenance-help .subheading,
.path-permissions p {
margin: 0;
}
+5 -2
View File
@@ -1093,14 +1093,16 @@
</select>
<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
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
placeholder="app.pem" required="" />
<label data-i18n="privateKeyFile" for="certificate-key"></label><input
id="certificate-key" maxlength="96" pattern="[A-Za-z0-9_.-]+"
placeholder="app.key" required="" />
<div hidden id="certificate-external-acl-example"></div>
<div class="form-actions"><button type="button" class="quiet"
id="certificate-inspect" data-i18n="certificateReadNames"></button></div>
<p class="fine" id="certificate-inspect-status" role="status" aria-live="polite"></p>
<label class="check"><input id="certificate-default" type="checkbox" /><span
data-i18n="defaultCertificate"></span></label>
<div class="form-actions"><button class="primary"
@@ -3609,6 +3611,7 @@
required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
<div class="vh-wide" hidden id="web-service-permissions"></div>
<label class="vh-wide"><span data-i18n="webServiceIndex">Index files</span><textarea
id="web-service-index" maxlength="4096" rows="5" spellcheck="false"></textarea><small
class="fine" data-i18n="webServiceIndexHelp">Optional ordered index list. index.php may be
+10 -3
View File
@@ -2,6 +2,9 @@
(() => {
let editing = null, dirty = false;
const form = $("web-service-form"), dialog = $("web-service-dialog");
function syncRootPermissions() {
window.rustProxyPermissions?.render($("web-service-permissions"), { kind: "web", paths: [$("web-service-root").value] });
}
const lines = value => value.split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
const strictLines = value => value.split(/\n+/).map(item => item.trim()).filter(Boolean);
const mimeLines = value => Object.entries(value || {}).map(([extension, mime]) => `${extension} = ${mime}`).join("\n");
@@ -276,6 +279,7 @@
syncCompression();
syncPrecompressed();
syncHtaccess();
syncRootPermissions();
}
function updatePresetSummary() {
@@ -335,22 +339,23 @@
dialog.showModal();
}
function clearEditor() { editing = null; dirty = false; form.reset(); if (dialog.open) dialog.close(); }
function clearEditor() { editing = null; dirty = false; form.reset(); syncRootPermissions(); if (dialog.open) dialog.close(); }
function suspendDraft() {
const activeTab = $("web-service-tabs").querySelector("button.active")?.dataset.tab || "general";
const state = { editing, activeTab, label: $("web-service-editing").textContent, controls: window.rustProxyDrafts.captureControls(form) };
const title = $("web-service-id").value.trim() || $("web-service-name").value.trim() || editing || t("draftUntitled");
editing = null; dirty = false; form.reset(); dialog.close(); return { title, state };
editing = null; dirty = false; form.reset(); syncRootPermissions(); dialog.close(); return { title, state };
}
function restoreDraft(state) {
const saved = state.editing ? (envelope?.config.web_services || []).find(item => item.id === state.editing) : null;
open(saved ? structuredClone(saved) : (state.editing ? { id: state.editing } : null));
window.rustProxyDrafts.restoreControls(form, state.controls); editing = state.editing; $("web-service-editing").textContent = state.label;
syncPhp(); syncPrecompressed(); syncHtaccess(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
syncPhp(); syncPrecompressed(); syncHtaccess(); syncRootPermissions(); updatePresetSummary(); activateTab(state.activeTab || "general"); dirty = true;
}
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("web-server"); clearEditor(); return true; }
function render() {
syncRootPermissions();
if (!envelope) return;
const query = ($("web-service-search")?.value || "").trim().toLowerCase();
const services = (envelope.config.web_services || []).filter(service => !query || [service.id, service.name, service.document_root].join(" ").toLowerCase().includes(query));
@@ -530,6 +535,8 @@
initTabs();
form.addEventListener("input", () => { dirty = true; });
form.addEventListener("change", () => { dirty = true; });
$("web-service-root").addEventListener("input", syncRootPermissions);
$("web-service-root").addEventListener("change", syncRootPermissions);
$("web-service-php-enabled").addEventListener("change", syncPhp);
$("web-service-php-profile").addEventListener("change", syncPhp);
$("web-service-php-profiles").addEventListener("click", openProfiles);
+3 -1
View File
@@ -104,7 +104,9 @@ fn read_pem_with_policy(
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
// Reject special files below without blocking while opening a FIFO.
// O_NONBLOCK has no effect on ordinary certificate/key files.
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
let file = options
.open(path)
+18
View File
@@ -127,3 +127,21 @@ fn external_paths_reject_writable_non_sticky_parent_directories() {
fs::write(&cert, b"dummy").unwrap();
assert!(configured_certificate_path(root.path(), cert.to_str().unwrap()).is_err());
}
#[cfg(unix)]
#[test]
fn certificate_input_rejects_fifo_without_waiting_for_a_writer() {
use std::os::unix::ffi::OsStrExt;
let root = tempfile::tempdir().unwrap();
let fifo = root.path().join("certificate.pipe");
let path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
let (send, receive) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = send.send(read_pem_with_policy(&fifo, false, false).is_err());
});
assert!(receive
.recv_timeout(std::time::Duration::from_secs(2))
.expect("certificate reads must reject a FIFO without blocking"));
}