This commit is contained in:
Mateusz Gruszczyński committed 2026-10-12 14:57:37 +02:00
1 parent 180a0e4877
commit d23fa5e154
39 files changed
+1521 -376

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.
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.31.8.
compatibility. Current source version: 0.31.9.
## Delivery
+7
View File
@@ -3,6 +3,13 @@
> Operator-facing release history. Historical engineering notes are kept in
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.31.9
- Add per-credential **View settings**, **Edit settings** and **Regenerate token** actions to Settings → Certificate API. Read and edit return non-secret metadata only; Key ID remains stable, and editing comment/certificate allowlist/deploy permission does not rotate the existing bearer token.
- Add administrator-only `GET /api/certificate-api/{key_id}`, `PATCH /api/certificate-api/{key_id}` and `POST /api/certificate-api/{key_id}/regenerate`. Regeneration atomically updates only the stored hash and rotation/usage timestamps, preserving the grant and showing the new random secret only in the regeneration response. The old secret becomes invalid immediately.
- Keep existing login/session/CSRF protections and external Certificate API authorization strictly limited to targeted reload and explicit deploy permissions. Include OpenAPI, English/Polish UI strings and regression tests for secret non-disclosure and token invalidation.
- No database schema or proxy transport change. Cargo/runtime acceptance remains required on a Rust 1.99+ build host.
## 0.31.8
- Consolidate 28 numbered Rust unit-test fragments into seven existing `tests.rs` suites (Admin, Balance, Config, Web Server and three Cache suites). Preserve the tests, remove obsolete fragment directories, and exempt Rust test sources (not production modules) from the 500-line check. Fix the test fixture's relative `login-keys.json` include path. No runtime functionality changes.
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.31.8"
version = "0.31.9"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.31.8"
version = "0.31.9"
edition = "2021"
rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration"
+10 -3
View File
@@ -1,11 +1,11 @@
# RustProxy 0.31.8
# RustProxy 0.31.9
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration,
streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
**0.31.8 Stored Logs sorting, consistent built-in status pages and scoped memory optimizations (Rust/Cargo and live testing required).**
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
## Layer-4 streams
@@ -13,7 +13,14 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a
Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
## What changed in 0.31.8
## What changed in 0.31.9
- **Settings → Certificate API:** view the saved non-secret configuration of any Key ID, including comment, allowed certificate IDs, reload/deploy permission and creation/last-use timestamps. Current bearer tokens cannot be retrieved.
- **Edit settings:** change a credential's comment, certificate allowlist and deploy permission without replacing its bearer token, Key ID, creation timestamp or last-use timestamp. Existing automation can continue with the current secret.
- **Regenerate token:** replace only the selected credential's token, keep its settings, immediately invalidate the old secret and display the new token once. New per-Key-ID endpoints: `GET` and `PATCH /api/certificate-api/{key_id}`, `POST /api/certificate-api/{key_id}/regenerate`. Administrator session and CSRF are required for writes; external automation bearer tokens cannot call the management endpoints.
- No SQLite migration or public proxy change. Existing `PUT /api/certificate-api` creation/manual rotation, revocation and token grants remain compatible. Verify with Rust 1.99+ before production use.
## Previously in 0.31.8
- **Test organization:** merge 28 mechanically numbered Rust test fragments into seven coherent `tests.rs` suites. Test-only files are exempt from the 500-line production-module limit; no test cases or runtime behavior are intentionally changed. See [test report](docs/TEST_REPORT.md).
- **Stored Logs:** click a desktop column heading to sort ascending/descending (including discovered nested JSON columns). Sorting applies to the entire bounded server-side matching scan **before pagination**, with missing values last. Direction and selected sort column persist per administrator. Existing filters, mobile cards, and repeated-value analysis are unchanged.
+8
View File
@@ -1,5 +1,13 @@
# RustProxy roadmap
## 0.31.9 - Certificate API credential management (runtime acceptance pending)
- [x] Inspect individual saved Certificate API credential metadata without reading its bearer secret.
- [x] Edit comment, explicit certificate allowlist and deploy grant independently of token rotation.
- [x] Regenerate only a selected credential's token while keeping Key ID, comment and grants, invalidating the old secret and showing the replacement only once.
- [x] Preserve existing scopes, administrator-only access, audit trail, schema 9 and backwards-compatible creation/revocation endpoints.
- [ ] Compile and execute Rust regression tests with Rust 1.99+; verify end-to-end panel flows on a real server before production acceptance.
## 0.31.8 - Stored Logs sorting, built-in pages and allocation reduction (runtime acceptance pending)
- [x] Consolidate 28 numbered Rust test fragments into seven large `tests.rs` files; exempt test sources from the 500-line production limit and remove obsolete fragment paths.
+38 -34
View File
@@ -1,18 +1,18 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
6ea926f3894f548532eaec87bbe4ef03cacbb9bc49d81331af11d14d49f8b3fa AGENTS.md
763041714a5be537d3d1bcd4cf393600228807706924855ccfd9663a8af5d403 CHANGELOG.md
427b7b7bffadc0440a8d3400565b528153800825c0cfc3951f6c67c33a64ae1d Cargo.lock
83a13d441c530b0a120beb45a78381b53153568f3b6c1289e5922a3a0a7383ad Cargo.toml
fa9e3f1bf83ebe02e7c1b60e014e4e0d05d2a9d9a319715ebca3fff4677a6b28 AGENTS.md
2347fb7dedb9d9fbb9fd46d75ee8df9cac2aa6c9f63299329b4a86d6700e4d7c CHANGELOG.md
951711fd4a30d9514f7872fb0a31728af9c33c1d7148e357dc96b266becc23b5 Cargo.lock
86002aec92d8f486e50d115fdedcaf860a8a88db60465fc49deceeadabf6b877 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
a1a145015876773f43efef010a6d1e48819e7d1ac5a61b9ef6b466ab1221ec8c README.md
9e38c8a5b4f011fcd26f467ebcbdc59658b45fa9da1622ebf7704fdb335d11ba ROADMAP.md
c92e6406addb5c9b75f93c43fef0189a501a6f64c5a4fe26e4225b49e0d86285 README.md
8d136174ae003dacb0693d0f56df93eb8c17c320feb1b9890892df8cae162d43 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
1a8fe0ebb42fe780831edbda10b6e122b70ee546598dc80a13ec4cde5d516e03 dev.sh
80dad45d29aac750fbc7569a73d7ca53ac497961dec32a08f94fca53a1f3a914 dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
@@ -27,8 +27,8 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
e25ce97c6d1d75f0cab162eee28c1cb77561b210ad36ba7189d81328c3bb4d6c docs/API.md
b0c6fae1d538f33fb468a00307cbb745a601d9fc341c0a4f859d60c7e8d48865 docs/API_ACCESS.md
4d4c767eb13805c9213cebfa51ddce0d881c6b60ee4ed59d93476163f30b49bd docs/API.md
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md
e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
@@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
7f27d782534455eebce538f10c902346f7d194acd75b6106c7f8dec3c44857e9 docs/SETTINGS.md
07517c8bf788b1560f760d7e17b2f20027a57098156056ff4be7b1aacf2d6f9b docs/SETTINGS.md
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
e97895472f909bd6dcbb0643c9f5d3b7c3eb229c81b53487807265faef6fff62 docs/TEST_REPORT.md
a1cc550782cb0fa488580768070c799895f401971390161d07a4f9e92a3d9e5b docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
@@ -95,8 +95,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
4106dd653c2968b4a20b18ce629a976b7e58cf9dcd5d6d9938855ec6ef005efc langs/en_US.json
a2fa657001729c02a073fa962f37d5025be05b206e098e08a3f177453fc2ab0b langs/pl_pl.json
5c17d25127a690d925a96ae37e661763c7c9936269e0fb368000875b29b38661 langs/en_US.json
97aba663b64f7d38869c00170adfa097fa43ab5354f208f12a2fe9d35a6959d8 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -138,6 +138,7 @@ b9e57db4bd80ef37564dfc533a96897d8a494671d53acea9fb4bf28d23ba370d src/acme/provi
f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs
e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs
b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs
b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs
958546c43c05bf4bed8121b24db3b3096f9243f3c56a71e1762592b755684013 src/admin/documentation/documents.rs
@@ -147,9 +148,9 @@ ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
6e5c18e8d9db9a52f3f0c3989efc41552251f238a6eaee87a19908495da2eb87 src/admin/openapi.json
0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
1a49462c34150005759d4824264c72491f0431251af3909620f98c73a3a788bf src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
d8bad220bf42663be3355d0a4b8ea9cede1c425ba6c6e0d961c59cd16ff7f55a src/admin/preferences.rs
@@ -157,13 +158,13 @@ d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/pref
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs
e41f1e4276f3f00db6caff2738fc01c949ace3aca468e4de82cbeecd2ae58276 src/admin/router.rs
8d48e4b7818562159d5c1418e242b9425cd4074c7d4611ef36f6b096f5d92132 src/admin/router.rs
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
f2d56dfa0e03ca54077124842e66874a510415b95206f91a655eba4430b2c03e src/admin/tests.rs
f047f6eee5d219c4324422332d9e1e7749e3fab845945a864badd97badb3075e src/admin/tests.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
d8a06511406d6ab974a6bf6cb5da35a1eeb4031144ef462c5390f9d0954fa059 src/admin/web/app/core.js
@@ -173,6 +174,7 @@ d8a06511406d6ab974a6bf6cb5da35a1eeb4031144ef462c5390f9d0954fa059 src/admin/web/
db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/app/session-resume.js
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
d01a2e48f344fe074c1e63ee0a9bbf7151dfe26140690418535197e13a2a16d7 src/admin/web/docker.js
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
@@ -194,17 +196,17 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
a065af0a331a953dc51c72f0f589023cf8a917cda5baa5308dd0d8c105ec3d91 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
4740b0fe963604528db10f0beffca5c43f52a8fab923c71f31c4cdfdea446e9a src/admin/web/index.html
1e736b63c322b13627ee139399016df77d9cb2754c819a76755f2d2b52fd1046 src/admin/web/index.html
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js
136c093a907825e0d942c06186fffc834936b60dd6408bcafe81092cff7e3c64 src/admin/web/management.js
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
2936ef4ea7d56edb878758ddcaeed7f55fcfe57044c82eacab7d7126c0ba1b44 src/admin/web/settings.js
2efa65f4112ea5523bbb5583c6e320c93f0cfed520a0bc5cf4c22fb51b123649 src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
a616a18259ae03b9161d3e672f24023e6470ed207ac81b0734cff8060322b800 src/admin/web/style.css
5dd0099b6fdbf741dcd088f93833be5f7f2b5d66f8fc884aa788c6cf9890a639 src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js
00f59a6f656e44c364ed3c8eed33ce1df878a488908b30a9523a87b64f50f9ef src/admin/web/vhosts/core.js
@@ -283,7 +285,7 @@ f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/rou
818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs
b20dea6e67919379ac56f64c852e05082974300c183e7b5884e70db68f89ce5f src/config/tests.rs
c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs
7fadb86f6a61dd0127dd68c115be36865f0b2af535510f4c8f9aff6c9b8cdbfe src/db/certificate_api.rs
30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs
19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs
@@ -474,40 +476,42 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs
2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs
5afffaa2b7f96130565e11e1405d97787086438ec1a57647d8757fcf5cd1fa15 tools/checks/check_source.py
5c3d7278de6a4f3ab81908b89f1581f13fa22295714b2ba6651312c49ee9b5a7 tools/checks/certificate_api_0319.py
526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs
882bbba56046d62f279d725868ad5794dbd1e0214ba4863d71083e44f923be46 tools/checks/check_source.py
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
c75a529827c2bc3e39bfe0bd0e7e9f6e8a6f4f08982edadc05e1a10494f33ace tools/checks/dependency_upgrade_0312.py
6c1ea212b9a60215739a5f2cbeb75487aaf51bbd90cc58714ce287ed5bc21267 tools/checks/docker_startup_0318_ui_test.mjs
9037865daab1bf206ff0f06f6a98bb385691c1b7e87293b6d2f4bd4fc014454f tools/checks/docs_check.py
9039136a8e40537546ee3e5c1e1e1144d9903ef3e770c02df1ca6cd8c0092760 tools/checks/dependency_upgrade_0312.py
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
3bf519557c229285aac5078c0049d53b8dd0c6161d7eac220b0a2ac2ac93dc10 tools/checks/docs_check.py
6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs
83c74f0027a6d2f51f637d552593f9904e60ccd69665f6460136c48b159afb60 tools/checks/h3_cache_mobile_0315_test.mjs
c3128f6b2e668c92b3026bddd4590d8df01f555f011d0dcfe19d46829b6e8753 tools/checks/h3_navigation_0316.py
d7e22227c9f159eb4129ecbeb5a036f7445f5848b0153dfca1df54aeacc6d233 tools/checks/h3_navigation_0316.py
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs
8c6bf085d39da0a27aa4ddab7322aa19e19966dd1e18732e04f54d676d6109e4 tools/checks/preflight.py
eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py
021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
683e70df42c5c9577c0db5c7c4e94b25ee4654265525c3d4d9d1e471a914e5df tools/checks/release_0318.py
1d2f2f3adaaa1f8576fcf97126335c4dcfbf6f4c30dede5f7e0c187241e9ea0e tools/checks/release_0318_ui_test.mjs
5825b872b7000daae2c345bb95d755d3beaca5fc3dada07595cff73d447c7dc7 tools/checks/release_smoke.py
399a926877c225b9564dd447f8889bffb6092c0a1ab9a1f187137ecdfb59f566 tools/checks/release_0318.py
e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs
1c1ae5ac6447524a56bd3db4bd5f14457d8052a3d0dde18c5e221a632f5e645d tools/checks/release_smoke.py
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
325ff424200a908395a2f77c87bda3cbc4ed57509c4927af590911b3ce639ac3 tools/checks/security_logs_0315_ui_test.mjs
32574fd9d1abdd72764a9a9a425b410a194a666fef72652fcfa78ff307ce7b3e tools/checks/security_logs_0315_ui_test.mjs
70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
3e86bbe244fad73adc653918f2386815bdddbfc8084a5a19ea17f271681e92eb tools/checks/system_info_0301_ui_test.mjs
28c55ffdd4a1762217a32b6595ba67f5c23dce9261a25926854d9bbfb22b1602 tools/checks/system_info_0301_ui_test.mjs
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
c6777d83976f021d689857a0d53a9a30f4a4dafdf68cdcc672dd995279b09d1f tools/checks/web_assets.py
7aeef339092ab07aa0d906fce223ff34ecc1ee8dbd6b3f173d94c521a79558ac tools/checks/web_assets.py
618f9c6b20c043d151eac319d1712ec50358ae86b2e7fa23418c8e4bbcc45963 tools/checks/web_server_contracts.py
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs
+2
View File
@@ -93,6 +93,7 @@ if [[ "$mode" == "test" ]]; then
python3 tools/checks/h3_navigation_0316.py
python3 tools/checks/release_0317.py
python3 tools/checks/release_0318.py
python3 tools/checks/certificate_api_0319.py
python3 tools/checks/source_pruning_0317.py
python3 tools/checks/system_info_no_subprocess_contracts.py
python3 tools/checks/source_module_contracts.py
@@ -122,6 +123,7 @@ if [[ "$mode" == "test" ]]; then
node tools/checks/release_0317_ui_test.mjs
node tools/checks/release_0318_ui_test.mjs
node tools/checks/docker_startup_0318_ui_test.mjs
node tools/checks/certificate_api_0319_ui_test.mjs
node tools/checks/h3_cache_mobile_0315_test.mjs
node tools/checks/panel_cache_http_0310_ui_test.mjs
node tools/checks/vhost_origin_errors_0297_ui_test.mjs
+13 -1
View File
@@ -463,7 +463,19 @@ curl --fail --show-error -X POST \
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.
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.
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; `GET /api/certificate-api/{key_id}` returns a single credential's **non-secret** settings (Key ID, comment, created/last-used timestamps, allowed certificate IDs and deploy grant); `PATCH /api/certificate-api/{key_id}` edits the comment/grants without changing or returning the bearer token; and `POST /api/certificate-api/{key_id}/regenerate` creates a fresh token for the same Key ID, immediately invalidates the old token, retains metadata/grants and returns the new token exactly once. The original `PUT /api/certificate-api` still creates/rotates credentials, `DELETE /api/certificate-api/{key_id}` revokes one and `DELETE /api/certificate-api` revokes all. **Only administrator sessions** may use these management endpoints. Writes require the existing CSRF, origin and request-marker checks, including regeneration. Legacy wildcard grants cannot be saved in `PATCH`; replace them with explicit IDs first.
Example session-authenticated metadata edit (existing token stays valid; the `token` field is deliberately forbidden):
```json
{
"comment": "renewal hook on edge-02",
"certificate_ids": ["shared-cert", "backup-cert"],
"allow_deploy": false
}
```
Submit this JSON to `PATCH /api/certificate-api/certbot-main` with the normal administrator cookie + CSRF/Origin headers. Regenerate through `POST /api/certificate-api/certbot-main/regenerate` with the same session controls and securely record the one-time `token` response.
Example external deploy for a token that has **Also allow deploy** enabled and contains `shared-cert` in its allowlist:
+2
View File
@@ -108,6 +108,8 @@ A reload-only credential authorizes only:
- `POST /api/certificates/{certificate_id}/reload` when that ID is allowlisted.
Certificate API credential inspection (`GET /api/certificate-api` or `GET /api/certificate-api/{key_id}`) is likewise administrator-session only and returns no hashes/secrets. `PATCH /api/certificate-api/{key_id}` changes metadata/grants without touching the secret; `POST /api/certificate-api/{key_id}/regenerate` replaces the hash and invalidates the old token. Both writes require session + CSRF/Origin/request-marker protection. The replacement secret is revealed only once in the regeneration response. External Key ID + Bearer clients are not authorized to call any credential-management endpoint.
`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.
+10
View File
@@ -12,6 +12,16 @@ The Settings page includes a local search field that filters category tabs using
Unfinished Web Server, Vhost, Redirect and Stream forms use a session-only draft dock. Closing a dirty editor minimizes its exact browser state to the bottom-right dock so another editor can be opened independently. The dock follows the square-corner panel styling of the dashboard and intentionally avoids rounded/glass presentation. Drafts are intentionally not written to SQLite because form state can contain credentials or other secrets; they are discarded on logout/session reset.
## Certificate API credentials (0.31.9)
Open **Settings → Certificate API** (`/settings?tab=certificate-api`) to manage external certificate automation credentials. The credential list now has **View settings**, **Edit settings**, **Regenerate token** and **Revoke** actions. The preview shows Key ID, non-secret comment, allowed certificate IDs, reload-only/deploy scope and creation/last-use times. Existing bearer token values cannot be viewed: only hashes are stored.
**Edit settings** loads the current credential into a form with fixed Key ID. You may update its comment, list of 1–32 explicit certificate IDs, and **Also allow deploy** permission. **Save settings** changes only this metadata; the current secret stays valid and is never shown or replaced. Legacy `*` certificate grants must be replaced with explicit IDs before saving an edit.
**Regenerate token** requires confirmation. It generates a new random bearer token, preserves the Key ID/comment/permissions/allowed IDs, immediately invalidates the old token and reveals the new token only once. Copy it into the external automation before closing the page. Regeneration updates the token's creation/rotation timestamp and clears last-used metadata. The same existing session + Origin + CSRF checks protect both operations; bearer-only external clients cannot read or change credentials.
The existing **Generate / rotate token** form still creates new Key IDs (and retains its previous ability to rotate a Key ID by supplying it). Use **Edit settings** to modify settings without rotating, and **Regenerate token** to rotate without changing any settings. See [API](API.md) and [access control](API_ACCESS.md).
## Refresh preferences (0.31.0)
**Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values:
+9 -1
View File
@@ -1,4 +1,12 @@
# Verification report - RustProxy 0.31.8
# Verification report - RustProxy 0.31.9
## 0.31.9 — Certificate API credential management (runtime acceptance open)
- **Implemented:** read-only detail for a Key ID, metadata-only edit retaining the current bearer hash and usage/creation timestamps, and explicit per-Key-ID regeneration replacing the hash and resetting last-used time while retaining permissions and comment. Existing scoped reload/deploy and session/CSRF rules remain unchanged. API responses from GET/PATCH never expose existing tokens or hashes; the new secret is returned only after regeneration.
- **Added verification:** Rust admin route regression covers status, edit vs. rotation, authorization, invalid scopes, old/new token authentication and audit actions; a focused Node panel/API simulation and OpenAPI/locale/source contract test cover UI/API wiring. Rust compilation and real runtime acceptance remain pending until the Rust 1.99+ toolchain is available.
- **Compatibility:** no SQLite schema migration, no existing token changes on upgrade, no public proxy/listener changes. Legacy wildcard grants remain valid for reads and regeneration; editing requires a new explicit certificate ID allowlist.
- **Checks executed:** focused Certificate API Python source/OpenAPI/locale and Node actual-module/UI-simulation checks **PASS**; Node browser module syntax, OpenAPI JSON parsing, shell syntax, `docs_check.py`, `langs_check.py` and offline Go agent tests **PASS**. The `./dev.sh test` pre-Cargo source/schema/upgrade/security/memory/load/Node stages printed **PASS** through documentation checks, but the enclosing execution timed out before its final result. The language check passed independently. No live RustProxy binary was run.
- **Open release gate:** this environment has neither `cargo` nor `rustc`; compile/check/unit tests and authenticated live browser/HTTP verification remain necessary on a Rust-equipped host.
## 0.31.8 — implementation pending runtime acceptance
+14
View File
@@ -1708,6 +1708,20 @@
"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.",
"certificateApiRotate": "Generate / rotate token",
"certificateApiView": "View settings",
"certificateApiHide": "Hide settings",
"certificateApiEdit": "Edit settings",
"certificateApiSave": "Save settings",
"certificateApiSaved": "Certificate API settings saved. The current token is unchanged.",
"certificateApiCancelEdit": "Cancel edit",
"certificateApiEditNote": "Edit comment, permissions and allowed certificate IDs without changing the token. The Key ID cannot be changed.",
"certificateApiRegenerate": "Regenerate token",
"certificateApiRegenerateConfirm": "Regenerate the token for {key}? The current token will immediately stop working. Copy the new token after regenerating.",
"certificateApiRegenerated": "Token regenerated. Copy the new token now; the previous token is no longer valid.",
"certificateApiCreatedAt": "Created / last rotated",
"certificateApiPermissions": "Permissions",
"certificateApiTokenLabel": "Bearer token",
"certificateApiTokenHidden": "Unavailable (stored as a hash). Regenerate to obtain a new token.",
"certificateApiRevoke": "Revoke token",
"certificateApiOneTimeToken": "Copy this token now",
"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.",
+14
View File
@@ -1708,6 +1708,20 @@
"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.",
"certificateApiRotate": "Generuj / obróć token",
"certificateApiView": "Podgląd ustawień",
"certificateApiHide": "Ukryj ustawienia",
"certificateApiEdit": "Edytuj ustawienia",
"certificateApiSave": "Zapisz ustawienia",
"certificateApiSaved": "Ustawienia API certyfikatów zapisane. Dotychczasowy token pozostał bez zmian.",
"certificateApiCancelEdit": "Anuluj edycję",
"certificateApiEditNote": "Możesz zmienić komentarz, uprawnienia i dozwolone ID certyfikatów bez zmiany tokenu. Key ID pozostaje bez zmian.",
"certificateApiRegenerate": "Regeneruj token",
"certificateApiRegenerateConfirm": "Regenerować token {key}? Poprzedni token natychmiast przestanie działać. Po regeneracji skopiuj nowy token.",
"certificateApiRegenerated": "Token zregenerowany. Skopiuj go teraz — poprzedni token został unieważniony.",
"certificateApiCreatedAt": "Utworzony / ostatnio zmieniony",
"certificateApiPermissions": "Uprawnienia",
"certificateApiTokenLabel": "Bearer token",
"certificateApiTokenHidden": "Niedostępny (zapisany wyłącznie hash). Regeneruj, aby uzyskać nowy token.",
"certificateApiRevoke": "Unieważnij token",
"certificateApiOneTimeToken": "Skopiuj ten token teraz",
"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.",
+245
View File
@@ -0,0 +1,245 @@
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiRotate {
#[serde(default)]
key_id: Option<String>,
#[serde(default)]
token: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
certificate_ids: Vec<String>,
#[serde(default)]
allow_deploy: bool,
}
fn valid_certificate_api_comment(value: &str) -> bool {
value.len() <= 256 && !value.chars().any(char::is_control)
}
fn certificate_api_status_value(values: Vec<crate::db::CertificateApiCredential>) -> Value {
let legacy = values.first();
json!({
"configured": !values.is_empty(),
"key_id": legacy.map(|value| value.key_id.clone()),
"created_at": legacy.map(|value| value.created_at),
"last_used_at": legacy.and_then(|value| value.last_used_at),
"credentials": values,
"max_credentials": crate::db::CERTIFICATE_API_CREDENTIAL_CAPACITY,
"scopes": ["certificates:deploy", "certificates:reload"],
})
}
async fn certificate_api_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
let values = state
.store
.certificate_api_credentials()
.await
.map_err(auth::internal)?;
Ok(Json(certificate_api_status_value(values)))
}
async fn certificate_api_rotate(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<CertificateApiRotate>,
) -> Result<Json<Value>, ApiError> {
let generated = input.token.is_none();
let token = input.token.unwrap_or_else(auth::random_secret);
if !valid_certificate_api_token(&token) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_token_format".into(),
));
}
let key_id = input.key_id.unwrap_or_else(|| {
let random = auth::random_secret();
format!("certapi-{}", &random[..12])
});
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_key_id".into(),
));
}
let comment = input.comment.unwrap_or_default().trim().to_owned();
if !valid_certificate_api_comment(&comment) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"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 credential = state
.store
.upsert_certificate_api_credential(
&key_id,
&token_hash,
&comment,
input.allow_deploy,
&certificate_ids,
&session.username,
)
.await
.map_err(|error| {
if error.to_string().contains("credential limit reached") {
ApiError(StatusCode::CONFLICT, "certificate_api.limit_reached".into())
} else {
auth::internal(error)
}
})?;
let values = state
.store
.certificate_api_credentials()
.await
.map_err(auth::internal)?;
let mut value = certificate_api_status_value(values);
value["credential"] = serde_json::to_value(&credential).map_err(auth::internal)?;
value["token"] = Value::String(token);
value["generated"] = Value::Bool(generated);
Ok(Json(value))
}
// Per-credential settings are intentionally separate from token rotation.
// The input cannot include token/token_hash, and every read returns only metadata.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiEdit {
comment: String,
certificate_ids: Vec<String>,
allow_deploy: bool,
}
async fn certificate_api_detail(
State(state): State<Arc<App>>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
}
let credential = state
.store
.certificate_api_credential(&key_id)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
Ok(Json(json!({ "credential": credential })))
}
async fn certificate_api_edit(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(key_id): Path<String>,
Json(input): Json<CertificateApiEdit>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
}
let comment = input.comment.trim();
if !valid_certificate_api_comment(comment) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "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 credential = state
.store
.edit_certificate_api_credential(
&key_id, comment, input.allow_deploy, &certificate_ids, &session.username,
)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
Ok(Json(json!({ "credential": credential })))
}
async fn certificate_api_regenerate(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
}
let token = auth::random_secret();
let hash = auth::secret_hash(&token);
let credential = state
.store
.regenerate_certificate_api_token(&key_id, &hash, &session.username)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
// The only endpoint in this flow that returns the new bearer secret.
Ok(Json(json!({ "credential": credential, "token": token })))
}
async fn certificate_api_revoke(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let revoked = state
.store
.revoke_certificate_api_credential(&key_id, &session.username)
.await
.map_err(auth::internal)?;
if !revoked {
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
Ok(Json(json!({"revoked": true, "key_id": key_id})))
}
async fn certificate_api_revoke_all(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Value>, ApiError> {
let revoked = state
.store
.revoke_all_certificate_api_credentials(&session.username)
.await
.map_err(auth::internal)?;
Ok(Json(json!({"configured": false, "revoked": revoked})))
}
+1
View File
@@ -9,6 +9,7 @@ include!("logging_api.rs");
include!("file_logs.rs");
include!("file_log_search.rs");
include!("operations.rs");
include!("certificate_api.rs");
include!("certificates.rs");
include!("tail.rs");
+283 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.31.8",
"version": "0.31.9",
"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": [
@@ -6057,6 +6057,65 @@
"description": "First matching UA overrides the base and country rate; separate vhost/rule/IP fixed-window counters. Default empty."
}
}
},
"CertificateApiEdit": {
"type": "object",
"additionalProperties": false,
"description": "Non-secret settings only: changes do not rotate or reveal the bearer token. Key ID cannot be changed.",
"properties": {
"comment": {
"type": "string",
"maxLength": 256
},
"certificate_ids": {
"type": "array",
"minItems": 1,
"maxItems": 32,
"items": {
"type": "string"
},
"description": "Explicit ID allowlist; * is forbidden when editing legacy credentials."
},
"allow_deploy": {
"type": "boolean",
"description": "Allow deploy in addition to targeted reload."
}
},
"required": [
"comment",
"certificate_ids",
"allow_deploy"
]
},
"CertificateApiDetailResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"credential": {
"$ref": "#/components/schemas/CertificateApiCredential"
}
},
"required": [
"credential"
]
},
"CertificateApiRegenerateResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"credential": {
"$ref": "#/components/schemas/CertificateApiCredential"
},
"token": {
"type": "string",
"readOnly": true,
"description": "New bearer token returned only by the successful regeneration response. Never returned by GET/PATCH."
}
},
"required": [
"credential",
"token"
]
}
}
},
@@ -10357,6 +10416,159 @@
}
}
}
},
"get": {
"summary": "View one certificate API credential without revealing its token",
"tags": [
"Certificates"
],
"operationId": "get_api_certificate_api_key_id",
"parameters": [
{
"name": "key_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 3,
"maxLength": 64
}
}
],
"security": [
{
"AdminSession": []
}
],
"responses": {
"200": {
"description": "Non-secret Key ID, comment, creation/rotation time, usage time, allowlisted certificate IDs and deploy grant",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CertificateApiDetailResult"
}
}
}
},
"401": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"patch": {
"summary": "Edit one credential’s comment and permissions without rotating the token",
"tags": [
"Certificates"
],
"operationId": "patch_api_certificate_api_key_id",
"parameters": [
{
"name": "key_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 3,
"maxLength": 64
}
}
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CertificateApiEdit"
}
}
}
},
"responses": {
"200": {
"description": "Updated non-secret settings. The current token and its last-used/created time are unchanged.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CertificateApiDetailResult"
}
}
}
},
"400": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"422": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/certificates/{certificate_id}/reload": {
@@ -11133,6 +11345,76 @@
}
}
}
},
"/api/certificate-api/{key_id}/regenerate": {
"post": {
"summary": "Regenerate an existing bearer token, preserving its settings and Key ID",
"description": "Immediately invalidates the previous token. Returns the new token exactly once; subsequent GET/PATCH and list responses never reveal either token. No settings fields are accepted.",
"tags": [
"Certificates"
],
"operationId": "post_api_certificate_api_key_id_regenerate",
"parameters": [
{
"name": "key_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"minLength": 3,
"maxLength": 64
}
}
],
"security": [
{
"AdminSession": [],
"CsrfToken": []
}
],
"responses": {
"200": {
"description": "Generated token, revealed only in this response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CertificateApiRegenerateResult"
}
}
}
},
"401": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Authentication, validation or access error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
-158
View File
@@ -326,164 +326,6 @@ async fn certificate_upload(
Ok(Json(json!({"certificate":certificate, "staged":true})))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiRotate {
#[serde(default)]
key_id: Option<String>,
#[serde(default)]
token: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
certificate_ids: Vec<String>,
#[serde(default)]
allow_deploy: bool,
}
fn valid_certificate_api_comment(value: &str) -> bool {
value.len() <= 256 && !value.chars().any(char::is_control)
}
fn certificate_api_status_value(values: Vec<crate::db::CertificateApiCredential>) -> Value {
let legacy = values.first();
json!({
"configured": !values.is_empty(),
"key_id": legacy.map(|value| value.key_id.clone()),
"created_at": legacy.map(|value| value.created_at),
"last_used_at": legacy.and_then(|value| value.last_used_at),
"credentials": values,
"max_credentials": crate::db::CERTIFICATE_API_CREDENTIAL_CAPACITY,
"scopes": ["certificates:deploy", "certificates:reload"],
})
}
async fn certificate_api_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
let values = state
.store
.certificate_api_credentials()
.await
.map_err(auth::internal)?;
Ok(Json(certificate_api_status_value(values)))
}
async fn certificate_api_rotate(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<CertificateApiRotate>,
) -> Result<Json<Value>, ApiError> {
let generated = input.token.is_none();
let token = input.token.unwrap_or_else(auth::random_secret);
if !valid_certificate_api_token(&token) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_token_format".into(),
));
}
let key_id = input.key_id.unwrap_or_else(|| {
let random = auth::random_secret();
format!("certapi-{}", &random[..12])
});
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_key_id".into(),
));
}
let comment = input.comment.unwrap_or_default().trim().to_owned();
if !valid_certificate_api_comment(&comment) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"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 credential = state
.store
.upsert_certificate_api_credential(
&key_id,
&token_hash,
&comment,
input.allow_deploy,
&certificate_ids,
&session.username,
)
.await
.map_err(|error| {
if error.to_string().contains("credential limit reached") {
ApiError(StatusCode::CONFLICT, "certificate_api.limit_reached".into())
} else {
auth::internal(error)
}
})?;
let values = state
.store
.certificate_api_credentials()
.await
.map_err(auth::internal)?;
let mut value = certificate_api_status_value(values);
value["credential"] = serde_json::to_value(&credential).map_err(auth::internal)?;
value["token"] = Value::String(token);
value["generated"] = Value::Bool(generated);
Ok(Json(value))
}
async fn certificate_api_revoke(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let revoked = state
.store
.revoke_certificate_api_credential(&key_id, &session.username)
.await
.map_err(auth::internal)?;
if !revoked {
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
Ok(Json(json!({"revoked": true, "key_id": key_id})))
}
async fn certificate_api_revoke_all(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Value>, ApiError> {
let revoked = state
.store
.revoke_all_certificate_api_credentials(&session.username)
.await
.map_err(auth::internal)?;
Ok(Json(json!({"configured": false, "revoked": revoked})))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateDeploy {
+9 -1
View File
@@ -71,6 +71,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/transform-panel.js"),
include_str!("web/acme.js"),
include_str!("web/settings.js"),
include_str!("web/certificate-api.js"),
include_str!("web/file-logs.js"),
]
.join("\n")
@@ -181,7 +182,14 @@ pub fn router(state: Arc<App>) -> Router {
)
.route(
"/api/certificate-api/{key_id}",
delete(certificate_api_revoke).layer(DefaultBodyLimit::max(4096)),
get(certificate_api_detail)
.patch(certificate_api_edit)
.delete(certificate_api_revoke)
.layer(DefaultBodyLimit::max(4096)),
)
.route(
"/api/certificate-api/{key_id}/regenerate",
post(certificate_api_regenerate).layer(DefaultBodyLimit::max(4096)),
)
.route("/api/traffic", get(traffic_status))
.route("/api/cache", get(cache_status))
+150
View File
@@ -1284,6 +1284,156 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn certificate_api_settings_read_edit_and_regenerate_without_exposing_existing_token() {
let (router, store, _dir, secret, csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let existing_token = "C".repeat(64);
let admin = |method: Method, path: &str, body: serde_json::Value| {
request(path)
.method(method)
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
let external = |path: &str, token: &str| {
request(path)
.method("POST")
.header("authorization", format!("Bearer {token}"))
.header("x-rustproxy-certificate-key", "certbot-prod")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap()
};
let created = router.clone().oneshot(admin(
Method::PUT,
"/api/certificate-api",
json!({
"key_id": "certbot-prod", "token": existing_token,
"comment": "first comment", "certificate_ids": ["first-cert"], "allow_deploy": false
}),
)).await.unwrap();
assert_eq!(created.status(), StatusCode::OK);
let created = json_body(created).await;
assert_eq!(created["token"], existing_token);
let before_hash: String = sqlx::query_scalar(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
).fetch_one(&store.pool).await.unwrap();
assert_eq!(before_hash, auth::secret_hash(&existing_token));
let detail = router.clone().oneshot(admin(
Method::GET, "/api/certificate-api/certbot-prod", json!({}),
)).await.unwrap();
assert_eq!(detail.status(), StatusCode::OK);
let detail = json_body(detail).await;
assert_eq!(detail["credential"]["comment"], "first comment");
assert_eq!(detail["credential"]["certificate_ids"], json!(["first-cert"]));
assert!(detail.get("token").is_none());
assert!(detail["credential"].get("token").is_none());
assert!(detail["credential"].get("token_hash").is_none());
let created_at = detail["credential"]["created_at"].clone();
// A bearer-only automation client cannot read or modify management records.
let denied = router.clone().oneshot(
request("/api/certificate-api/certbot-prod")
.header("authorization", format!("Bearer {existing_token}"))
.header("x-rustproxy-certificate-key", "certbot-prod")
.body(Body::empty()).unwrap(),
).await.unwrap();
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
let no_csrf = router.clone().oneshot(
request("/api/certificate-api/certbot-prod/regenerate")
.method("POST").header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.body(Body::empty()).unwrap(),
).await.unwrap();
assert_eq!(no_csrf.status(), StatusCode::FORBIDDEN);
let bad_edit = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"changed", "certificate_ids":["other-cert"], "allow_deploy":true, "token":"should-not-be-accepted"}),
)).await.unwrap();
assert!(bad_edit.status().is_client_error());
let wildcard = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"changed", "certificate_ids":["*"], "allow_deploy":true}),
)).await.unwrap();
assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY);
// An edit changes only metadata and grants, not the current bearer secret.
let edited = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}),
)).await.unwrap();
assert_eq!(edited.status(), StatusCode::OK);
let edited = json_body(edited).await;
assert_eq!(edited["credential"]["comment"], "updated owner");
assert_eq!(edited["credential"]["certificate_ids"], json!(["new-cert"]));
assert_eq!(edited["credential"]["allow_deploy"], true);
assert_eq!(edited["credential"]["created_at"], created_at);
assert!(edited.get("token").is_none());
let hash_after_edit: String = sqlx::query_scalar(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
).fetch_one(&store.pool).await.unwrap();
assert_eq!(hash_after_edit, before_hash);
let allowed = router.clone().oneshot(external(
"/api/certificates/new-cert/reload", &existing_token,
)).await.unwrap();
assert_eq!(allowed.status(), StatusCode::NOT_FOUND);
let old_id = router.clone().oneshot(external(
"/api/certificates/first-cert/reload", &existing_token,
)).await.unwrap();
assert_eq!(old_id.status(), StatusCode::FORBIDDEN);
let regenerated = router.clone().oneshot(admin(
Method::POST, "/api/certificate-api/certbot-prod/regenerate", json!({}),
)).await.unwrap();
assert_eq!(regenerated.status(), StatusCode::OK);
let regenerated = json_body(regenerated).await;
let new_token = regenerated["token"].as_str().unwrap().to_owned();
assert!(new_token.len() >= 32);
assert_ne!(new_token, existing_token);
assert_eq!(regenerated["credential"]["comment"], "updated owner");
assert_eq!(regenerated["credential"]["allow_deploy"], true);
assert_eq!(regenerated["credential"]["certificate_ids"], json!(["new-cert"]));
assert!(regenerated["credential"]["last_used_at"].is_null());
let raw: String = sqlx::query_scalar(
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
).fetch_one(&store.pool).await.unwrap();
assert_eq!(raw, auth::secret_hash(&new_token));
assert_ne!(raw, new_token);
assert_ne!(raw, before_hash);
assert_eq!(router.clone().oneshot(external(
"/api/certificates/new-cert/reload", &existing_token,
)).await.unwrap().status(), StatusCode::UNAUTHORIZED);
assert_eq!(router.clone().oneshot(external(
"/api/certificates/new-cert/reload", &new_token,
)).await.unwrap().status(), StatusCode::NOT_FOUND);
let after = router.clone().oneshot(admin(
Method::GET, "/api/certificate-api/certbot-prod", json!({}),
)).await.unwrap();
let after = json_body(after).await;
assert!(after.get("token").is_none());
assert!(after["credential"].get("token").is_none());
assert!(after["credential"].get("token_hash").is_none());
let missing = router.clone().oneshot(admin(
Method::POST, "/api/certificate-api/missing-key/regenerate", json!({}),
)).await.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let audit: Vec<String> = sqlx::query_scalar(
"SELECT action FROM admin_audit WHERE action LIKE 'certificate_api.%' ORDER BY id",
).fetch_all(&store.pool).await.unwrap();
assert!(audit.iter().any(|entry| entry == "certificate_api.edit"));
assert!(audit.iter().any(|entry| entry == "certificate_api.regenerate"));
store.close().await;
}
#[tokio::test]
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
let (router, store, _dir, secret, csrf) = fixture().await;
+249
View File
@@ -0,0 +1,249 @@
"use strict";
// Credential management is separated from generic Settings to keep UI modules bounded.
// Only non-secret metadata is retained in client state.
(() => {
let certificateApiState = null, certificateApiLoading = false;
let certificateApiEditingKey = null, certificateApiDetailsKey = null;
function certificateApiCredentials() {
const values = certificateApiState?.credentials;
if (Array.isArray(values)) return values;
if (certificateApiState?.configured && certificateApiState?.key_id) return [{ key_id: certificateApiState.key_id, comment: "", created_at: certificateApiState.created_at, last_used_at: certificateApiState.last_used_at }];
return [];
}
function clearCertificateApiSecret() {
$("certificate-api-secret").textContent = "";
$("certificate-api-secret-wrap").hidden = true;
}
function showCertificateApiSecret(token) {
$("certificate-api-secret").textContent = token;
$("certificate-api-secret-wrap").hidden = !token;
}
function resetCertificateApiForm() {
certificateApiEditingKey = null;
$("certificate-api-key-id").value = "";
$("certificate-api-key-id").readOnly = false;
$("certificate-api-comment").value = "";
$("certificate-api-certificate-ids").value = "";
$("certificate-api-allow-deploy").checked = false;
$("certificate-api-token").value = "";
$("certificate-api-token-field").hidden = false;
$("certificate-api-edit-note").hidden = true;
$("certificate-api-cancel-edit").hidden = true;
const submit = $("certificate-api-save");
submit.dataset.i18n = "certificateApiRotate";
submit.textContent = t("certificateApiRotate");
certificateApiExample();
}
function beginCertificateApiEdit(credential) {
certificateApiEditingKey = credential.key_id;
certificateApiDetailsKey = credential.key_id;
$("certificate-api-key-id").value = credential.key_id;
$("certificate-api-key-id").readOnly = true;
$("certificate-api-comment").value = credential.comment || "";
$("certificate-api-certificate-ids").value = (credential.certificate_ids || []).join("\n");
$("certificate-api-allow-deploy").checked = !!credential.allow_deploy;
$("certificate-api-token").value = "";
$("certificate-api-token-field").hidden = true;
$("certificate-api-edit-note").hidden = false;
$("certificate-api-cancel-edit").hidden = false;
const submit = $("certificate-api-save");
submit.dataset.i18n = "certificateApiSave";
submit.textContent = t("certificateApiSave");
certificateApiExample();
$("certificate-api-form").scrollIntoView?.({ block: "start", behavior: "smooth" });
$("certificate-api-comment").focus?.();
}
function setCertificateApiCredential(credential) {
if (!certificateApiState) return;
const entries = certificateApiCredentials();
certificateApiState.credentials = entries.some(item => item.key_id === credential.key_id)
? entries.map(item => item.key_id === credential.key_id ? credential : item)
: [...entries, credential];
certificateApiState.configured = certificateApiState.credentials.length > 0;
}
function certificateApiExample() {
const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main";
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 'X-RustProxy-Certificate-Key: ${key}' \
--header 'Content-Type: application/json' \
--data @deploy.json \
${location.origin}/api/certificates/deploy`;
}
function certificateApiDetailNode(credential) {
const detail = el("div", undefined, "certificate-api-details");
const item = (label, value) => {
const line = el("div", undefined, "certificate-api-detail-line");
line.append(el("span", `${label}:`, "fine"), el("strong", String(value), "mono"));
detail.append(line);
};
item(t("certificateApiKeyId"), credential.key_id);
item(t("certificateApiComment"), credential.comment || "-");
item(t("certificateApiCreatedAt"), formatDateTime(Number(credential.created_at) * 1000));
item(t("certificateApiLastUsed"), credential.last_used_at ? formatDateTime(Number(credential.last_used_at) * 1000) : t("certificateApiNeverUsed"));
item(t("certificateApiPermissions"), credential.allow_deploy ? t("certificateApiScopeDeployReload") : t("certificateApiScopeReloadOnly"));
item(t("certificateApiCertificateIds"), (credential.certificate_ids || []).join(", "));
item(t("certificateApiTokenLabel"), t("certificateApiTokenHidden"));
return detail;
}
function renderCertificateApi() {
const state = certificateApiState, target = $("certificate-api-credentials");
if (!state) { $("certificate-api-status").textContent = t("certificateApiUnknown"); if (target) target.replaceChildren(); certificateApiExample(); return; }
const credentials = certificateApiCredentials(), max = Number(state.max_credentials) || 32;
$("certificate-api-status").textContent = credentials.length
? t("certificateApiConfiguredCount").replace("{count}", credentials.length).replace("{max}", max)
: t("certificateApiDisabled");
$("certificate-api-revoke-all").disabled = !credentials.length;
if (target) {
target.replaceChildren();
for (const credential of credentials) {
const container = el("div", undefined, "certificate-api-credential-item");
const row = el("div", undefined, "certificate-api-credential-row");
const info = el("div", undefined, "certificate-api-credential-info");
info.append(el("b", credential.key_id || "-", "mono"));
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");
info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine"));
const actions = el("div", undefined, "certificate-api-row-actions");
const path = `/api/certificate-api/${encodeURIComponent(credential.key_id)}`;
const preview = action(t(certificateApiDetailsKey === credential.key_id ? "certificateApiHide" : "certificateApiView"), async () => {
if (certificateApiDetailsKey === credential.key_id) { certificateApiDetailsKey = null; renderCertificateApi(); return; }
preview.disabled = true;
clearCertificateApiSecret();
try {
const data = await api(path);
setCertificateApiCredential(data.credential);
certificateApiDetailsKey = credential.key_id;
renderCertificateApi();
} catch (error) { toast(error.message, true); preview.disabled = false; }
});
const edit = action(t("certificateApiEdit"), async () => {
edit.disabled = true;
clearCertificateApiSecret();
try {
const data = await api(path);
setCertificateApiCredential(data.credential);
beginCertificateApiEdit(data.credential);
renderCertificateApi();
} catch (error) { toast(error.message, true); edit.disabled = false; }
});
const regenerate = action(t("certificateApiRegenerate"), async () => {
if (!await appConfirm(t("certificateApiRegenerateConfirm").replace("{key}", credential.key_id), { danger: true })) return;
regenerate.disabled = true;
clearCertificateApiSecret();
try {
const data = await api(`${path}/regenerate`, { method: "POST" });
setCertificateApiCredential(data.credential);
// Never put the bearer token into the persistent credential list/state.
showCertificateApiSecret(data.token || "");
renderCertificateApi();
toast(t("certificateApiRegenerated"));
} catch (error) { toast(error.message, true); regenerate.disabled = false; }
});
const remove = action(t("certificateApiRevoke"), async () => {
if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return;
remove.disabled = true;
try {
await api(path, { method: "DELETE" });
certificateApiState.credentials = certificateApiCredentials().filter(value => value.key_id !== credential.key_id);
certificateApiState.configured = certificateApiState.credentials.length > 0;
if (certificateApiEditingKey === credential.key_id) resetCertificateApiForm();
if (certificateApiDetailsKey === credential.key_id) certificateApiDetailsKey = null;
clearCertificateApiSecret(); renderCertificateApi(); toast(t("certificateApiRevoked"));
} catch (error) { toast(error.message, true); remove.disabled = false; }
});
remove.classList.add("danger");
actions.append(preview, edit, regenerate, remove);
row.append(info, actions);
container.append(row);
if (certificateApiDetailsKey === credential.key_id) container.append(certificateApiDetailNode(credential));
target.append(container);
}
}
certificateApiExample();
}
async function loadCertificateApi(force = false) {
if (!authenticated || certificateApiLoading || (certificateApiState && !force)) return;
certificateApiLoading = true;
try { certificateApiState = await api("/api/certificate-api"); renderCertificateApi(); }
catch (error) { $("certificate-api-status").textContent = error.message; }
finally { certificateApiLoading = false; }
}
$("certificate-api-form").addEventListener("submit", async event => {
event.preventDefault();
const button = $("certificate-api-save");
button.disabled = true;
try {
const keyId = $("certificate-api-key-id").value.trim();
const comment = $("certificate-api-comment").value.trim();
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;
if (certificateApiEditingKey) {
const result = await api(`/api/certificate-api/${encodeURIComponent(certificateApiEditingKey)}`, {
method: "PATCH", body: JSON.stringify({ comment, certificate_ids: certificateIds, allow_deploy: allowDeploy })
});
setCertificateApiCredential(result.credential);
resetCertificateApiForm();
renderCertificateApi();
toast(t("certificateApiSaved"));
} else {
const token = $("certificate-api-token").value.trim();
const result = await api("/api/certificate-api", {
method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment, certificate_ids: certificateIds, allow_deploy: allowDeploy })
});
certificateApiState = { ...result }; delete certificateApiState.token; delete certificateApiState.credential;
resetCertificateApiForm();
showCertificateApiSecret(result.token || "");
renderCertificateApi();
toast(t("certificateApiRotated"));
}
} catch (error) { showFieldError(error, $("certificate-api-form")); toast(error.message, true); }
finally { button.disabled = false; }
});
$("certificate-api-cancel-edit").addEventListener("click", resetCertificateApiForm);
$("certificate-api-revoke-all").addEventListener("click", async () => {
if (!await appConfirm(t("certificateApiRevokeAllConfirm"), { danger: true })) return;
const button = $("certificate-api-revoke-all"); button.disabled = true;
try {
await api("/api/certificate-api", { method: "DELETE" });
certificateApiState = { configured: false, credentials: [], max_credentials: certificateApiState?.max_credentials || 32, scopes: certificateApiState?.scopes || [] };
certificateApiDetailsKey = null;
resetCertificateApiForm(); clearCertificateApiSecret(); renderCertificateApi(); toast(t("certificateApiRevoked"));
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$("certificate-api-key-id").addEventListener("input", certificateApiExample);
$("certificate-api-certificate-ids").addEventListener("input", certificateApiExample);
$("certificate-api-allow-deploy").addEventListener("change", certificateApiExample);
window.rustProxyCertificateApi = {
render: renderCertificateApi,
load: loadCertificateApi,
reset() {
certificateApiState = null;
certificateApiLoading = false;
certificateApiDetailsKey = null;
resetCertificateApiForm();
clearCertificateApiSecret();
$("certificate-api-credentials").replaceChildren();
}
};
renderCertificateApi();
loadCertificateApi();
})();
+3 -2
View File
@@ -2867,6 +2867,7 @@
credentials are reload-only by default and restricted to explicit certificate IDs.
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" data-i18n="certificateApiEditNote" id="certificate-api-edit-note" hidden>Edit the settings below without changing the bearer token. Key ID cannot be changed.</p>
<label><span data-i18n="certificateApiKeyId">Key ID</span><input
id="certificate-api-key-id" maxlength="64" placeholder="certbot-main" /></label>
<label><span data-i18n="certificateApiComment">Comment</span><input
@@ -2878,14 +2879,14 @@
<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 id="certificate-api-token-field"><span data-i18n="certificateApiToken">Bearer token (optional custom
value)</span><input autocomplete="off" id="certificate-api-token"
maxlength="128" minlength="32"
placeholder="Leave empty to generate a strong token" type="password" /></label>
<p class="fine" data-i18n="certificateApiTokenHelp">Each cleartext token is shown only
after generation/rotation. RustProxy stores only its hash.</p>
<div class="form-actions"><button class="primary" data-i18n="certificateApiRotate"
type="submit">Generate / rotate token</button><button class="outline danger"
id="certificate-api-save" type="submit">Generate / rotate token</button><button class="outline" data-i18n="certificateApiCancelEdit" id="certificate-api-cancel-edit" hidden type="button">Cancel edit</button><button class="outline danger"
data-i18n="certificateApiRevokeAll" id="certificate-api-revoke-all"
type="button">Revoke all tokens</button></div>
<div hidden id="certificate-api-secret-wrap">
+2 -95
View File
@@ -114,78 +114,6 @@
button.addEventListener("keydown", event => { const index = httpTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % httpTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + httpTabs.length - 1) % httpTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = httpTabs.length - 1; else return; event.preventDefault(); selectHttpTab(httpTabs[next].dataset.httpSettingsTab, true); });
}
selectHttpTab("http2");
let certificateApiState = null, certificateApiLoading = false;
function certificateApiCredentials() {
const values = certificateApiState?.credentials;
if (Array.isArray(values)) return values;
if (certificateApiState?.configured && certificateApiState?.key_id) return [{ key_id: certificateApiState.key_id, comment: "", created_at: certificateApiState.created_at, last_used_at: certificateApiState.last_used_at }];
return [];
}
function certificateApiExample() {
const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main";
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 'X-RustProxy-Certificate-Key: ${key}' \
--header 'Content-Type: application/json' \
--data @deploy.json \
${location.origin}/api/certificates/deploy`;
}
function renderCertificateApi() {
const state = certificateApiState, target = $("certificate-api-credentials");
if (!state) { $("certificate-api-status").textContent = t("certificateApiUnknown"); if (target) target.replaceChildren(); certificateApiExample(); return; }
const credentials = certificateApiCredentials(), max = Number(state.max_credentials) || 32;
$("certificate-api-status").textContent = credentials.length
? t("certificateApiConfiguredCount").replace("{count}", credentials.length).replace("{max}", max)
: t("certificateApiDisabled");
$("certificate-api-revoke-all").disabled = !credentials.length;
if (target) {
target.replaceChildren();
for (const credential of credentials) {
const row = el("div", undefined, "certificate-api-credential-row");
const info = el("div", undefined, "certificate-api-credential-info");
info.append(el("b", credential.key_id || "-", "mono"));
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");
info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine"));
const remove = action(t("certificateApiRevoke"), async () => {
if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return;
remove.disabled = true;
try {
await api(`/api/certificate-api/${encodeURIComponent(credential.key_id)}`, { method: "DELETE" });
certificateApiState.credentials = certificateApiCredentials().filter(value => value.key_id !== credential.key_id);
certificateApiState.configured = certificateApiState.credentials.length > 0;
renderCertificateApi(); toast(t("certificateApiRevoked"));
} catch (error) { toast(error.message, true); remove.disabled = false; }
});
remove.classList.add("danger");
row.append(info, remove); target.append(row);
}
}
certificateApiExample();
}
async function loadCertificateApi(force = false) {
if (!authenticated || certificateApiLoading || (certificateApiState && !force)) return;
certificateApiLoading = true;
try { certificateApiState = await api("/api/certificate-api"); renderCertificateApi(); }
catch (error) { $("certificate-api-status").textContent = error.message; }
finally { certificateApiLoading = false; }
}
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_traffic: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
const geoipDefaults = {
enabled: true, format: "csv_pair",
@@ -303,7 +231,7 @@ curl --fail --show-error --request POST \
const effective = latestStatus?.tls?.effective_cipher_suites || [];
$("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : "";
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
renderCertificateApi(); loadCertificateApi();
window.rustProxyCertificateApi?.render(); window.rustProxyCertificateApi?.load();
}
$("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat);
bindSettingsForm("geoip-form", next => {
@@ -343,27 +271,6 @@ curl --fail --show-error --request POST \
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
next.tls ||= {}; next.tls.policy = { tls12: $("tls-policy-12").checked, tls13: $("tls-policy-13").checked, cipher_suites: $("tls-policy-ciphers").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean) };
}, "tls");
$("certificate-api-form").addEventListener("submit", async event => {
event.preventDefault(); const button = event.submitter || $("certificate-api-form").querySelector("button[type=submit]"); button.disabled = true;
try {
const keyId = $("certificate-api-key-id").value.trim(), token = $("certificate-api-token").value.trim(), comment = $("certificate-api-comment").value.trim();
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;
$("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; }
});
$("certificate-api-revoke-all").addEventListener("click", async () => {
if (!await appConfirm(t("certificateApiRevokeAllConfirm"), { danger: true })) return;
const button = $("certificate-api-revoke-all"); button.disabled = true;
try { await api("/api/certificate-api", { method: "DELETE" }); certificateApiState = { configured: false, credentials: [], max_credentials: certificateApiState?.max_credentials || 32, scopes: certificateApiState?.scopes || [] }; $("certificate-api-secret-wrap").hidden = true; $("certificate-api-secret").textContent = ""; renderCertificateApi(); toast(t("certificateApiRevoked")); }
catch (error) { toast(error.message, true); } finally { button.disabled = false; }
});
$("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; });
$("prometheus-form").addEventListener("input", example);
$("prom-preview").addEventListener("click", async () => {
@@ -373,6 +280,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;
} 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); selectPolicyTab("user-agent"); 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; } };
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+34 -3
View File
@@ -8081,15 +8081,46 @@ body[data-density="compact"] .page-footer {
margin-top: 10px;
}
#certificate-api-token-field[hidden],
#certificate-api-edit-note[hidden],
#certificate-api-cancel-edit[hidden] {
display: none;
}
.certificate-api-credential-item {
border: 1px solid var(--line);
border-radius: 4px;
background: var(--panel2);
}
.certificate-api-row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.certificate-api-details {
display: grid;
gap: 7px;
padding: 10px 12px;
border-top: 1px solid var(--line);
}
.certificate-api-detail-line {
display: grid;
gap: 4px;
grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr);
overflow-wrap: anywhere;
}
.certificate-api-credential-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--panel2);
}
.certificate-api-credential-info {
+139 -36
View File
@@ -5,6 +5,41 @@ use super::{
use anyhow::{ensure, Context, Result};
use sqlx::Row;
fn normalized_certificate_api_ids(comment: &str, certificate_ids: &[String]) -> Result<Vec<String>> {
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 = certificate_ids.to_vec();
normalized.sort();
normalized.dedup();
ensure!(
normalized.iter().all(|value| value != "*" && crate::config::valid_id(value)),
"invalid certificate API certificate id"
);
ensure!(
serde_json::to_string(&normalized)?.len() <= 4096,
"certificate API certificate id list is too large"
);
Ok(normalized)
}
fn credential_from_row(row: sqlx::sqlite::SqliteRow) -> Result<CertificateApiCredential> {
let raw_ids: String = row.try_get("certificate_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: serde_json::from_str(&raw_ids).context("invalid certificate API grant ids")?,
})
}
impl Store {
pub async fn certificate_api_credentials(&self) -> Result<Vec<CertificateApiCredential>> {
let rows = sqlx::query(
@@ -17,21 +52,7 @@ impl Store {
)
.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()
rows.into_iter().map(credential_from_row).collect()
}
pub async fn upsert_certificate_api_credential(
@@ -51,28 +72,8 @@ impl Store {
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 normalized_ids = normalized_certificate_api_ids(comment, certificate_ids)?;
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 =
@@ -134,6 +135,108 @@ impl Store {
})
}
// Metadata access never selects token_hash, including for a single credential.
pub async fn certificate_api_credential(
&self,
key_id: &str,
) -> Result<Option<CertificateApiCredential>> {
let row = 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 \
WHERE c.key_id = ?",
)
.bind(key_id)
.fetch_optional(&self.pool)
.await?;
row.map(credential_from_row).transpose()
}
// Do not touch token_hash, created_at, or last_used_at while editing settings.
pub async fn edit_certificate_api_credential(
&self,
key_id: &str,
comment: &str,
allow_deploy: bool,
certificate_ids: &[String],
actor: &str,
) -> Result<Option<CertificateApiCredential>> {
ensure!(
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
"invalid certificate API key id"
);
let normalized_ids = normalized_certificate_api_ids(comment, certificate_ids)?;
let ids_json = serde_json::to_string(&normalized_ids)?;
let mut tx = self.pool.begin().await?;
let changed = sqlx::query("UPDATE certificate_api_credentials SET comment = ? WHERE key_id = ?")
.bind(comment)
.bind(key_id)
.execute(&mut *tx)
.await?;
if changed.rows_affected() == 0 {
return Ok(None);
}
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(&ids_json)
.execute(&mut *tx)
.await?;
audit_tx(
&mut tx,
actor,
"certificate_api.edit",
&format!(
"key_id={key_id}; scope={}; certificate_ids={}",
if allow_deploy { "deploy+reload" } else { "reload" },
normalized_ids.join(",")
),
)
.await?;
tx.commit().await?;
self.certificate_api_credential(key_id).await
}
// Hash-only rotation preserves comment/grants and invalidates the old token at commit.
pub async fn regenerate_certificate_api_token(
&self,
key_id: &str,
token_hash: &str,
actor: &str,
) -> Result<Option<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"
);
let now = crate::auth::now();
let mut tx = self.pool.begin().await?;
let changed = sqlx::query(
"UPDATE certificate_api_credentials \
SET token_hash = ?, created_at = ?, last_used_at = NULL WHERE key_id = ?",
)
.bind(token_hash)
.bind(now)
.bind(key_id)
.execute(&mut *tx)
.await?;
if changed.rows_affected() == 0 {
return Ok(None);
}
audit_tx(&mut tx, actor, "certificate_api.regenerate", &format!("key_id={key_id}"))
.await?;
tx.commit().await?;
self.certificate_api_credential(key_id).await
}
pub async fn revoke_certificate_api_credential(
&self,
key_id: &str,
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""0.31.9 Certificate API source/OpenAPI/UI contracts; no Rust runtime claims."""
import json
from pathlib import Path
import tomllib
root = Path(__file__).resolve().parents[2]
read = lambda path: (root / path).read_text(encoding='utf-8')
version = tomllib.loads(read('Cargo.toml'))['package']['version']
assert version == '0.31.9', version
spec = json.loads(read('src/admin/openapi.json'))
assert spec['info']['version'] == version
paths = spec['paths']
assert set(paths['/api/certificate-api/{key_id}']) == {'get', 'patch', 'delete'}
assert set(paths['/api/certificate-api/{key_id}/regenerate']) == {'post'}
for method, path in [('get', '/api/certificate-api/{key_id}'), ('patch', '/api/certificate-api/{key_id}'),
('post', '/api/certificate-api/{key_id}/regenerate')]:
operation = paths[path][method]
assert operation['security'] == ([{'AdminSession': []}] if method == 'get' else [{'AdminSession': [], 'CsrfToken': []}])
assert operation['parameters'][0]['name'] == 'key_id'
assert '401' in operation['responses'] and '404' in operation['responses']
edit = spec['components']['schemas']['CertificateApiEdit']
assert edit['additionalProperties'] is False
assert set(edit['required']) == {'comment', 'certificate_ids', 'allow_deploy'}
assert not {'token', 'token_hash', 'key_id'} & edit['properties'].keys()
assert set(spec['components']['schemas']['CertificateApiDetailResult']['properties']) == {'credential'}
assert set(spec['components']['schemas']['CertificateApiRegenerateResult']['properties']) == {'credential', 'token'}
assert 'readOnly' in spec['components']['schemas']['CertificateApiRegenerateResult']['properties']['token']
backend = read('src/admin/certificate_api.rs')
router = read('src/admin/router.rs')
db = read('src/db/certificate_api.rs')
assert 'include!("certificate_api.rs")' in read('src/admin/mod.rs')
for symbol in ('certificate_api_detail', 'certificate_api_edit', 'certificate_api_regenerate'):
assert f'async fn {symbol}(' in backend
assert symbol in router
assert 'deny_unknown_fields' in backend
read_source = backend.split('async fn certificate_api_detail(')[1].split('async fn certificate_api_edit(')[0]
edit_source = backend.split('async fn certificate_api_edit(')[1].split('async fn certificate_api_regenerate(')[0]
regenerate_source = backend.split('async fn certificate_api_regenerate(')[1].split('async fn certificate_api_revoke(')[0]
assert '"token"' not in read_source
assert '"token"' not in edit_source and '"token_hash"' not in edit_source
assert '"token": token' in regenerate_source
assert 'auth::random_secret()' in regenerate_source
assert '"certificate_api.invalid_certificate_ids"' in edit_source
update_source = db.split('pub async fn edit_certificate_api_credential(')[1].split('pub async fn regenerate_certificate_api_token(')[0]
rotate_source = db.split('pub async fn regenerate_certificate_api_token(')[1].split('pub async fn revoke_certificate_api_credential(')[0]
assert 'SET comment = ?' in update_source
assert 'SET token_hash' not in update_source
assert 'SET token_hash = ?, created_at = ?, last_used_at = NULL' in rotate_source
assert 'certificate_api.edit' in update_source and 'certificate_api.regenerate' in rotate_source
assert 'WHERE key_id = ?' in rotate_source
assert 'certificate_api_operation(method: &Method' in router or 'fn certificate_api_operation(method: &Method' in read('src/admin/access.rs') + router
assert 'if method != Method::POST' in router
panel = read('src/admin/web/certificate-api.js')
assert 'include_str!("web/certificate-api.js")' in router
for marker in ('function certificateApiDetailNode(', 'beginCertificateApiEdit(', 'certificateApiEditingKey',
'method: "PATCH"', '/regenerate', 'showCertificateApiSecret(data.token || "")',
'clearCertificateApiSecret()', 'token-field', 'certificateApiTokenHidden'):
assert marker in panel, marker
for item in ('certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-secret', 'certificate-api-token-field'):
assert f'id="{item}"' in read('src/admin/web/index.html')
for language in ('en_US', 'pl_pl'):
strings = json.loads(read(f'langs/{language}.json'))['translations']
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
assert strings.get(key), (language, key)
assert '0.31.9' in read('docs/TEST_REPORT.md')
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
@@ -0,0 +1,169 @@
#!/usr/bin/env node
// Execute the real 0.31.9 Certificate API panel code against a simulated DOM/API.
// This is not a compiled Rust proxy test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync('src/admin/web/certificate-api.js', 'utf8');
const html = readFileSync('src/admin/web/index.html', 'utf8');
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
for (const pack of [english, polish]) {
for (const key of ['certificateApiView', 'certificateApiHide', 'certificateApiEdit',
'certificateApiSave', 'certificateApiCancelEdit', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiRegenerated', 'certificateApiTokenHidden']) {
assert.ok(pack[key], key);
}
}
for (const id of ['certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-token-field',
'certificate-api-secret', 'certificate-api-credentials', 'certificate-api-edit-note']) {
assert.ok(html.includes(`id="${id}"`), id);
}
class FakeNode {
constructor(tag = 'div', content = '', cls = '') {
this.tagName = tag; this.value = ''; this.content = String(content ?? '');
this.className = cls; this.children = []; this.events = {};
this.dataset = {}; this.hidden = false; this.disabled = false;
this.checked = false; this.readOnly = false;
this.classList = { add: cls => { this.className += ` ${cls}`; } };
}
get textContent() { return this.content + this.children.map(child => child.textContent).join(''); }
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
addEventListener(type, callback) { this.events[type] = callback; }
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; this.content = ''; }
scrollIntoView() { }
focus() { }
async fire(type, event = {}) { assert.ok(this.events[type], `${this.tagName} missing ${type}`); await this.events[type](event); }
findByText(value) { return [this, ...this.children.flatMap(child => child.findAll())].find(child => child.content === value); }
findAll() { return [this, ...this.children.flatMap(child => child.findAll())]; }
}
const fields = new Map();
const dollar = id => {
if (!fields.has(id)) fields.set(id, new FakeNode(id));
return fields.get(id);
};
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
let persisted = [{ key_id: 'renew-prod', comment: 'Old owner', certificate_ids: ['first-cert'],
allow_deploy: false, created_at: 1690000000, last_used_at: 1700000000 }];
let activeToken = 'original-secret-not-readable';
const requests = [];
const makeStatus = () => ({ configured: persisted.length > 0, credentials: structuredClone(persisted),
max_credentials: 32, scopes: ['certificates:reload', 'certificates:deploy'] });
async function api(path, options = {}) {
const method = options.method || 'GET';
const data = options.body ? JSON.parse(options.body) : {};
requests.push({ path, method, data });
if (path === '/api/certificate-api' && method === 'GET') return makeStatus();
if (path === '/api/certificate-api' && method === 'DELETE') { persisted = []; return { revoked: 1 }; }
if (path === '/api/certificate-api' && method === 'PUT') {
activeToken = data.token || 'created-once-token';
const entry = { key_id: data.key_id || 'generated-id', comment: data.comment,
certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy,
created_at: 1800000000, last_used_at: null };
persisted.push(entry);
return { ...makeStatus(), credential: entry, token: activeToken, generated: !data.token };
}
const match = path.match(/^\/api\/certificate-api\/([^/]+)(\/regenerate)?$/);
assert.ok(match, `Unexpected ${method} ${path}`);
const credential = persisted.find(entry => entry.key_id === match[1]);
assert.ok(credential);
if (method === 'GET') return { credential: structuredClone(credential) };
if (method === 'PATCH') {
assert.deepEqual(Object.keys(data).sort(), ['allow_deploy', 'certificate_ids', 'comment']);
assert.ok(!('token' in data) && !('token_hash' in data));
Object.assign(credential, { comment: data.comment, certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy });
return { credential: structuredClone(credential) };
}
if (method === 'POST' && match[2] === '/regenerate') {
activeToken = 'replacement-secret-once';
credential.last_used_at = null;
credential.created_at = 1800000001;
return { credential: structuredClone(credential), token: activeToken };
}
if (method === 'DELETE') {
persisted = persisted.filter(entry => entry.key_id !== credential.key_id);
return { revoked: true };
}
throw new Error(`Unsupported ${method} ${path}`);
}
const toasts = [];
const ctx = {
document: {}, window: {}, $: dollar, el: node,
action: (text, cb) => { const result = node('button', text, 'quiet'); result.addEventListener('click', cb); result.type = 'button'; return result; },
t: key => english[key] || key,
location: { origin: 'https://admin.example.test' },
authenticated: true, api, appConfirm: async () => true,
formatDateTime: timestamp => new Date(timestamp).toISOString(),
toast: value => toasts.push(value),
fieldError: (_, message) => new Error(message), showFieldError: () => {},
};
vm.createContext(ctx);
vm.runInContext(source, ctx, { filename: 'src/admin/web/certificate-api.js' });
await new Promise(resolve => setImmediate(resolve));
const list = dollar('certificate-api-credentials');
const actionsFor = (rowIndex = 0) => list.children[rowIndex].children[0].children[1].children;
const click = async (label, rowIndex = 0) => {
const btn = actionsFor(rowIndex).find(item => item.content === label);
assert.ok(btn, `Missing button: ${label}`);
await btn.fire('click');
};
assert.equal(list.children.length, 1);
assert.ok(requests.some(request => request.path === '/api/certificate-api' && request.method === 'GET'));
await click(english.certificateApiView);
assert.equal(list.children[0].children.length, 2, 'details expanded');
const previewText = list.children[0].children[1].textContent;
assert.match(previewText, /renew-prod/);
assert.match(previewText, /Old owner/);
assert.match(previewText, /first-cert/);
assert.match(previewText, /Unavailable/);
assert.ok(!previewText.includes(activeToken), 'never show existing bearer token');
assert.ok(requests.some(request => request.path === '/api/certificate-api/renew-prod' && request.method === 'GET'));
await click(english.certificateApiEdit);
assert.equal(dollar('certificate-api-key-id').value, 'renew-prod');
assert.equal(dollar('certificate-api-key-id').readOnly, true);
assert.equal(dollar('certificate-api-comment').value, 'Old owner');
assert.equal(dollar('certificate-api-token-field').hidden, true);
assert.equal(dollar('certificate-api-save').content, english.certificateApiSave);
assert.equal(dollar('certificate-api-cancel-edit').hidden, false);
dollar('certificate-api-comment').value = 'New owner';
dollar('certificate-api-certificate-ids').value = 'second-cert,third-cert';
dollar('certificate-api-allow-deploy').checked = true;
await dollar('certificate-api-form').fire('submit', { preventDefault() {} });
assert.equal(persisted[0].comment, 'New owner');
assert.deepEqual(persisted[0].certificate_ids, ['second-cert', 'third-cert']);
assert.equal(persisted[0].allow_deploy, true);
assert.equal(persisted[0].created_at, 1690000000, 'editing must not rotate');
assert.equal(activeToken, 'original-secret-not-readable');
assert.equal(dollar('certificate-api-token-field').hidden, false);
assert.equal(dollar('certificate-api-save').content, english.certificateApiRotate);
assert.equal(dollar('certificate-api-secret-wrap').hidden, true);
await click(english.certificateApiRegenerate);
assert.equal(activeToken, 'replacement-secret-once');
assert.equal(dollar('certificate-api-secret').textContent, activeToken);
assert.equal(dollar('certificate-api-secret-wrap').hidden, false);
assert.equal(persisted[0].comment, 'New owner', 'metadata unchanged by regeneration');
assert.equal(persisted[0].allow_deploy, true);
assert.equal(persisted[0].last_used_at, null);
assert.equal(list.textContent.includes(activeToken), false, 'token not in persistent credential DOM');
await click(english.certificateApiHide);
assert.equal(dollar('certificate-api-secret').textContent, 'replacement-secret-once', 'collapsing is not a new API secret read');
await click(english.certificateApiView);
assert.equal(dollar('certificate-api-secret-wrap').hidden, true, 'reading settings does not re-expose the token');
assert.equal(dollar('certificate-api-secret').textContent, '');
assert.ok(!list.textContent.includes(activeToken));
ctx.window.rustProxyCertificateApi.reset();
assert.equal(dollar('certificate-api-secret').textContent, '');
assert.equal(list.children.length, 0);
await ctx.window.rustProxyCertificateApi.load();
assert.equal(list.children.length, 1);
assert.ok(!list.textContent.includes(activeToken));
assert.equal(requests.filter(item => item.path.endsWith('/regenerate')).length, 1);
assert.ok(toasts.includes(english.certificateApiSaved));
assert.ok(toasts.includes(english.certificateApiRegenerated));
console.log('PASS: 0.31.9 Certificate API view/edit/regenerate/one-time secret UI (real module, simulated DOM/API).');
+12 -11
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.31.8"
assert manifest["package"]["version"] == "0.31.9"
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
h3dep = manifest["dependencies"]["h3"]
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
lock_path = root / "Cargo.lock"
if lock_path.exists():
lock = lock_path.read_text()
assert 'name = "rust-proxy"\nversion = "0.31.8"' in lock
assert 'name = "rust-proxy"\nversion = "0.31.9"' in lock
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
for release_doc, marker in {
"README.md": "# RustProxy 0.31.8",
"CHANGELOG.md": "## 0.31.8",
"ROADMAP.md": "## 0.31.8",
"AGENTS.md": "Current source version: 0.31.8.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.8",
"README.md": "# RustProxy 0.31.9",
"CHANGELOG.md": "## 0.31.9",
"ROADMAP.md": "## 0.31.9",
"AGENTS.md": "Current source version: 0.31.9.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.9",
}.items():
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
for marker in ['"version": "0.31.8"', '"/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.31.9"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
assert marker in openapi, marker
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
@@ -569,6 +569,7 @@ assert 'const panelPaths = new Set' in login_js and 'panelPaths.has(location.pat
assert '"/404-hosts"' in login_js
assert 'async function redirectExistingSession' in login_js and '/api/auth/session' in login_js
settings_js = (root / "src/admin/web/settings.js").read_text()
certificate_settings_js = (root / "src/admin/web/certificate-api.js").read_text()
assert '["/settings", "/configuration", "/logging"].includes(location.pathname)' in settings_js
assert 'new URLSearchParams(location.search).get("tab")' in settings_js and 'function syncLocation()' in settings_js and 'history.replaceState' in settings_js
assert 'url.pathname = "/settings"' in settings_js and 'url.searchParams.set("tab", active)' in settings_js
@@ -607,7 +608,7 @@ assert 'data-i18n="processDiagnostics">Process<' in html
for marker in ['function setOptionalMetric', 'setOptionalMetric("process-rss-anon"', 'setOptionalMetric("system-swap"', 'closest(".mini-live-chart").hidden']:
assert marker in app_js, marker
for marker in ['/api/certificate-api', 'certificate-api-form', 'certificate-api-revoke-all', 'certificate-api-credentials', 'data-cache-settings-tab']:
assert marker in settings_js or marker in html, marker
assert marker in settings_js or marker in certificate_settings_js or marker in html, marker
db_source = rust_source(root, "src/db.rs")
acme_source = rust_source(root, "src/acme.rs")
acme_runtime_source = (root / "src/acme/runtime_env.rs").read_text(encoding="utf-8") + (root / "src/acme/runtime_bootstrap.rs").read_text(encoding="utf-8")
@@ -1395,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
assert 'location.pathname === "/logging"' in settings_js_0241
assert '"RuntimeInformation"' in openapi and '"version": "0.31.8"' in openapi
assert '"RuntimeInformation"' in openapi and '"version": "0.31.9"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs")
@@ -1634,5 +1635,5 @@ for marker_0282 in [
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/INSTALL.md').read_text()
print("PASS: 0.31.8 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
print("PASS: 0.31.9 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.")
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Check the 0.31.8 dependency/source migration; optionally require a resolved lock.
"""Check the 0.31.9 dependency/source migration; optionally require a resolved lock.
This is not a substitute for rustc, cargo metadata or runtime tests.
"""
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
args = parser.parse_args()
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
assert manifest['package']['version'] == '0.31.8'
assert manifest['package']['version'] == '0.31.9'
assert manifest['package']['rust-version'] == '1.99'
deps = manifest['dependencies']
expected_requirements = {
@@ -78,4 +78,4 @@ if missing:
print(f'PENDING: lockfile still requires cargo update ({details})')
else:
print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required')
print('PASS: 0.31.8 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
print('PASS: 0.31.9 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
+1 -1
View File
@@ -164,4 +164,4 @@ config.docker_agents.agents = [{ ...agent, enabled: false }];
ctx.window.rustProxyDocker.render();
assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling');
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
console.log('PASS: 0.31.8 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
console.log('PASS: 0.31.9 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.31.8 release."""
"""Rust-independent documentation/Reader consistency checks for the 0.31.9 release."""
from pathlib import Path
import re
from rust_source import rust_source
@@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
assert readme.startswith("# RustProxy 0.31.8")
assert readme.startswith("# RustProxy 0.31.9")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
html = (ROOT / 'src/admin/web/index.html').read_text()
assert '0.31.8' in (ROOT / "Cargo.toml").read_text()
assert '0.31.9' in (ROOT / "Cargo.toml").read_text()
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
+3 -3
View File
@@ -76,13 +76,13 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.31.8 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
# The Reader uses compile-time include_str! for its bundled Markdown. A
# dangling registration is a real compiler error, unlike an omitted old
# release audit. Match both reader_doc! and the expanded include_str! form.
registry = root / "src/admin/documentation/documents.rs"
if not registry.is_file():
raise ValueError("Incomplete RustProxy 0.31.8 source tree: missing src/admin/documentation/documents.rs")
raise ValueError("Incomplete RustProxy 0.31.9 source tree: missing src/admin/documentation/documents.rs")
embedded_docs = set(re.findall(
r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8")
))
@@ -91,7 +91,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration")
text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.31.8 entry point; extract the complete package, not individual files.")
raise ValueError("src/lib.rs is not the 0.31.9 entry point; extract the complete package, not individual files.")
return messages
+5 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""0.31.8 source-only regression; does NOT claim Rust compilation or memory profiling."""
"""0.31.9 source-only regression; does NOT claim Rust compilation or memory profiling."""
from pathlib import Path
import json
import re
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
ROOT = Path(__file__).resolve().parents[2]
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.8'
assert 'name = "rust-proxy"\nversion = "0.31.8"' in read('Cargo.lock')
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.8'
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.9'
assert 'name = "rust-proxy"\nversion = "0.31.9"' in read('Cargo.lock')
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.9'
pages = read('src/pages.rs')
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
@@ -43,4 +43,4 @@ for marker in ('sort_by: Option<String>', 'sort_descending: Option<bool>', 'vali
'file_log_field(record, field)', 'None, Some(_)', 'sort_file_log_records(&mut records'):
assert marker in q, marker
assert q.index('sort_file_log_records(&mut records') < q.index('let page = records')
print('PASS: 0.31.8 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
print('PASS: 0.31.9 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
+1 -1
View File
@@ -60,4 +60,4 @@ assert.equal(ctx.sortDescending, true);
assert.equal(row.children[1].attrs['aria-sort'], 'descending');
assert.equal(ctx.saved, 2);
assert.equal(ctx.refreshed, 2);
console.log('PASS: 0.31.8 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
console.log('PASS: 0.31.9 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
+5 -5
View File
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.31.8"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert 'version = "0.31.9"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
assert (target / "langs/site.json").is_file()
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert (target / "docker-agent/certificate.go").is_file()
assert (target / "docker-agent/server.go").is_file()
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
assert manifest["to_version"] == "0.31.8"
assert manifest["to_version"] == "0.31.9"
assert manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists()
assert not (backup / "legacy-data").exists()
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
assert marker in admin, marker
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
assert '"/api/status"' not in anonymous_block
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.8"' in openapi_0241
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.9"' in openapi_0241
assert 'id="panel-logging"' not in html
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
assert marker in html, marker
@@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None:
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
assert '"/api/config/history/restore"' in openapi
for marker in ['"version": "0.31.8"', '"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.31.9"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
assert '"software_update"' in openapi and '"update_available"' in openapi
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
@@ -1315,7 +1315,7 @@ def main() -> None:
source_contract_smoke()
container_contract_smoke()
grafana_contract_smoke()
print("PASS: 0.31.8 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
print("PASS: 0.31.9 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__":
+2 -2
View File
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
const en = json('langs/en_US.json').translations;
const pl = json('langs/pl_pl.json').translations;
assert.match(manifest, /version = "0\.31\.8"/);
assert.match(manifest, /version = "0\.31\.9"/);
assert.match(lib, /pub mod user_agent;/);
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
assert.match(fileLogs, /aggregate_min_count/);
assert.match(fileLogs, /file-log-aggregate-copy/);
assert.equal(openapi.info.version, '0.31.8');
assert.equal(openapi.info.version, '0.31.9');
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
+3 -3
View File
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
const elements = new Map();
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
const status = {
version: '0.31.8', uptime_seconds: 4201,
version: '0.31.9', uptime_seconds: 4201,
runtime: {
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
@@ -63,7 +63,7 @@ const context = {
vm.runInNewContext(code, context);
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
assert.equal(dollar('runtime-version').textContent, '0.31.8');
assert.equal(dollar('runtime-version').textContent, '0.31.9');
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
@@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) {
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
}
console.log('PASS: 0.31.8 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
console.log('PASS: 0.31.9 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
+1 -1
View File
@@ -5,7 +5,7 @@ PANEL_PARTS = (
"management.js", "drafts.js", "integrations.js", "vhosts.js",
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js",
"redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js",
"transform-panel.js", "acme.js", "settings.js", "file-logs.js",
"transform-panel.js", "acme.js", "settings.js", "certificate-api.js", "file-logs.js",
)
VHOST_PARTS = ("core.js", "health.js", "rates.js", "editor.js", "render.js")