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.

+

Leave disabled for Certbot renewal hooks. Reload-only is the recommended least-privilege mode.

-