0.31.9
This commit is contained in:
1 parent
180a0e4877
commit
d23fa5e154
39 files changed
+1521
-376
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user