0.31.9
This commit is contained in:
1 parent
180a0e4877
commit
d23fa5e154
39 files changed
+1521
-376
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
> Operator-facing release history. Historical engineering notes are kept in
|
||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||
|
||||
## 0.31.9
|
||||
|
||||
- Add per-credential **View settings**, **Edit settings** and **Regenerate token** actions to Settings → Certificate API. Read and edit return non-secret metadata only; Key ID remains stable, and editing comment/certificate allowlist/deploy permission does not rotate the existing bearer token.
|
||||
- Add administrator-only `GET /api/certificate-api/{key_id}`, `PATCH /api/certificate-api/{key_id}` and `POST /api/certificate-api/{key_id}/regenerate`. Regeneration atomically updates only the stored hash and rotation/usage timestamps, preserving the grant and showing the new random secret only in the regeneration response. The old secret becomes invalid immediately.
|
||||
- Keep existing login/session/CSRF protections and external Certificate API authorization strictly limited to targeted reload and explicit deploy permissions. Include OpenAPI, English/Polish UI strings and regression tests for secret non-disclosure and token invalidation.
|
||||
- No database schema or proxy transport change. Cargo/runtime acceptance remains required on a Rust 1.99+ build host.
|
||||
|
||||
## 0.31.8
|
||||
|
||||
- Consolidate 28 numbered Rust unit-test fragments into seven existing `tests.rs` suites (Admin, Balance, Config, Web Server and three Cache suites). Preserve the tests, remove obsolete fragment directories, and exempt Rust test sources (not production modules) from the 500-line check. Fix the test fixture's relative `login-keys.json` include path. No runtime functionality changes.
|
||||
|
||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.31.8"
|
||||
version = "0.31.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# RustProxy roadmap
|
||||
|
||||
## 0.31.9 - Certificate API credential management (runtime acceptance pending)
|
||||
|
||||
- [x] Inspect individual saved Certificate API credential metadata without reading its bearer secret.
|
||||
- [x] Edit comment, explicit certificate allowlist and deploy grant independently of token rotation.
|
||||
- [x] Regenerate only a selected credential's token while keeping Key ID, comment and grants, invalidating the old secret and showing the replacement only once.
|
||||
- [x] Preserve existing scopes, administrator-only access, audit trail, schema 9 and backwards-compatible creation/revocation endpoints.
|
||||
- [ ] Compile and execute Rust regression tests with Rust 1.99+; verify end-to-end panel flows on a real server before production acceptance.
|
||||
|
||||
## 0.31.8 - Stored Logs sorting, built-in pages and allocation reduction (runtime acceptance pending)
|
||||
|
||||
- [x] Consolidate 28 numbered Rust test fragments into seven large `tests.rs` files; exempt test sources from the 500-line production limit and remove obsolete fragment paths.
|
||||
|
||||
+38
-34
@@ -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
|
||||
|
||||
@@ -93,6 +93,7 @@ if [[ "$mode" == "test" ]]; then
|
||||
python3 tools/checks/h3_navigation_0316.py
|
||||
python3 tools/checks/release_0317.py
|
||||
python3 tools/checks/release_0318.py
|
||||
python3 tools/checks/certificate_api_0319.py
|
||||
python3 tools/checks/source_pruning_0317.py
|
||||
python3 tools/checks/system_info_no_subprocess_contracts.py
|
||||
python3 tools/checks/source_module_contracts.py
|
||||
@@ -122,6 +123,7 @@ if [[ "$mode" == "test" ]]; then
|
||||
node tools/checks/release_0317_ui_test.mjs
|
||||
node tools/checks/release_0318_ui_test.mjs
|
||||
node tools/checks/docker_startup_0318_ui_test.mjs
|
||||
node tools/checks/certificate_api_0319_ui_test.mjs
|
||||
node tools/checks/h3_cache_mobile_0315_test.mjs
|
||||
node tools/checks/panel_cache_http_0310_ui_test.mjs
|
||||
node tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||
|
||||
+13
-1
@@ -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:
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ A reload-only credential authorizes only:
|
||||
|
||||
- `POST /api/certificates/{certificate_id}/reload` when that ID is allowlisted.
|
||||
|
||||
Certificate API credential inspection (`GET /api/certificate-api` or `GET /api/certificate-api/{key_id}`) is likewise administrator-session only and returns no hashes/secrets. `PATCH /api/certificate-api/{key_id}` changes metadata/grants without touching the secret; `POST /api/certificate-api/{key_id}/regenerate` replaces the hash and invalidates the old token. Both writes require session + CSRF/Origin/request-marker protection. The replacement secret is revealed only once in the regeneration response. External Key ID + Bearer clients are not authorized to call any credential-management endpoint.
|
||||
|
||||
`POST /api/certificates/reload` is administrator-session only and never accepts a Certificate API token. `POST /api/certificates/deploy` accepts a Certificate API token only when the credential has explicit deploy permission and the request's `certificate_id` is also allowlisted.
|
||||
|
||||
Both `Authorization: Bearer <token>` and `X-RustProxy-Certificate-Key: <key-id>` are required. The clear token is returned only on creation/rotation; the database stores its SHA-256 hash. Up to 32 Key IDs can coexist, and each can be revoked independently or all at once. Credentials created before 0.28.2 migrate to reload-only compatibility access for all certificate IDs (`*`); rotate them to narrow the allowlist. The credential does not authorize configuration, logs, metrics, users or any other endpoint. Non-loopback use is accepted only on the configured HTTPS administration surface. Revocation from Settings invalidates that credential immediately.
|
||||
|
||||
@@ -12,6 +12,16 @@ The Settings page includes a local search field that filters category tabs using
|
||||
|
||||
Unfinished Web Server, Vhost, Redirect and Stream forms use a session-only draft dock. Closing a dirty editor minimizes its exact browser state to the bottom-right dock so another editor can be opened independently. The dock follows the square-corner panel styling of the dashboard and intentionally avoids rounded/glass presentation. Drafts are intentionally not written to SQLite because form state can contain credentials or other secrets; they are discarded on logout/session reset.
|
||||
|
||||
## Certificate API credentials (0.31.9)
|
||||
|
||||
Open **Settings → Certificate API** (`/settings?tab=certificate-api`) to manage external certificate automation credentials. The credential list now has **View settings**, **Edit settings**, **Regenerate token** and **Revoke** actions. The preview shows Key ID, non-secret comment, allowed certificate IDs, reload-only/deploy scope and creation/last-use times. Existing bearer token values cannot be viewed: only hashes are stored.
|
||||
|
||||
**Edit settings** loads the current credential into a form with fixed Key ID. You may update its comment, list of 1–32 explicit certificate IDs, and **Also allow deploy** permission. **Save settings** changes only this metadata; the current secret stays valid and is never shown or replaced. Legacy `*` certificate grants must be replaced with explicit IDs before saving an edit.
|
||||
|
||||
**Regenerate token** requires confirmation. It generates a new random bearer token, preserves the Key ID/comment/permissions/allowed IDs, immediately invalidates the old token and reveals the new token only once. Copy it into the external automation before closing the page. Regeneration updates the token's creation/rotation timestamp and clears last-used metadata. The same existing session + Origin + CSRF checks protect both operations; bearer-only external clients cannot read or change credentials.
|
||||
|
||||
The existing **Generate / rotate token** form still creates new Key IDs (and retains its previous ability to rotate a Key ID by supplying it). Use **Edit settings** to modify settings without rotating, and **Regenerate token** to rotate without changing any settings. See [API](API.md) and [access control](API_ACCESS.md).
|
||||
|
||||
## Refresh preferences (0.31.0)
|
||||
|
||||
**Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values:
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateApiRotate {
|
||||
#[serde(default)]
|
||||
key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
#[serde(default)]
|
||||
certificate_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
allow_deploy: bool,
|
||||
}
|
||||
|
||||
fn valid_certificate_api_comment(value: &str) -> bool {
|
||||
value.len() <= 256 && !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn certificate_api_status_value(values: Vec<crate::db::CertificateApiCredential>) -> Value {
|
||||
let legacy = values.first();
|
||||
json!({
|
||||
"configured": !values.is_empty(),
|
||||
"key_id": legacy.map(|value| value.key_id.clone()),
|
||||
"created_at": legacy.map(|value| value.created_at),
|
||||
"last_used_at": legacy.and_then(|value| value.last_used_at),
|
||||
"credentials": values,
|
||||
"max_credentials": crate::db::CERTIFICATE_API_CREDENTIAL_CAPACITY,
|
||||
"scopes": ["certificates:deploy", "certificates:reload"],
|
||||
})
|
||||
}
|
||||
|
||||
async fn certificate_api_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
let values = state
|
||||
.store
|
||||
.certificate_api_credentials()
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(certificate_api_status_value(values)))
|
||||
}
|
||||
|
||||
async fn certificate_api_rotate(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<CertificateApiRotate>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let generated = input.token.is_none();
|
||||
let token = input.token.unwrap_or_else(auth::random_secret);
|
||||
if !valid_certificate_api_token(&token) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_token_format".into(),
|
||||
));
|
||||
}
|
||||
let key_id = input.key_id.unwrap_or_else(|| {
|
||||
let random = auth::random_secret();
|
||||
format!("certapi-{}", &random[..12])
|
||||
});
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_key_id".into(),
|
||||
));
|
||||
}
|
||||
let comment = input.comment.unwrap_or_default().trim().to_owned();
|
||||
if !valid_certificate_api_comment(&comment) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_comment".into(),
|
||||
));
|
||||
}
|
||||
let mut certificate_ids: Vec<String> = input
|
||||
.certificate_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
certificate_ids.sort();
|
||||
certificate_ids.dedup();
|
||||
if certificate_ids.is_empty()
|
||||
|| certificate_ids.len() > 32
|
||||
|| certificate_ids
|
||||
.iter()
|
||||
.any(|value| value == "*" || !crate::config::valid_id(value))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_certificate_ids".into(),
|
||||
));
|
||||
}
|
||||
let token_hash = auth::secret_hash(&token);
|
||||
let credential = state
|
||||
.store
|
||||
.upsert_certificate_api_credential(
|
||||
&key_id,
|
||||
&token_hash,
|
||||
&comment,
|
||||
input.allow_deploy,
|
||||
&certificate_ids,
|
||||
&session.username,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.to_string().contains("credential limit reached") {
|
||||
ApiError(StatusCode::CONFLICT, "certificate_api.limit_reached".into())
|
||||
} else {
|
||||
auth::internal(error)
|
||||
}
|
||||
})?;
|
||||
let values = state
|
||||
.store
|
||||
.certificate_api_credentials()
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let mut value = certificate_api_status_value(values);
|
||||
value["credential"] = serde_json::to_value(&credential).map_err(auth::internal)?;
|
||||
value["token"] = Value::String(token);
|
||||
value["generated"] = Value::Bool(generated);
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
// Per-credential settings are intentionally separate from token rotation.
|
||||
// The input cannot include token/token_hash, and every read returns only metadata.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateApiEdit {
|
||||
comment: String,
|
||||
certificate_ids: Vec<String>,
|
||||
allow_deploy: bool,
|
||||
}
|
||||
|
||||
async fn certificate_api_detail(
|
||||
State(state): State<Arc<App>>,
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
|
||||
}
|
||||
let credential = state
|
||||
.store
|
||||
.certificate_api_credential(&key_id)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
|
||||
Ok(Json(json!({ "credential": credential })))
|
||||
}
|
||||
|
||||
async fn certificate_api_edit(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(key_id): Path<String>,
|
||||
Json(input): Json<CertificateApiEdit>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
|
||||
}
|
||||
let comment = input.comment.trim();
|
||||
if !valid_certificate_api_comment(comment) {
|
||||
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "certificate_api.invalid_comment".into()));
|
||||
}
|
||||
let mut certificate_ids: Vec<String> = input
|
||||
.certificate_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
certificate_ids.sort();
|
||||
certificate_ids.dedup();
|
||||
if certificate_ids.is_empty()
|
||||
|| certificate_ids.len() > 32
|
||||
|| certificate_ids.iter().any(|value| value == "*" || !crate::config::valid_id(value))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_certificate_ids".into(),
|
||||
));
|
||||
}
|
||||
let credential = state
|
||||
.store
|
||||
.edit_certificate_api_credential(
|
||||
&key_id, comment, input.allow_deploy, &certificate_ids, &session.username,
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
|
||||
Ok(Json(json!({ "credential": credential })))
|
||||
}
|
||||
|
||||
async fn certificate_api_regenerate(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
|
||||
}
|
||||
let token = auth::random_secret();
|
||||
let hash = auth::secret_hash(&token);
|
||||
let credential = state
|
||||
.store
|
||||
.regenerate_certificate_api_token(&key_id, &hash, &session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
|
||||
// The only endpoint in this flow that returns the new bearer secret.
|
||||
Ok(Json(json!({ "credential": credential, "token": token })))
|
||||
}
|
||||
|
||||
async fn certificate_api_revoke(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"certificate_api.not_found".into(),
|
||||
));
|
||||
}
|
||||
let revoked = state
|
||||
.store
|
||||
.revoke_certificate_api_credential(&key_id, &session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
if !revoked {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"certificate_api.not_found".into(),
|
||||
));
|
||||
}
|
||||
Ok(Json(json!({"revoked": true, "key_id": key_id})))
|
||||
}
|
||||
|
||||
async fn certificate_api_revoke_all(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let revoked = state
|
||||
.store
|
||||
.revoke_all_certificate_api_credentials(&session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"configured": false, "revoked": revoked})))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ include!("logging_api.rs");
|
||||
include!("file_logs.rs");
|
||||
include!("file_log_search.rs");
|
||||
include!("operations.rs");
|
||||
include!("certificate_api.rs");
|
||||
include!("certificates.rs");
|
||||
include!("tail.rs");
|
||||
|
||||
|
||||
+283
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,164 +326,6 @@ async fn certificate_upload(
|
||||
Ok(Json(json!({"certificate":certificate, "staged":true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateApiRotate {
|
||||
#[serde(default)]
|
||||
key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
#[serde(default)]
|
||||
certificate_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
allow_deploy: bool,
|
||||
}
|
||||
|
||||
fn valid_certificate_api_comment(value: &str) -> bool {
|
||||
value.len() <= 256 && !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn certificate_api_status_value(values: Vec<crate::db::CertificateApiCredential>) -> Value {
|
||||
let legacy = values.first();
|
||||
json!({
|
||||
"configured": !values.is_empty(),
|
||||
"key_id": legacy.map(|value| value.key_id.clone()),
|
||||
"created_at": legacy.map(|value| value.created_at),
|
||||
"last_used_at": legacy.and_then(|value| value.last_used_at),
|
||||
"credentials": values,
|
||||
"max_credentials": crate::db::CERTIFICATE_API_CREDENTIAL_CAPACITY,
|
||||
"scopes": ["certificates:deploy", "certificates:reload"],
|
||||
})
|
||||
}
|
||||
|
||||
async fn certificate_api_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
let values = state
|
||||
.store
|
||||
.certificate_api_credentials()
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(certificate_api_status_value(values)))
|
||||
}
|
||||
|
||||
async fn certificate_api_rotate(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<CertificateApiRotate>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let generated = input.token.is_none();
|
||||
let token = input.token.unwrap_or_else(auth::random_secret);
|
||||
if !valid_certificate_api_token(&token) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_token_format".into(),
|
||||
));
|
||||
}
|
||||
let key_id = input.key_id.unwrap_or_else(|| {
|
||||
let random = auth::random_secret();
|
||||
format!("certapi-{}", &random[..12])
|
||||
});
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_key_id".into(),
|
||||
));
|
||||
}
|
||||
let comment = input.comment.unwrap_or_default().trim().to_owned();
|
||||
if !valid_certificate_api_comment(&comment) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_comment".into(),
|
||||
));
|
||||
}
|
||||
let mut certificate_ids: Vec<String> = input
|
||||
.certificate_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
certificate_ids.sort();
|
||||
certificate_ids.dedup();
|
||||
if certificate_ids.is_empty()
|
||||
|| certificate_ids.len() > 32
|
||||
|| certificate_ids
|
||||
.iter()
|
||||
.any(|value| value == "*" || !crate::config::valid_id(value))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_certificate_ids".into(),
|
||||
));
|
||||
}
|
||||
let token_hash = auth::secret_hash(&token);
|
||||
let credential = state
|
||||
.store
|
||||
.upsert_certificate_api_credential(
|
||||
&key_id,
|
||||
&token_hash,
|
||||
&comment,
|
||||
input.allow_deploy,
|
||||
&certificate_ids,
|
||||
&session.username,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.to_string().contains("credential limit reached") {
|
||||
ApiError(StatusCode::CONFLICT, "certificate_api.limit_reached".into())
|
||||
} else {
|
||||
auth::internal(error)
|
||||
}
|
||||
})?;
|
||||
let values = state
|
||||
.store
|
||||
.certificate_api_credentials()
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let mut value = certificate_api_status_value(values);
|
||||
value["credential"] = serde_json::to_value(&credential).map_err(auth::internal)?;
|
||||
value["token"] = Value::String(token);
|
||||
value["generated"] = Value::Bool(generated);
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn certificate_api_revoke(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"certificate_api.not_found".into(),
|
||||
));
|
||||
}
|
||||
let revoked = state
|
||||
.store
|
||||
.revoke_certificate_api_credential(&key_id, &session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
if !revoked {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"certificate_api.not_found".into(),
|
||||
));
|
||||
}
|
||||
Ok(Json(json!({"revoked": true, "key_id": key_id})))
|
||||
}
|
||||
|
||||
async fn certificate_api_revoke_all(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let revoked = state
|
||||
.store
|
||||
.revoke_all_certificate_api_credentials(&session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"configured": false, "revoked": revoked})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateDeploy {
|
||||
|
||||
+9
-1
@@ -71,6 +71,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
include_str!("web/transform-panel.js"),
|
||||
include_str!("web/acme.js"),
|
||||
include_str!("web/settings.js"),
|
||||
include_str!("web/certificate-api.js"),
|
||||
include_str!("web/file-logs.js"),
|
||||
]
|
||||
.join("\n")
|
||||
@@ -181,7 +182,14 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/certificate-api/{key_id}",
|
||||
delete(certificate_api_revoke).layer(DefaultBodyLimit::max(4096)),
|
||||
get(certificate_api_detail)
|
||||
.patch(certificate_api_edit)
|
||||
.delete(certificate_api_revoke)
|
||||
.layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route(
|
||||
"/api/certificate-api/{key_id}/regenerate",
|
||||
post(certificate_api_regenerate).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route("/api/traffic", get(traffic_status))
|
||||
.route("/api/cache", get(cache_status))
|
||||
|
||||
@@ -1284,6 +1284,156 @@ mod tests {
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_api_settings_read_edit_and_regenerate_without_exposing_existing_token() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let existing_token = "C".repeat(64);
|
||||
let admin = |method: Method, path: &str, body: serde_json::Value| {
|
||||
request(path)
|
||||
.method(method)
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
let external = |path: &str, token: &str| {
|
||||
request(path)
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-rustproxy-certificate-key", "certbot-prod")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap()
|
||||
};
|
||||
let created = router.clone().oneshot(admin(
|
||||
Method::PUT,
|
||||
"/api/certificate-api",
|
||||
json!({
|
||||
"key_id": "certbot-prod", "token": existing_token,
|
||||
"comment": "first comment", "certificate_ids": ["first-cert"], "allow_deploy": false
|
||||
}),
|
||||
)).await.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::OK);
|
||||
let created = json_body(created).await;
|
||||
assert_eq!(created["token"], existing_token);
|
||||
let before_hash: String = sqlx::query_scalar(
|
||||
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
|
||||
).fetch_one(&store.pool).await.unwrap();
|
||||
assert_eq!(before_hash, auth::secret_hash(&existing_token));
|
||||
|
||||
let detail = router.clone().oneshot(admin(
|
||||
Method::GET, "/api/certificate-api/certbot-prod", json!({}),
|
||||
)).await.unwrap();
|
||||
assert_eq!(detail.status(), StatusCode::OK);
|
||||
let detail = json_body(detail).await;
|
||||
assert_eq!(detail["credential"]["comment"], "first comment");
|
||||
assert_eq!(detail["credential"]["certificate_ids"], json!(["first-cert"]));
|
||||
assert!(detail.get("token").is_none());
|
||||
assert!(detail["credential"].get("token").is_none());
|
||||
assert!(detail["credential"].get("token_hash").is_none());
|
||||
let created_at = detail["credential"]["created_at"].clone();
|
||||
|
||||
// A bearer-only automation client cannot read or modify management records.
|
||||
let denied = router.clone().oneshot(
|
||||
request("/api/certificate-api/certbot-prod")
|
||||
.header("authorization", format!("Bearer {existing_token}"))
|
||||
.header("x-rustproxy-certificate-key", "certbot-prod")
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
|
||||
let no_csrf = router.clone().oneshot(
|
||||
request("/api/certificate-api/certbot-prod/regenerate")
|
||||
.method("POST").header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
assert_eq!(no_csrf.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let bad_edit = router.clone().oneshot(admin(
|
||||
Method::PATCH, "/api/certificate-api/certbot-prod",
|
||||
json!({"comment":"changed", "certificate_ids":["other-cert"], "allow_deploy":true, "token":"should-not-be-accepted"}),
|
||||
)).await.unwrap();
|
||||
assert!(bad_edit.status().is_client_error());
|
||||
let wildcard = router.clone().oneshot(admin(
|
||||
Method::PATCH, "/api/certificate-api/certbot-prod",
|
||||
json!({"comment":"changed", "certificate_ids":["*"], "allow_deploy":true}),
|
||||
)).await.unwrap();
|
||||
assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// An edit changes only metadata and grants, not the current bearer secret.
|
||||
let edited = router.clone().oneshot(admin(
|
||||
Method::PATCH, "/api/certificate-api/certbot-prod",
|
||||
json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}),
|
||||
)).await.unwrap();
|
||||
assert_eq!(edited.status(), StatusCode::OK);
|
||||
let edited = json_body(edited).await;
|
||||
assert_eq!(edited["credential"]["comment"], "updated owner");
|
||||
assert_eq!(edited["credential"]["certificate_ids"], json!(["new-cert"]));
|
||||
assert_eq!(edited["credential"]["allow_deploy"], true);
|
||||
assert_eq!(edited["credential"]["created_at"], created_at);
|
||||
assert!(edited.get("token").is_none());
|
||||
let hash_after_edit: String = sqlx::query_scalar(
|
||||
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
|
||||
).fetch_one(&store.pool).await.unwrap();
|
||||
assert_eq!(hash_after_edit, before_hash);
|
||||
let allowed = router.clone().oneshot(external(
|
||||
"/api/certificates/new-cert/reload", &existing_token,
|
||||
)).await.unwrap();
|
||||
assert_eq!(allowed.status(), StatusCode::NOT_FOUND);
|
||||
let old_id = router.clone().oneshot(external(
|
||||
"/api/certificates/first-cert/reload", &existing_token,
|
||||
)).await.unwrap();
|
||||
assert_eq!(old_id.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let regenerated = router.clone().oneshot(admin(
|
||||
Method::POST, "/api/certificate-api/certbot-prod/regenerate", json!({}),
|
||||
)).await.unwrap();
|
||||
assert_eq!(regenerated.status(), StatusCode::OK);
|
||||
let regenerated = json_body(regenerated).await;
|
||||
let new_token = regenerated["token"].as_str().unwrap().to_owned();
|
||||
assert!(new_token.len() >= 32);
|
||||
assert_ne!(new_token, existing_token);
|
||||
assert_eq!(regenerated["credential"]["comment"], "updated owner");
|
||||
assert_eq!(regenerated["credential"]["allow_deploy"], true);
|
||||
assert_eq!(regenerated["credential"]["certificate_ids"], json!(["new-cert"]));
|
||||
assert!(regenerated["credential"]["last_used_at"].is_null());
|
||||
let raw: String = sqlx::query_scalar(
|
||||
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
|
||||
).fetch_one(&store.pool).await.unwrap();
|
||||
assert_eq!(raw, auth::secret_hash(&new_token));
|
||||
assert_ne!(raw, new_token);
|
||||
assert_ne!(raw, before_hash);
|
||||
assert_eq!(router.clone().oneshot(external(
|
||||
"/api/certificates/new-cert/reload", &existing_token,
|
||||
)).await.unwrap().status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(router.clone().oneshot(external(
|
||||
"/api/certificates/new-cert/reload", &new_token,
|
||||
)).await.unwrap().status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let after = router.clone().oneshot(admin(
|
||||
Method::GET, "/api/certificate-api/certbot-prod", json!({}),
|
||||
)).await.unwrap();
|
||||
let after = json_body(after).await;
|
||||
assert!(after.get("token").is_none());
|
||||
assert!(after["credential"].get("token").is_none());
|
||||
assert!(after["credential"].get("token_hash").is_none());
|
||||
let missing = router.clone().oneshot(admin(
|
||||
Method::POST, "/api/certificate-api/missing-key/regenerate", json!({}),
|
||||
)).await.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
let audit: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT action FROM admin_audit WHERE action LIKE 'certificate_api.%' ORDER BY id",
|
||||
).fetch_all(&store.pool).await.unwrap();
|
||||
assert!(audit.iter().any(|entry| entry == "certificate_api.edit"));
|
||||
assert!(audit.iter().any(|entry| entry == "certificate_api.regenerate"));
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -2867,6 +2867,7 @@
|
||||
credentials are reload-only by default and restricted to explicit certificate IDs.
|
||||
Deploy is optional; tokens cannot read configuration or use other administration APIs.</p>
|
||||
<p class="fine" id="certificate-api-status" role="status"></p>
|
||||
<p class="fine" data-i18n="certificateApiEditNote" id="certificate-api-edit-note" hidden>Edit the settings below without changing the bearer token. Key ID cannot be changed.</p>
|
||||
<label><span data-i18n="certificateApiKeyId">Key ID</span><input
|
||||
id="certificate-api-key-id" maxlength="64" placeholder="certbot-main" /></label>
|
||||
<label><span data-i18n="certificateApiComment">Comment</span><input
|
||||
@@ -2878,14 +2879,14 @@
|
||||
<label class="check"><input id="certificate-api-allow-deploy" type="checkbox" /><span
|
||||
data-i18n="certificateApiAllowDeploy">Also allow deploy</span></label>
|
||||
<p class="fine" data-i18n="certificateApiAllowDeployHelp">Leave disabled for Certbot renewal hooks. Reload-only is the recommended least-privilege mode.</p>
|
||||
<label><span data-i18n="certificateApiToken">Bearer token (optional custom
|
||||
<label id="certificate-api-token-field"><span data-i18n="certificateApiToken">Bearer token (optional custom
|
||||
value)</span><input autocomplete="off" id="certificate-api-token"
|
||||
maxlength="128" minlength="32"
|
||||
placeholder="Leave empty to generate a strong token" type="password" /></label>
|
||||
<p class="fine" data-i18n="certificateApiTokenHelp">Each cleartext token is shown only
|
||||
after generation/rotation. RustProxy stores only its hash.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="certificateApiRotate"
|
||||
type="submit">Generate / rotate token</button><button class="outline danger"
|
||||
id="certificate-api-save" type="submit">Generate / rotate token</button><button class="outline" data-i18n="certificateApiCancelEdit" id="certificate-api-cancel-edit" hidden type="button">Cancel edit</button><button class="outline danger"
|
||||
data-i18n="certificateApiRevokeAll" id="certificate-api-revoke-all"
|
||||
type="button">Revoke all tokens</button></div>
|
||||
<div hidden id="certificate-api-secret-wrap">
|
||||
|
||||
@@ -114,78 +114,6 @@
|
||||
button.addEventListener("keydown", event => { const index = httpTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % httpTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + httpTabs.length - 1) % httpTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = httpTabs.length - 1; else return; event.preventDefault(); selectHttpTab(httpTabs[next].dataset.httpSettingsTab, true); });
|
||||
}
|
||||
selectHttpTab("http2");
|
||||
let certificateApiState = null, certificateApiLoading = false;
|
||||
function certificateApiCredentials() {
|
||||
const values = certificateApiState?.credentials;
|
||||
if (Array.isArray(values)) return values;
|
||||
if (certificateApiState?.configured && certificateApiState?.key_id) return [{ key_id: certificateApiState.key_id, comment: "", created_at: certificateApiState.created_at, last_used_at: certificateApiState.last_used_at }];
|
||||
return [];
|
||||
}
|
||||
function certificateApiExample() {
|
||||
const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main";
|
||||
const enteredIds = $("certificate-api-certificate-ids").value.split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
|
||||
const selected = enteredIds[0] || certificateApiCredentials()[0]?.certificate_ids?.find(value => value !== "*") || "CERTIFICATE_ID";
|
||||
const reload = `curl --fail --show-error --request POST \
|
||||
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
|
||||
--header 'X-RustProxy-Certificate-Key: ${key}' \
|
||||
${location.origin}/api/certificates/${selected}/reload`;
|
||||
if (!$("certificate-api-allow-deploy").checked) {
|
||||
$("certificate-api-curl").textContent = reload;
|
||||
return;
|
||||
}
|
||||
$("certificate-api-curl").textContent = `${reload}
|
||||
|
||||
# Optional higher-privilege deploy for the same allowlisted certificate IDs:
|
||||
curl --fail --show-error --request POST \
|
||||
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
|
||||
--header 'X-RustProxy-Certificate-Key: ${key}' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data @deploy.json \
|
||||
${location.origin}/api/certificates/deploy`;
|
||||
}
|
||||
function renderCertificateApi() {
|
||||
const state = certificateApiState, target = $("certificate-api-credentials");
|
||||
if (!state) { $("certificate-api-status").textContent = t("certificateApiUnknown"); if (target) target.replaceChildren(); certificateApiExample(); return; }
|
||||
const credentials = certificateApiCredentials(), max = Number(state.max_credentials) || 32;
|
||||
$("certificate-api-status").textContent = credentials.length
|
||||
? t("certificateApiConfiguredCount").replace("{count}", credentials.length).replace("{max}", max)
|
||||
: t("certificateApiDisabled");
|
||||
$("certificate-api-revoke-all").disabled = !credentials.length;
|
||||
if (target) {
|
||||
target.replaceChildren();
|
||||
for (const credential of credentials) {
|
||||
const row = el("div", undefined, "certificate-api-credential-row");
|
||||
const info = el("div", undefined, "certificate-api-credential-info");
|
||||
info.append(el("b", credential.key_id || "-", "mono"));
|
||||
if (credential.comment) info.append(el("span", credential.comment, "fine"));
|
||||
const scope = credential.allow_deploy ? t("certificateApiScopeDeployReload") : t("certificateApiScopeReloadOnly");
|
||||
const ids = Array.isArray(credential.certificate_ids) ? credential.certificate_ids.join(", ") : "-";
|
||||
info.append(el("span", `${scope} · ${t("certificateApiAllowedIds")}: ${ids}`, "fine mono"));
|
||||
const lastUsed = credential.last_used_at ? formatDateTime(Number(credential.last_used_at) * 1000) : t("certificateApiNeverUsed");
|
||||
info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine"));
|
||||
const remove = action(t("certificateApiRevoke"), async () => {
|
||||
if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return;
|
||||
remove.disabled = true;
|
||||
try {
|
||||
await api(`/api/certificate-api/${encodeURIComponent(credential.key_id)}`, { method: "DELETE" });
|
||||
certificateApiState.credentials = certificateApiCredentials().filter(value => value.key_id !== credential.key_id);
|
||||
certificateApiState.configured = certificateApiState.credentials.length > 0;
|
||||
renderCertificateApi(); toast(t("certificateApiRevoked"));
|
||||
} catch (error) { toast(error.message, true); remove.disabled = false; }
|
||||
});
|
||||
remove.classList.add("danger");
|
||||
row.append(info, remove); target.append(row);
|
||||
}
|
||||
}
|
||||
certificateApiExample();
|
||||
}
|
||||
async function loadCertificateApi(force = false) {
|
||||
if (!authenticated || certificateApiLoading || (certificateApiState && !force)) return;
|
||||
certificateApiLoading = true;
|
||||
try { certificateApiState = await api("/api/certificate-api"); renderCertificateApi(); }
|
||||
catch (error) { $("certificate-api-status").textContent = error.message; }
|
||||
finally { certificateApiLoading = false; }
|
||||
}
|
||||
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_traffic: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
|
||||
const geoipDefaults = {
|
||||
enabled: true, format: "csv_pair",
|
||||
@@ -303,7 +231,7 @@ curl --fail --show-error --request POST \
|
||||
const effective = latestStatus?.tls?.effective_cipher_suites || [];
|
||||
$("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : "";
|
||||
$("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : "";
|
||||
renderCertificateApi(); loadCertificateApi();
|
||||
window.rustProxyCertificateApi?.render(); window.rustProxyCertificateApi?.load();
|
||||
}
|
||||
$("geoip-format").addEventListener("change", syncGeoipFormat); $("geoip-enabled").addEventListener("change", syncGeoipFormat);
|
||||
bindSettingsForm("geoip-form", next => {
|
||||
@@ -343,27 +271,6 @@ curl --fail --show-error --request POST \
|
||||
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
|
||||
next.tls ||= {}; next.tls.policy = { tls12: $("tls-policy-12").checked, tls13: $("tls-policy-13").checked, cipher_suites: $("tls-policy-ciphers").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean) };
|
||||
}, "tls");
|
||||
$("certificate-api-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const button = event.submitter || $("certificate-api-form").querySelector("button[type=submit]"); button.disabled = true;
|
||||
try {
|
||||
const keyId = $("certificate-api-key-id").value.trim(), token = $("certificate-api-token").value.trim(), comment = $("certificate-api-comment").value.trim();
|
||||
const certificateIds = $("certificate-api-certificate-ids").value.split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (!certificateIds.length) throw fieldError($("certificate-api-certificate-ids"), t("certificateApiCertificateIdsRequired"));
|
||||
const allowDeploy = $("certificate-api-allow-deploy").checked;
|
||||
const payload = await api("/api/certificate-api", { method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment, certificate_ids: certificateIds, allow_deploy: allowDeploy }) });
|
||||
const clearToken = payload.token || ""; certificateApiState = { ...payload }; delete certificateApiState.token; delete certificateApiState.credential;
|
||||
$("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = clearToken; $("certificate-api-secret-wrap").hidden = !clearToken; renderCertificateApi(); toast(t("certificateApiRotated"));
|
||||
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
|
||||
});
|
||||
$("certificate-api-revoke-all").addEventListener("click", async () => {
|
||||
if (!await appConfirm(t("certificateApiRevokeAllConfirm"), { danger: true })) return;
|
||||
const button = $("certificate-api-revoke-all"); button.disabled = true;
|
||||
try { await api("/api/certificate-api", { method: "DELETE" }); certificateApiState = { configured: false, credentials: [], max_credentials: certificateApiState?.max_credentials || 32, scopes: certificateApiState?.scopes || [] }; $("certificate-api-secret-wrap").hidden = true; $("certificate-api-secret").textContent = ""; renderCertificateApi(); toast(t("certificateApiRevoked")); }
|
||||
catch (error) { toast(error.message, true); } finally { button.disabled = false; }
|
||||
});
|
||||
$("certificate-api-key-id").addEventListener("input", certificateApiExample);
|
||||
$("certificate-api-certificate-ids").addEventListener("input", certificateApiExample);
|
||||
$("certificate-api-allow-deploy").addEventListener("change", certificateApiExample);
|
||||
$("admin-tls").addEventListener("change", () => { $("admin-certificate").disabled = !$("admin-tls").checked; });
|
||||
$("prometheus-form").addEventListener("input", example);
|
||||
$("prom-preview").addEventListener("click", async () => {
|
||||
@@ -373,6 +280,6 @@ curl --fail --show-error --request POST \
|
||||
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
|
||||
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
|
||||
});
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); window.rustProxyCertificateApi?.reset(); $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
|
||||
select(active); render();
|
||||
})();
|
||||
+34
-3
@@ -8081,15 +8081,46 @@ body[data-density="compact"] .page-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#certificate-api-token-field[hidden],
|
||||
#certificate-api-edit-note[hidden],
|
||||
#certificate-api-cancel-edit[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.certificate-api-credential-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.certificate-api-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.certificate-api-details {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.certificate-api-detail-line {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.certificate-api-credential-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.certificate-api-credential-info {
|
||||
|
||||
+139
-36
@@ -5,6 +5,41 @@ use super::{
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use sqlx::Row;
|
||||
|
||||
fn normalized_certificate_api_ids(comment: &str, certificate_ids: &[String]) -> Result<Vec<String>> {
|
||||
ensure!(
|
||||
comment.len() <= 256 && !comment.chars().any(char::is_control),
|
||||
"invalid certificate API comment"
|
||||
);
|
||||
ensure!(
|
||||
!certificate_ids.is_empty() && certificate_ids.len() <= 32,
|
||||
"certificate API token must allow between 1 and 32 certificate ids"
|
||||
);
|
||||
let mut normalized = certificate_ids.to_vec();
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
ensure!(
|
||||
normalized.iter().all(|value| value != "*" && crate::config::valid_id(value)),
|
||||
"invalid certificate API certificate id"
|
||||
);
|
||||
ensure!(
|
||||
serde_json::to_string(&normalized)?.len() <= 4096,
|
||||
"certificate API certificate id list is too large"
|
||||
);
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn credential_from_row(row: sqlx::sqlite::SqliteRow) -> Result<CertificateApiCredential> {
|
||||
let raw_ids: String = row.try_get("certificate_ids")?;
|
||||
Ok(CertificateApiCredential {
|
||||
key_id: row.try_get("key_id")?,
|
||||
comment: row.try_get("comment")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
allow_deploy: row.try_get::<i64, _>("allow_deploy")? != 0,
|
||||
certificate_ids: serde_json::from_str(&raw_ids).context("invalid certificate API grant ids")?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn certificate_api_credentials(&self) -> Result<Vec<CertificateApiCredential>> {
|
||||
let rows = sqlx::query(
|
||||
@@ -17,21 +52,7 @@ impl Store {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let raw_ids: String = row.try_get("certificate_ids")?;
|
||||
let certificate_ids: Vec<String> =
|
||||
serde_json::from_str(&raw_ids).context("invalid certificate API grant ids")?;
|
||||
Ok(CertificateApiCredential {
|
||||
key_id: row.try_get("key_id")?,
|
||||
comment: row.try_get("comment")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
allow_deploy: row.try_get::<i64, _>("allow_deploy")? != 0,
|
||||
certificate_ids,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
rows.into_iter().map(credential_from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn upsert_certificate_api_credential(
|
||||
@@ -51,28 +72,8 @@ impl Store {
|
||||
token_hash.len() == 64 && token_hash.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
"invalid certificate API token hash"
|
||||
);
|
||||
ensure!(
|
||||
comment.len() <= 256 && !comment.chars().any(char::is_control),
|
||||
"invalid certificate API comment"
|
||||
);
|
||||
ensure!(
|
||||
!certificate_ids.is_empty() && certificate_ids.len() <= 32,
|
||||
"certificate API token must allow between 1 and 32 certificate ids"
|
||||
);
|
||||
let mut normalized_ids = certificate_ids.to_vec();
|
||||
normalized_ids.sort();
|
||||
normalized_ids.dedup();
|
||||
ensure!(
|
||||
normalized_ids
|
||||
.iter()
|
||||
.all(|value| value != "*" && crate::config::valid_id(value)),
|
||||
"invalid certificate API certificate id"
|
||||
);
|
||||
let normalized_ids = normalized_certificate_api_ids(comment, certificate_ids)?;
|
||||
let certificate_ids_json = serde_json::to_string(&normalized_ids)?;
|
||||
ensure!(
|
||||
certificate_ids_json.len() <= 4096,
|
||||
"certificate API certificate id list is too large"
|
||||
);
|
||||
let now = crate::auth::now();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let exists: i64 =
|
||||
@@ -134,6 +135,108 @@ impl Store {
|
||||
})
|
||||
}
|
||||
|
||||
// Metadata access never selects token_hash, including for a single credential.
|
||||
pub async fn certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
) -> Result<Option<CertificateApiCredential>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT c.key_id, c.comment, c.created_at, c.last_used_at, \
|
||||
COALESCE(g.allow_deploy, 0) AS allow_deploy, \
|
||||
COALESCE(g.certificate_ids, '[\"*\"]') AS certificate_ids \
|
||||
FROM certificate_api_credentials c \
|
||||
LEFT JOIN certificate_api_grants g ON g.key_id = c.key_id \
|
||||
WHERE c.key_id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(credential_from_row).transpose()
|
||||
}
|
||||
|
||||
// Do not touch token_hash, created_at, or last_used_at while editing settings.
|
||||
pub async fn edit_certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
comment: &str,
|
||||
allow_deploy: bool,
|
||||
certificate_ids: &[String],
|
||||
actor: &str,
|
||||
) -> Result<Option<CertificateApiCredential>> {
|
||||
ensure!(
|
||||
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
|
||||
"invalid certificate API key id"
|
||||
);
|
||||
let normalized_ids = normalized_certificate_api_ids(comment, certificate_ids)?;
|
||||
let ids_json = serde_json::to_string(&normalized_ids)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let changed = sqlx::query("UPDATE certificate_api_credentials SET comment = ? WHERE key_id = ?")
|
||||
.bind(comment)
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if changed.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO certificate_api_grants(key_id, allow_deploy, certificate_ids) VALUES(?, ?, ?) \
|
||||
ON CONFLICT(key_id) DO UPDATE SET allow_deploy=excluded.allow_deploy, certificate_ids=excluded.certificate_ids",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(if allow_deploy { 1_i64 } else { 0_i64 })
|
||||
.bind(&ids_json)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.edit",
|
||||
&format!(
|
||||
"key_id={key_id}; scope={}; certificate_ids={}",
|
||||
if allow_deploy { "deploy+reload" } else { "reload" },
|
||||
normalized_ids.join(",")
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
self.certificate_api_credential(key_id).await
|
||||
}
|
||||
|
||||
// Hash-only rotation preserves comment/grants and invalidates the old token at commit.
|
||||
pub async fn regenerate_certificate_api_token(
|
||||
&self,
|
||||
key_id: &str,
|
||||
token_hash: &str,
|
||||
actor: &str,
|
||||
) -> Result<Option<CertificateApiCredential>> {
|
||||
ensure!(
|
||||
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
|
||||
"invalid certificate API key id"
|
||||
);
|
||||
ensure!(
|
||||
token_hash.len() == 64 && token_hash.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
"invalid certificate API token hash"
|
||||
);
|
||||
let now = crate::auth::now();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let changed = sqlx::query(
|
||||
"UPDATE certificate_api_credentials \
|
||||
SET token_hash = ?, created_at = ?, last_used_at = NULL WHERE key_id = ?",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(now)
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if changed.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
audit_tx(&mut tx, actor, "certificate_api.regenerate", &format!("key_id={key_id}"))
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
self.certificate_api_credential(key_id).await
|
||||
}
|
||||
|
||||
pub async fn revoke_certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.31.9 Certificate API source/OpenAPI/UI contracts; no Rust runtime claims."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
read = lambda path: (root / path).read_text(encoding='utf-8')
|
||||
version = tomllib.loads(read('Cargo.toml'))['package']['version']
|
||||
assert version == '0.31.9', version
|
||||
spec = json.loads(read('src/admin/openapi.json'))
|
||||
assert spec['info']['version'] == version
|
||||
paths = spec['paths']
|
||||
assert set(paths['/api/certificate-api/{key_id}']) == {'get', 'patch', 'delete'}
|
||||
assert set(paths['/api/certificate-api/{key_id}/regenerate']) == {'post'}
|
||||
for method, path in [('get', '/api/certificate-api/{key_id}'), ('patch', '/api/certificate-api/{key_id}'),
|
||||
('post', '/api/certificate-api/{key_id}/regenerate')]:
|
||||
operation = paths[path][method]
|
||||
assert operation['security'] == ([{'AdminSession': []}] if method == 'get' else [{'AdminSession': [], 'CsrfToken': []}])
|
||||
assert operation['parameters'][0]['name'] == 'key_id'
|
||||
assert '401' in operation['responses'] and '404' in operation['responses']
|
||||
edit = spec['components']['schemas']['CertificateApiEdit']
|
||||
assert edit['additionalProperties'] is False
|
||||
assert set(edit['required']) == {'comment', 'certificate_ids', 'allow_deploy'}
|
||||
assert not {'token', 'token_hash', 'key_id'} & edit['properties'].keys()
|
||||
assert set(spec['components']['schemas']['CertificateApiDetailResult']['properties']) == {'credential'}
|
||||
assert set(spec['components']['schemas']['CertificateApiRegenerateResult']['properties']) == {'credential', 'token'}
|
||||
assert 'readOnly' in spec['components']['schemas']['CertificateApiRegenerateResult']['properties']['token']
|
||||
backend = read('src/admin/certificate_api.rs')
|
||||
router = read('src/admin/router.rs')
|
||||
db = read('src/db/certificate_api.rs')
|
||||
assert 'include!("certificate_api.rs")' in read('src/admin/mod.rs')
|
||||
for symbol in ('certificate_api_detail', 'certificate_api_edit', 'certificate_api_regenerate'):
|
||||
assert f'async fn {symbol}(' in backend
|
||||
assert symbol in router
|
||||
assert 'deny_unknown_fields' in backend
|
||||
read_source = backend.split('async fn certificate_api_detail(')[1].split('async fn certificate_api_edit(')[0]
|
||||
edit_source = backend.split('async fn certificate_api_edit(')[1].split('async fn certificate_api_regenerate(')[0]
|
||||
regenerate_source = backend.split('async fn certificate_api_regenerate(')[1].split('async fn certificate_api_revoke(')[0]
|
||||
assert '"token"' not in read_source
|
||||
assert '"token"' not in edit_source and '"token_hash"' not in edit_source
|
||||
assert '"token": token' in regenerate_source
|
||||
assert 'auth::random_secret()' in regenerate_source
|
||||
assert '"certificate_api.invalid_certificate_ids"' in edit_source
|
||||
update_source = db.split('pub async fn edit_certificate_api_credential(')[1].split('pub async fn regenerate_certificate_api_token(')[0]
|
||||
rotate_source = db.split('pub async fn regenerate_certificate_api_token(')[1].split('pub async fn revoke_certificate_api_credential(')[0]
|
||||
assert 'SET comment = ?' in update_source
|
||||
assert 'SET token_hash' not in update_source
|
||||
assert 'SET token_hash = ?, created_at = ?, last_used_at = NULL' in rotate_source
|
||||
assert 'certificate_api.edit' in update_source and 'certificate_api.regenerate' in rotate_source
|
||||
assert 'WHERE key_id = ?' in rotate_source
|
||||
assert 'certificate_api_operation(method: &Method' in router or 'fn certificate_api_operation(method: &Method' in read('src/admin/access.rs') + router
|
||||
assert 'if method != Method::POST' in router
|
||||
panel = read('src/admin/web/certificate-api.js')
|
||||
assert 'include_str!("web/certificate-api.js")' in router
|
||||
for marker in ('function certificateApiDetailNode(', 'beginCertificateApiEdit(', 'certificateApiEditingKey',
|
||||
'method: "PATCH"', '/regenerate', 'showCertificateApiSecret(data.token || "")',
|
||||
'clearCertificateApiSecret()', 'token-field', 'certificateApiTokenHidden'):
|
||||
assert marker in panel, marker
|
||||
for item in ('certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-secret', 'certificate-api-token-field'):
|
||||
assert f'id="{item}"' in read('src/admin/web/index.html')
|
||||
for language in ('en_US', 'pl_pl'):
|
||||
strings = json.loads(read(f'langs/{language}.json'))['translations']
|
||||
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
|
||||
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
|
||||
assert strings.get(key), (language, key)
|
||||
assert '0.31.9' in read('docs/TEST_REPORT.md')
|
||||
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
// Execute the real 0.31.9 Certificate API panel code against a simulated DOM/API.
|
||||
// This is not a compiled Rust proxy test.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = readFileSync('src/admin/web/certificate-api.js', 'utf8');
|
||||
const html = readFileSync('src/admin/web/index.html', 'utf8');
|
||||
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
|
||||
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
||||
for (const pack of [english, polish]) {
|
||||
for (const key of ['certificateApiView', 'certificateApiHide', 'certificateApiEdit',
|
||||
'certificateApiSave', 'certificateApiCancelEdit', 'certificateApiRegenerate',
|
||||
'certificateApiRegenerateConfirm', 'certificateApiRegenerated', 'certificateApiTokenHidden']) {
|
||||
assert.ok(pack[key], key);
|
||||
}
|
||||
}
|
||||
for (const id of ['certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-token-field',
|
||||
'certificate-api-secret', 'certificate-api-credentials', 'certificate-api-edit-note']) {
|
||||
assert.ok(html.includes(`id="${id}"`), id);
|
||||
}
|
||||
class FakeNode {
|
||||
constructor(tag = 'div', content = '', cls = '') {
|
||||
this.tagName = tag; this.value = ''; this.content = String(content ?? '');
|
||||
this.className = cls; this.children = []; this.events = {};
|
||||
this.dataset = {}; this.hidden = false; this.disabled = false;
|
||||
this.checked = false; this.readOnly = false;
|
||||
this.classList = { add: cls => { this.className += ` ${cls}`; } };
|
||||
}
|
||||
get textContent() { return this.content + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
|
||||
addEventListener(type, callback) { this.events[type] = callback; }
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = [...children]; this.content = ''; }
|
||||
scrollIntoView() { }
|
||||
focus() { }
|
||||
async fire(type, event = {}) { assert.ok(this.events[type], `${this.tagName} missing ${type}`); await this.events[type](event); }
|
||||
findByText(value) { return [this, ...this.children.flatMap(child => child.findAll())].find(child => child.content === value); }
|
||||
findAll() { return [this, ...this.children.flatMap(child => child.findAll())]; }
|
||||
}
|
||||
const fields = new Map();
|
||||
const dollar = id => {
|
||||
if (!fields.has(id)) fields.set(id, new FakeNode(id));
|
||||
return fields.get(id);
|
||||
};
|
||||
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
|
||||
let persisted = [{ key_id: 'renew-prod', comment: 'Old owner', certificate_ids: ['first-cert'],
|
||||
allow_deploy: false, created_at: 1690000000, last_used_at: 1700000000 }];
|
||||
let activeToken = 'original-secret-not-readable';
|
||||
const requests = [];
|
||||
const makeStatus = () => ({ configured: persisted.length > 0, credentials: structuredClone(persisted),
|
||||
max_credentials: 32, scopes: ['certificates:reload', 'certificates:deploy'] });
|
||||
async function api(path, options = {}) {
|
||||
const method = options.method || 'GET';
|
||||
const data = options.body ? JSON.parse(options.body) : {};
|
||||
requests.push({ path, method, data });
|
||||
if (path === '/api/certificate-api' && method === 'GET') return makeStatus();
|
||||
if (path === '/api/certificate-api' && method === 'DELETE') { persisted = []; return { revoked: 1 }; }
|
||||
if (path === '/api/certificate-api' && method === 'PUT') {
|
||||
activeToken = data.token || 'created-once-token';
|
||||
const entry = { key_id: data.key_id || 'generated-id', comment: data.comment,
|
||||
certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy,
|
||||
created_at: 1800000000, last_used_at: null };
|
||||
persisted.push(entry);
|
||||
return { ...makeStatus(), credential: entry, token: activeToken, generated: !data.token };
|
||||
}
|
||||
const match = path.match(/^\/api\/certificate-api\/([^/]+)(\/regenerate)?$/);
|
||||
assert.ok(match, `Unexpected ${method} ${path}`);
|
||||
const credential = persisted.find(entry => entry.key_id === match[1]);
|
||||
assert.ok(credential);
|
||||
if (method === 'GET') return { credential: structuredClone(credential) };
|
||||
if (method === 'PATCH') {
|
||||
assert.deepEqual(Object.keys(data).sort(), ['allow_deploy', 'certificate_ids', 'comment']);
|
||||
assert.ok(!('token' in data) && !('token_hash' in data));
|
||||
Object.assign(credential, { comment: data.comment, certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy });
|
||||
return { credential: structuredClone(credential) };
|
||||
}
|
||||
if (method === 'POST' && match[2] === '/regenerate') {
|
||||
activeToken = 'replacement-secret-once';
|
||||
credential.last_used_at = null;
|
||||
credential.created_at = 1800000001;
|
||||
return { credential: structuredClone(credential), token: activeToken };
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
persisted = persisted.filter(entry => entry.key_id !== credential.key_id);
|
||||
return { revoked: true };
|
||||
}
|
||||
throw new Error(`Unsupported ${method} ${path}`);
|
||||
}
|
||||
const toasts = [];
|
||||
const ctx = {
|
||||
document: {}, window: {}, $: dollar, el: node,
|
||||
action: (text, cb) => { const result = node('button', text, 'quiet'); result.addEventListener('click', cb); result.type = 'button'; return result; },
|
||||
t: key => english[key] || key,
|
||||
location: { origin: 'https://admin.example.test' },
|
||||
authenticated: true, api, appConfirm: async () => true,
|
||||
formatDateTime: timestamp => new Date(timestamp).toISOString(),
|
||||
toast: value => toasts.push(value),
|
||||
fieldError: (_, message) => new Error(message), showFieldError: () => {},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(source, ctx, { filename: 'src/admin/web/certificate-api.js' });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const list = dollar('certificate-api-credentials');
|
||||
const actionsFor = (rowIndex = 0) => list.children[rowIndex].children[0].children[1].children;
|
||||
const click = async (label, rowIndex = 0) => {
|
||||
const btn = actionsFor(rowIndex).find(item => item.content === label);
|
||||
assert.ok(btn, `Missing button: ${label}`);
|
||||
await btn.fire('click');
|
||||
};
|
||||
assert.equal(list.children.length, 1);
|
||||
assert.ok(requests.some(request => request.path === '/api/certificate-api' && request.method === 'GET'));
|
||||
|
||||
await click(english.certificateApiView);
|
||||
assert.equal(list.children[0].children.length, 2, 'details expanded');
|
||||
const previewText = list.children[0].children[1].textContent;
|
||||
assert.match(previewText, /renew-prod/);
|
||||
assert.match(previewText, /Old owner/);
|
||||
assert.match(previewText, /first-cert/);
|
||||
assert.match(previewText, /Unavailable/);
|
||||
assert.ok(!previewText.includes(activeToken), 'never show existing bearer token');
|
||||
assert.ok(requests.some(request => request.path === '/api/certificate-api/renew-prod' && request.method === 'GET'));
|
||||
|
||||
await click(english.certificateApiEdit);
|
||||
assert.equal(dollar('certificate-api-key-id').value, 'renew-prod');
|
||||
assert.equal(dollar('certificate-api-key-id').readOnly, true);
|
||||
assert.equal(dollar('certificate-api-comment').value, 'Old owner');
|
||||
assert.equal(dollar('certificate-api-token-field').hidden, true);
|
||||
assert.equal(dollar('certificate-api-save').content, english.certificateApiSave);
|
||||
assert.equal(dollar('certificate-api-cancel-edit').hidden, false);
|
||||
dollar('certificate-api-comment').value = 'New owner';
|
||||
dollar('certificate-api-certificate-ids').value = 'second-cert,third-cert';
|
||||
dollar('certificate-api-allow-deploy').checked = true;
|
||||
await dollar('certificate-api-form').fire('submit', { preventDefault() {} });
|
||||
assert.equal(persisted[0].comment, 'New owner');
|
||||
assert.deepEqual(persisted[0].certificate_ids, ['second-cert', 'third-cert']);
|
||||
assert.equal(persisted[0].allow_deploy, true);
|
||||
assert.equal(persisted[0].created_at, 1690000000, 'editing must not rotate');
|
||||
assert.equal(activeToken, 'original-secret-not-readable');
|
||||
assert.equal(dollar('certificate-api-token-field').hidden, false);
|
||||
assert.equal(dollar('certificate-api-save').content, english.certificateApiRotate);
|
||||
assert.equal(dollar('certificate-api-secret-wrap').hidden, true);
|
||||
|
||||
await click(english.certificateApiRegenerate);
|
||||
assert.equal(activeToken, 'replacement-secret-once');
|
||||
assert.equal(dollar('certificate-api-secret').textContent, activeToken);
|
||||
assert.equal(dollar('certificate-api-secret-wrap').hidden, false);
|
||||
assert.equal(persisted[0].comment, 'New owner', 'metadata unchanged by regeneration');
|
||||
assert.equal(persisted[0].allow_deploy, true);
|
||||
assert.equal(persisted[0].last_used_at, null);
|
||||
assert.equal(list.textContent.includes(activeToken), false, 'token not in persistent credential DOM');
|
||||
await click(english.certificateApiHide);
|
||||
assert.equal(dollar('certificate-api-secret').textContent, 'replacement-secret-once', 'collapsing is not a new API secret read');
|
||||
await click(english.certificateApiView);
|
||||
assert.equal(dollar('certificate-api-secret-wrap').hidden, true, 'reading settings does not re-expose the token');
|
||||
assert.equal(dollar('certificate-api-secret').textContent, '');
|
||||
assert.ok(!list.textContent.includes(activeToken));
|
||||
|
||||
ctx.window.rustProxyCertificateApi.reset();
|
||||
assert.equal(dollar('certificate-api-secret').textContent, '');
|
||||
assert.equal(list.children.length, 0);
|
||||
await ctx.window.rustProxyCertificateApi.load();
|
||||
assert.equal(list.children.length, 1);
|
||||
assert.ok(!list.textContent.includes(activeToken));
|
||||
assert.equal(requests.filter(item => item.path.endsWith('/regenerate')).length, 1);
|
||||
assert.ok(toasts.includes(english.certificateApiSaved));
|
||||
assert.ok(toasts.includes(english.certificateApiRegenerated));
|
||||
console.log('PASS: 0.31.9 Certificate API view/edit/regenerate/one-time secret UI (real module, simulated DOM/API).');
|
||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.31.8"
|
||||
assert manifest["package"]["version"] == "0.31.9"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
|
||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
||||
lock_path = root / "Cargo.lock"
|
||||
if lock_path.exists():
|
||||
lock = lock_path.read_text()
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.8"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.9"' in lock
|
||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||
for release_doc, marker in {
|
||||
"README.md": "# RustProxy 0.31.8",
|
||||
"CHANGELOG.md": "## 0.31.8",
|
||||
"ROADMAP.md": "## 0.31.8",
|
||||
"AGENTS.md": "Current source version: 0.31.8.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.8",
|
||||
"README.md": "# RustProxy 0.31.9",
|
||||
"CHANGELOG.md": "## 0.31.9",
|
||||
"ROADMAP.md": "## 0.31.9",
|
||||
"AGENTS.md": "Current source version: 0.31.9.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.9",
|
||||
}.items():
|
||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||
for marker in ['"version": "0.31.8"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
for marker in ['"version": "0.31.9"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
assert marker in openapi, marker
|
||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
@@ -569,6 +569,7 @@ assert 'const panelPaths = new Set' in login_js and 'panelPaths.has(location.pat
|
||||
assert '"/404-hosts"' in login_js
|
||||
assert 'async function redirectExistingSession' in login_js and '/api/auth/session' in login_js
|
||||
settings_js = (root / "src/admin/web/settings.js").read_text()
|
||||
certificate_settings_js = (root / "src/admin/web/certificate-api.js").read_text()
|
||||
assert '["/settings", "/configuration", "/logging"].includes(location.pathname)' in settings_js
|
||||
assert 'new URLSearchParams(location.search).get("tab")' in settings_js and 'function syncLocation()' in settings_js and 'history.replaceState' in settings_js
|
||||
assert 'url.pathname = "/settings"' in settings_js and 'url.searchParams.set("tab", active)' in settings_js
|
||||
@@ -607,7 +608,7 @@ assert 'data-i18n="processDiagnostics">Process<' in html
|
||||
for marker in ['function setOptionalMetric', 'setOptionalMetric("process-rss-anon"', 'setOptionalMetric("system-swap"', 'closest(".mini-live-chart").hidden']:
|
||||
assert marker in app_js, marker
|
||||
for marker in ['/api/certificate-api', 'certificate-api-form', 'certificate-api-revoke-all', 'certificate-api-credentials', 'data-cache-settings-tab']:
|
||||
assert marker in settings_js or marker in html, marker
|
||||
assert marker in settings_js or marker in certificate_settings_js or marker in html, marker
|
||||
db_source = rust_source(root, "src/db.rs")
|
||||
acme_source = rust_source(root, "src/acme.rs")
|
||||
acme_runtime_source = (root / "src/acme/runtime_env.rs").read_text(encoding="utf-8") + (root / "src/acme/runtime_bootstrap.rs").read_text(encoding="utf-8")
|
||||
@@ -1395,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.31.8"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.31.9"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
@@ -1634,5 +1635,5 @@ for marker_0282 in [
|
||||
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
|
||||
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/INSTALL.md').read_text()
|
||||
|
||||
print("PASS: 0.31.8 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
||||
print("PASS: 0.31.9 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
||||
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the 0.31.8 dependency/source migration; optionally require a resolved lock.
|
||||
"""Check the 0.31.9 dependency/source migration; optionally require a resolved lock.
|
||||
|
||||
This is not a substitute for rustc, cargo metadata or runtime tests.
|
||||
"""
|
||||
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
||||
args = parser.parse_args()
|
||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||
assert manifest['package']['version'] == '0.31.8'
|
||||
assert manifest['package']['version'] == '0.31.9'
|
||||
assert manifest['package']['rust-version'] == '1.99'
|
||||
deps = manifest['dependencies']
|
||||
expected_requirements = {
|
||||
@@ -78,4 +78,4 @@ if missing:
|
||||
print(f'PENDING: lockfile still requires cargo update ({details})')
|
||||
else:
|
||||
print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required')
|
||||
print('PASS: 0.31.8 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
|
||||
print('PASS: 0.31.9 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
|
||||
@@ -164,4 +164,4 @@ config.docker_agents.agents = [{ ...agent, enabled: false }];
|
||||
ctx.window.rustProxyDocker.render();
|
||||
assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling');
|
||||
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
|
||||
console.log('PASS: 0.31.8 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
|
||||
console.log('PASS: 0.31.9 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.8 release."""
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.9 release."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
from rust_source import rust_source
|
||||
@@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# RustProxy 0.31.8")
|
||||
assert readme.startswith("# RustProxy 0.31.9")
|
||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
||||
assert '0.31.8' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '0.31.9' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
||||
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
||||
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
||||
|
||||
@@ -76,13 +76,13 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.8 source tree: missing {path}")
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
|
||||
# The Reader uses compile-time include_str! for its bundled Markdown. A
|
||||
# dangling registration is a real compiler error, unlike an omitted old
|
||||
# release audit. Match both reader_doc! and the expanded include_str! form.
|
||||
registry = root / "src/admin/documentation/documents.rs"
|
||||
if not registry.is_file():
|
||||
raise ValueError("Incomplete RustProxy 0.31.8 source tree: missing src/admin/documentation/documents.rs")
|
||||
raise ValueError("Incomplete RustProxy 0.31.9 source tree: missing src/admin/documentation/documents.rs")
|
||||
embedded_docs = set(re.findall(
|
||||
r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8")
|
||||
))
|
||||
@@ -91,7 +91,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration")
|
||||
text = (root / "src/lib.rs").read_text(encoding="utf-8")
|
||||
if '#[path = "auth/mod.rs"]' not in text:
|
||||
raise ValueError("src/lib.rs is not the 0.31.8 entry point; extract the complete package, not individual files.")
|
||||
raise ValueError("src/lib.rs is not the 0.31.9 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.31.8 source-only regression; does NOT claim Rust compilation or memory profiling."""
|
||||
"""0.31.9 source-only regression; does NOT claim Rust compilation or memory profiling."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.8'
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.8"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.8'
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.9'
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.9"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.9'
|
||||
|
||||
pages = read('src/pages.rs')
|
||||
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
||||
@@ -43,4 +43,4 @@ for marker in ('sort_by: Option<String>', 'sort_descending: Option<bool>', 'vali
|
||||
'file_log_field(record, field)', 'None, Some(_)', 'sort_file_log_records(&mut records'):
|
||||
assert marker in q, marker
|
||||
assert q.index('sort_file_log_records(&mut records') < q.index('let page = records')
|
||||
print('PASS: 0.31.8 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
|
||||
print('PASS: 0.31.9 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
|
||||
@@ -60,4 +60,4 @@ assert.equal(ctx.sortDescending, true);
|
||||
assert.equal(row.children[1].attrs['aria-sort'], 'descending');
|
||||
assert.equal(ctx.saved, 2);
|
||||
assert.equal(ctx.refreshed, 2);
|
||||
console.log('PASS: 0.31.8 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
|
||||
console.log('PASS: 0.31.9 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
|
||||
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert backup.is_dir()
|
||||
assert (backup / "src/obsolete.rs").is_file()
|
||||
assert not (target / "src/obsolete.rs").exists()
|
||||
assert 'version = "0.31.8"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.31.9"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||
assert (target / "langs/site.json").is_file()
|
||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert (target / "docker-agent/certificate.go").is_file()
|
||||
assert (target / "docker-agent/server.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.31.8"
|
||||
assert manifest["to_version"] == "0.31.9"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
|
||||
assert marker in admin, marker
|
||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||
assert '"/api/status"' not in anonymous_block
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.8"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.9"' in openapi_0241
|
||||
assert 'id="panel-logging"' not in html
|
||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||
assert marker in html, marker
|
||||
@@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None:
|
||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||
assert '"/api/config/history/restore"' in openapi
|
||||
for marker in ['"version": "0.31.8"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.31.9"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||
@@ -1315,7 +1315,7 @@ def main() -> None:
|
||||
source_contract_smoke()
|
||||
container_contract_smoke()
|
||||
grafana_contract_smoke()
|
||||
print("PASS: 0.31.8 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
print("PASS: 0.31.9 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
|
||||
const en = json('langs/en_US.json').translations;
|
||||
const pl = json('langs/pl_pl.json').translations;
|
||||
|
||||
assert.match(manifest, /version = "0\.31\.8"/);
|
||||
assert.match(manifest, /version = "0\.31\.9"/);
|
||||
assert.match(lib, /pub mod user_agent;/);
|
||||
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
||||
assert.match(fileLogs, /aggregate_min_count/);
|
||||
assert.match(fileLogs, /file-log-aggregate-copy/);
|
||||
|
||||
assert.equal(openapi.info.version, '0.31.8');
|
||||
assert.equal(openapi.info.version, '0.31.9');
|
||||
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
||||
|
||||
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
|
||||
const elements = new Map();
|
||||
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
||||
const status = {
|
||||
version: '0.31.8', uptime_seconds: 4201,
|
||||
version: '0.31.9', uptime_seconds: 4201,
|
||||
runtime: {
|
||||
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
||||
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
||||
@@ -63,7 +63,7 @@ const context = {
|
||||
vm.runInNewContext(code, context);
|
||||
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
||||
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.31.8');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.31.9');
|
||||
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
||||
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
||||
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
||||
@@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) {
|
||||
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
|
||||
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
|
||||
}
|
||||
console.log('PASS: 0.31.8 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
console.log('PASS: 0.31.9 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
@@ -5,7 +5,7 @@ PANEL_PARTS = (
|
||||
"management.js", "drafts.js", "integrations.js", "vhosts.js",
|
||||
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js",
|
||||
"redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js",
|
||||
"transform-panel.js", "acme.js", "settings.js", "file-logs.js",
|
||||
"transform-panel.js", "acme.js", "settings.js", "certificate-api.js", "file-logs.js",
|
||||
)
|
||||
VHOST_PARTS = ("core.js", "health.js", "rates.js", "editor.js", "render.js")
|
||||
|
||||
|
||||
Reference in new issue
Block a user