diff --git a/AGENTS.md b/AGENTS.md
index 8b11c30..56ad65c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 719400b..0f74c31 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/Cargo.lock b/Cargo.lock
index fd3220a..534e5fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
-version = "0.31.8"
+version = "0.31.9"
dependencies = [
"anyhow",
"arc-swap",
diff --git a/Cargo.toml b/Cargo.toml
index 23787bf..dca0c93 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index ef0d173..46c96fa 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/ROADMAP.md b/ROADMAP.md
index 0c295a1..0b8dfcd 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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.
diff --git a/SHA256SUMS b/SHA256SUMS
index 85960d7..770c7a8 100644
--- a/SHA256SUMS
+++ b/SHA256SUMS
@@ -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
diff --git a/dev.sh b/dev.sh
index 7dc3ff3..85fe33d 100755
--- a/dev.sh
+++ b/dev.sh
@@ -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
diff --git a/docs/API.md b/docs/API.md
index 4c1837f..67780fd 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -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:
diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md
index f1d4815..c67d88f 100644
--- a/docs/API_ACCESS.md
+++ b/docs/API_ACCESS.md
@@ -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 ` and `X-RustProxy-Certificate-Key: ` 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.
diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md
index 3ddbae1..40eacb1 100644
--- a/docs/SETTINGS.md
+++ b/docs/SETTINGS.md
@@ -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:
diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md
index b2c1f81..521ab21 100644
--- a/docs/TEST_REPORT.md
+++ b/docs/TEST_REPORT.md
@@ -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
diff --git a/langs/en_US.json b/langs/en_US.json
index efef0c8..a3f938e 100644
--- a/langs/en_US.json
+++ b/langs/en_US.json
@@ -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.",
diff --git a/langs/pl_pl.json b/langs/pl_pl.json
index 4f473bf..202e685 100644
--- a/langs/pl_pl.json
+++ b/langs/pl_pl.json
@@ -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.",
diff --git a/src/admin/certificate_api.rs b/src/admin/certificate_api.rs
new file mode 100644
index 0000000..dcc701b
--- /dev/null
+++ b/src/admin/certificate_api.rs
@@ -0,0 +1,245 @@
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct CertificateApiRotate {
+ #[serde(default)]
+ key_id: Option,
+ #[serde(default)]
+ token: Option,
+ #[serde(default)]
+ comment: Option,
+ #[serde(default)]
+ certificate_ids: Vec,
+ #[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) -> 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>) -> Result, 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>,
+ Extension(session): Extension,
+ Json(input): Json,
+) -> Result, 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 = 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,
+ allow_deploy: bool,
+}
+
+async fn certificate_api_detail(
+ State(state): State>,
+ Path(key_id): Path,
+) -> Result, 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>,
+ Extension(session): Extension,
+ Path(key_id): Path,
+ Json(input): Json,
+) -> Result, 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 = 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>,
+ Extension(session): Extension,
+ Path(key_id): Path,
+) -> Result, 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>,
+ Extension(session): Extension,
+ Path(key_id): Path,
+) -> Result, 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>,
+ Extension(session): Extension,
+) -> Result, ApiError> {
+ let revoked = state
+ .store
+ .revoke_all_certificate_api_credentials(&session.username)
+ .await
+ .map_err(auth::internal)?;
+ Ok(Json(json!({"configured": false, "revoked": revoked})))
+}
+
diff --git a/src/admin/mod.rs b/src/admin/mod.rs
index b2215c2..df46cbe 100644
--- a/src/admin/mod.rs
+++ b/src/admin/mod.rs
@@ -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");
diff --git a/src/admin/openapi.json b/src/admin/openapi.json
index a61f138..3df86f4 100644
--- a/src/admin/openapi.json
+++ b/src/admin/openapi.json
@@ -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"
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
diff --git a/src/admin/operations.rs b/src/admin/operations.rs
index 7b8b240..650dfa7 100644
--- a/src/admin/operations.rs
+++ b/src/admin/operations.rs
@@ -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,
- #[serde(default)]
- token: Option,
- #[serde(default)]
- comment: Option,
- #[serde(default)]
- certificate_ids: Vec,
- #[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) -> 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>) -> Result, 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>,
- Extension(session): Extension,
- Json(input): Json,
-) -> Result, 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 = 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>,
- Extension(session): Extension,
- Path(key_id): Path,
-) -> Result, 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>,
- Extension(session): Extension,
-) -> Result, 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 {
diff --git a/src/admin/router.rs b/src/admin/router.rs
index a17ff27..caf0a07 100644
--- a/src/admin/router.rs
+++ b/src/admin/router.rs
@@ -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) -> 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))
diff --git a/src/admin/tests.rs b/src/admin/tests.rs
index 1b153fa..ac801b7 100644
--- a/src/admin/tests.rs
+++ b/src/admin/tests.rs
@@ -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 = 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;
diff --git a/src/admin/web/certificate-api.js b/src/admin/web/certificate-api.js
new file mode 100644
index 0000000..835c934
--- /dev/null
+++ b/src/admin/web/certificate-api.js
@@ -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();
+})();
diff --git a/src/admin/web/index.html b/src/admin/web/index.html
index a0bdd21..45640ba 100644
--- a/src/admin/web/index.html
+++ b/src/admin/web/index.html
@@ -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.
+ Edit the settings below without changing the bearer token. Key ID cannot be changed.
Leave disabled for Certbot renewal hooks. Reload-only is the recommended least-privilege mode.
-