This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 11:27:18 +02:00
1 parent d940f9995f
commit 65e082b9d1
61 files changed
+1225 -581

No files matched your search

+5
View File
@@ -1,3 +1,8 @@
#[derive(Clone, Debug)]
struct CertificateApiAccess {
grant: crate::db::CertificateApiGrant,
}
fn valid_certificate_api_token(value: &str) -> bool {
(32..=128).contains(&value.len())
&& value
+9
View File
@@ -1,6 +1,7 @@
async fn certificate_deploy(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
certificate_api_access: Option<Extension<CertificateApiAccess>>,
Json(input): Json<CertificateDeploy>,
) -> Result<Json<Value>, ApiError> {
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
@@ -21,6 +22,14 @@ async fn certificate_deploy(
"invalid certificate id".into(),
));
}
if let Some(Extension(access)) = certificate_api_access {
if !access.grant.allows_certificate(&certificate_id) {
return Err(ApiError(
StatusCode::FORBIDDEN,
"certificate_api.certificate_forbidden".into(),
));
}
}
let snapshot = state.config.load_full();
let expected_revision = snapshot.envelope.revision;
+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.28.2_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.3_IMPLEMENTATION.md", "technical"),
+35 -11
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.28.1",
"version": "0.28.2",
"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": [
@@ -34,7 +34,7 @@
"type": "http",
"scheme": "bearer",
"bearerFormat": "opaque",
"description": "One of the scoped external certificate deployment/reload tokens configured in Settings > Certificate API. Tokens are identified by X-RustProxy-Certificate-Key and are accepted only for certificate deploy/reload operations."
"description": "Scoped external certificate automation token configured in Settings > Certificate API. New tokens are reload-only by default and are restricted to explicit certificate IDs; optional deploy permission must be granted explicitly. The token is identified by X-RustProxy-Certificate-Key."
},
"CertificateApiKey": {
"type": "apiKey",
@@ -4457,8 +4457,25 @@
"maxLength": 256,
"nullable": true,
"description": "Human-readable non-secret note identifying the automation or owner."
},
"certificate_ids": {
"type": "array",
"minItems": 1,
"maxItems": 32,
"items": {
"type": "string"
},
"description": "Certificate IDs this token may operate on. Wildcards are not accepted for newly created credentials."
},
"allow_deploy": {
"type": "boolean",
"default": false,
"description": "Also authorize certificate deploy for the allowlisted IDs. Leave false for renewal/reload hooks."
}
}
},
"required": [
"certificate_ids"
]
},
"CertificateApiRotateResult": {
"type": "object",
@@ -4649,13 +4666,24 @@
"last_used_at": {
"type": "integer",
"nullable": true
},
"allow_deploy": {
"type": "boolean"
},
"certificate_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"key_id",
"comment",
"created_at",
"last_used_at"
"last_used_at",
"allow_deploy",
"certificate_ids"
]
},
"CertificateReloadOneResult": {
@@ -9270,7 +9298,7 @@
"/api/certificates/reload": {
"post": {
"summary": "Reload configured certificate files from disk",
"description": "Rebuilds and hot-publishes the public TLS routing snapshot from the currently configured certificate/key files without changing the configuration revision. New public TLS/H2/H3 handshakes use the refreshed keys. Administration TLS remains startup-fixed and reports when a restart is required. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback.",
"description": "Rebuilds and hot-publishes the public TLS routing snapshot from the currently configured certificate/key files without changing the configuration revision. New public TLS/H2/H3 handshakes use the refreshed keys. Administration TLS remains startup-fixed and reports when a restart is required. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback. Certificate API tokens are intentionally not accepted here; automation must use the targeted per-certificate reload endpoint.",
"tags": [
"Certificates"
],
@@ -9331,10 +9359,6 @@
{
"AdminSession": [],
"CsrfToken": []
},
{
"CertificateApiBearer": [],
"CertificateApiKey": []
}
]
}
@@ -9342,7 +9366,7 @@
"/api/certificates/deploy": {
"post": {
"summary": "Reconcile an externally issued pair with a manual certificate id",
"description": "Maximum body 300 KiB. Convergently reconciles an externally issued PEM pair: creates a missing manual certificate id, atomically rotates an existing manual id through immutable generation-specific files, or returns a no-op when the selected RustProxy-owned generation already contains the exact pair. All vhosts already assigned to that id use changed material for new public TLS/H2/H3 handshakes. RustProxy-managed ACME ids are rejected. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback.",
"description": "Maximum body 300 KiB. Convergently reconciles an externally issued PEM pair for one manual certificate id. Administrator session+CSRF is always accepted. Certificate API authentication is accepted only when that credential explicitly grants deploy permission and allowlists the requested certificate ID. New Certificate API credentials default to reload-only.",
"tags": [
"Certificates"
],
@@ -9754,7 +9778,7 @@
"/api/certificates/{certificate_id}/reload": {
"post": {
"summary": "Reload one manual/file-backed certificate from disk",
"description": "Targets one configured certificate ID that is not ACME-managed. RustProxy revalidates and atomically republishes its immutable TLS snapshot without changing the configuration revision. The scoped Certificate API token is accepted for this operation, making it suitable for external certificate renewal hooks. Managed ACME certificate IDs are rejected because their renewal pipeline owns those files.",
"description": "Targets one configured certificate ID that is not ACME-managed. RustProxy revalidates and atomically republishes its immutable TLS snapshot without changing the configuration revision. The scoped Certificate API token is accepted for this operation, making it suitable for external certificate renewal hooks. Managed ACME certificate IDs are rejected because their renewal pipeline owns those files. Certificate API tokens must be explicitly allowlisted for this certificate ID.",
"tags": [
"Certificates"
],
+31 -1
View File
@@ -319,6 +319,10 @@ struct CertificateApiRotate {
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 {
@@ -377,10 +381,36 @@ async fn certificate_api_rotate(
"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, &session.username)
.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") {
+21 -11
View File
@@ -470,15 +470,25 @@ fn anonymous_allowed(method: &Method, path: &str) -> bool {
const CERTIFICATE_API_KEY_HEADER: &str = "x-rustproxy-certificate-key";
fn certificate_api_operation(method: &Method, path: &str) -> bool {
if method != Method::POST {
return false;
}
matches!(
path,
"/api/certificates/deploy" | "/api/certificates/reload"
) || path
.strip_prefix("/api/certificates/")
.and_then(|tail| tail.strip_suffix("/reload"))
.is_some_and(crate::config::valid_id)
#[derive(Clone, Debug)]
enum CertificateApiOperation {
Deploy,
ReloadAll,
ReloadOne(String),
}
fn certificate_api_operation(method: &Method, path: &str) -> Option<CertificateApiOperation> {
if method != Method::POST {
return None;
}
if path == "/api/certificates/deploy" {
return Some(CertificateApiOperation::Deploy);
}
if path == "/api/certificates/reload" {
return Some(CertificateApiOperation::ReloadAll);
}
path.strip_prefix("/api/certificates/")
.and_then(|tail| tail.strip_suffix("/reload"))
.filter(|certificate_id| crate::config::valid_id(certificate_id))
.map(|certificate_id| CertificateApiOperation::ReloadOne(certificate_id.to_owned()))
}
+41 -10
View File
@@ -40,7 +40,9 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
&& request.method() != Method::HEAD
&& request.method() != Method::OPTIONS;
let secret = auth::cookie_secret(headers);
let certificate_api_session = if certificate_api_operation(request.method(), &path) {
let certificate_api_operation = certificate_api_operation(request.method(), &path);
let mut certificate_api_access = None;
let certificate_api_session = if let Some(operation) = certificate_api_operation.as_ref() {
match certificate_api_credentials(headers) {
Ok(None) => None,
Ok(Some((key_id, token))) => {
@@ -51,26 +53,52 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
));
}
let token_hash = auth::secret_hash(&token);
match state
let grant = match state
.store
.verify_certificate_api_credential(&key_id, &token_hash)
.await
{
Ok(true) => Some(Session {
token_hash,
csrf: String::new(),
username: format!("certificate-api:{key_id}"),
expires_at: auth::now().saturating_add(60),
generation: 0,
}),
Ok(false) => {
Ok(Some(grant)) => grant,
Ok(None) => {
return secure(reject(
StatusCode::UNAUTHORIZED,
"certificate_api.invalid_token",
))
}
Err(error) => return secure(auth::internal(error).into_response()),
};
match operation {
CertificateApiOperation::ReloadAll => {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.target_required",
));
}
CertificateApiOperation::ReloadOne(certificate_id) => {
if !grant.allows_certificate(certificate_id) {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.certificate_forbidden",
));
}
}
CertificateApiOperation::Deploy => {
if !grant.allow_deploy {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.deploy_forbidden",
));
}
}
}
certificate_api_access = Some(CertificateApiAccess { grant });
Some(Session {
token_hash,
csrf: String::new(),
username: format!("certificate-api:{key_id}"),
expires_at: auth::now().saturating_add(60),
generation: 0,
})
}
Err(()) => {
return secure(reject(
@@ -132,6 +160,9 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
if let Some(session) = session.clone() {
request.extensions_mut().insert(session);
}
if let Some(access) = certificate_api_access {
request.extensions_mut().insert(access);
}
let admin_origin = AdminOrigin {
secure: scheme == "https",
};
+69 -12
View File
@@ -185,6 +185,8 @@
"file-renewer",
&crate::auth::secret_hash(&api_token),
"external file renewal hook",
false,
&["manual-file".to_owned()],
"owner",
)
.await
@@ -223,7 +225,7 @@
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(json!({"key_id":"x","token":token}).to_string()))
.body(Body::from(json!({"key_id":"x","token":token,"certificate_ids":["shared-cert"]}).to_string()))
.unwrap(),
)
.await
@@ -245,7 +247,13 @@
.header("x-csrf-token", &csrf)
.header("content-type", "application/json")
.body(Body::from(
json!({"key_id":key_id,"token":value,"comment":comment}).to_string(),
json!({
"key_id":key_id,
"token":value,
"comment":comment,
"certificate_ids":[if key_id == "certbot-main" { "shared-cert" } else { "deploy-cert" }],
"allow_deploy": key_id == "deploy-hook"
}).to_string(),
))
.unwrap(),
)
@@ -256,6 +264,8 @@
assert_eq!(rotated["configured"], true);
assert_eq!(rotated["credential"]["key_id"], key_id);
assert_eq!(rotated["credential"]["comment"], comment);
assert_eq!(rotated["credential"]["allow_deploy"], key_id == "deploy-hook");
assert_eq!(rotated["credential"]["certificate_ids"].as_array().unwrap().len(), 1);
assert_eq!(rotated["token"], value);
}
@@ -274,8 +284,8 @@
assert_eq!(status["credentials"].as_array().unwrap().len(), 2);
assert!(status.get("token").is_none());
let external = |key_id: &'static str, value: String| {
request("/api/certificates/reload")
let external = |path: &'static str, key_id: &'static str, value: String| {
request(path)
.method("POST")
.header("authorization", format!("Bearer {value}"))
.header("x-rustproxy-certificate-key", key_id)
@@ -283,18 +293,65 @@
.body(Body::from("{}"))
.unwrap()
};
// Certificate API credentials cannot trigger an all-certificate reload.
let response = router
.clone()
.oneshot(external("certbot-main", token.clone()))
.oneshot(external("/api/certificates/reload", "certbot-main", token.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// The allowlisted ID passes the auth gate and reaches the handler; this
// fixture intentionally has no such configured certificate, so it is 404.
let response = router
.clone()
.oneshot(external("deploy-hook", token_two.clone()))
.oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// A reload-only token cannot use the deploy endpoint at all.
let response = router
.clone()
.oneshot(
request("/api/certificates/deploy")
.method("POST")
.header("authorization", format!("Bearer {token}"))
.header("x-rustproxy-certificate-key", "certbot-main")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// A different ID is rejected by the credential grant before the handler.
let response = router
.clone()
.oneshot(external("/api/certificates/deploy-cert/reload", "certbot-main", token.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = router
.clone()
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = router
.clone()
.oneshot(
request("/api/certificates/deploy")
.method("POST")
.header("authorization", format!("Bearer {token_two}"))
.header("x-rustproxy-certificate-key", "deploy-hook")
.header("content-type", "application/json")
.body(Body::from(
json!({"certificate_id":"shared-cert","cert_pem":"bad","key_pem":"bad"}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let missing_key = router
.clone()
@@ -340,16 +397,16 @@
assert_eq!(revoked.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(external("certbot-main", token.clone()))
.oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = router
.clone()
.oneshot(external("deploy-hook", token_two.clone()))
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let revoked_all = router
.clone()
@@ -367,7 +424,7 @@
.unwrap();
assert_eq!(revoked_all.status(), StatusCode::OK);
let response = router
.oneshot(external("deploy-hook", token_two))
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+1
View File
@@ -36,6 +36,7 @@ function syncCertificateFileSource() {
const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp";
$("certificate-file-source-help").dataset.i18n = helpKey;
$("certificate-file-source-help").textContent = t(helpKey);
$("certificate-external-acl-example").hidden = !external;
}
$("certificate-file-source").addEventListener("change", syncCertificateFileSource);
+13 -6
View File
@@ -1186,6 +1186,7 @@
<option data-i18n="certificateSourceExternal" value="external"></option>
</select>
<p class="fine" id="certificate-file-source-help" data-i18n="certificateSourceManagedHelp"></p>
<pre class="metrics-example" data-i18n="certificateExternalAclExample" hidden id="certificate-external-acl-example"></pre>
<label data-i18n="certificateFile" for="certificate-file"></label><input
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
placeholder="app.pem" required="" />
@@ -2728,14 +2729,20 @@
class="pill live">SCOPED TOKENS</span>
</div>
<p class="fine" data-i18n="certificateApiHelp">Separate Key ID + Bearer Token
credentials can only deploy or reload externally managed certificates. They cannot
read configuration or use other administration APIs.</p>
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>
<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
id="certificate-api-comment" maxlength="256"
placeholder="Certbot on edge-01" /></label>
<label><span data-i18n="certificateApiCertificateIds">Allowed certificate IDs</span><textarea
id="certificate-api-certificate-ids" maxlength="2048" placeholder="shared-cert" required rows="3"></textarea></label>
<p class="fine" data-i18n="certificateApiCertificateIdsHelp">List one or more certificate IDs separated by commas or new lines. The token can act only on these IDs.</p>
<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
value)</span><input autocomplete="off" id="certificate-api-token"
maxlength="128" minlength="32"
@@ -2752,8 +2759,8 @@
</div>
<div class="certificate-api-credentials" id="certificate-api-credentials"></div>
<p class="fine" data-i18n="certificateApiCurlHelp">External automation sends the Key ID
header and Bearer token to the certificate deploy/reload endpoints over HTTPS (or
loopback HTTP).</p>
header and Bearer token over HTTPS (or loopback HTTP). Reload tokens use the targeted
/api/certificates/{id}/reload endpoint; token access cannot trigger global reload.</p>
<pre class="metrics-example" id="certificate-api-curl"></pre>
</form>
</div>
@@ -2884,7 +2891,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.28.1</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3628,7 +3635,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.28.1</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.28.2</strong></div>
</div>
<dl class="about-meta">
<div>
+26 -11
View File
@@ -88,18 +88,25 @@
}
function certificateApiExample() {
const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main";
$("certificate-api-curl").textContent = `curl --fail --show-error --request POST \
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
# Reload one manual/file-backed certificate without changing its configuration:
curl --fail --show-error --request POST \
--header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \
--header 'X-RustProxy-Certificate-Key: ${key}' \
${location.origin}/api/certificates/CERTIFICATE_ID/reload`;
${location.origin}/api/certificates/deploy`;
}
function renderCertificateApi() {
const state = certificateApiState, target = $("certificate-api-credentials");
@@ -116,6 +123,9 @@ curl --fail --show-error --request POST \
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 () => {
@@ -267,9 +277,12 @@ curl --fail --show-error --request POST \
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 payload = await api("/api/certificate-api", { method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment }) });
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-token").value = ""; $("certificate-api-secret").textContent = clearToken; $("certificate-api-secret-wrap").hidden = !clearToken; renderCertificateApi(); toast(t("certificateApiRotated"));
$("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 () => {
@@ -279,6 +292,8 @@ curl --fail --show-error --request POST \
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 () => {
@@ -288,6 +303,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); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("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); 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; } };
select(active); render();
})();