v0,28,2
This commit is contained in:
1 parent
d940f9995f
commit
65e082b9d1
61 files changed
+1225
-581
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
})();
|
||||
@@ -1,9 +1,10 @@
|
||||
//! SQLite stores desired configuration, administrator state and bounded audit history.
|
||||
//! No database call is made from the public HTTP forwarding path.
|
||||
use crate::config::{Config, Envelope};
|
||||
mod certificate_api;
|
||||
mod history;
|
||||
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
|
||||
use serde::Serialize;
|
||||
use sqlx::{
|
||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
|
||||
@@ -11,7 +12,7 @@ use sqlx::{
|
||||
};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
pub const SCHEMA_VERSION: i64 = 8;
|
||||
pub const SCHEMA_VERSION: i64 = 9;
|
||||
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
|
||||
pub const AUDIT_CAPACITY: i64 = 10_000;
|
||||
|
||||
@@ -25,6 +26,22 @@ pub struct CertificateApiCredential {
|
||||
pub comment: String,
|
||||
pub created_at: i64,
|
||||
pub last_used_at: Option<i64>,
|
||||
pub allow_deploy: bool,
|
||||
pub certificate_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CertificateApiGrant {
|
||||
pub allow_deploy: bool,
|
||||
pub certificate_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl CertificateApiGrant {
|
||||
pub fn allows_certificate(&self, certificate_id: &str) -> bool {
|
||||
self.certificate_ids
|
||||
.iter()
|
||||
.any(|value| value == "*" || value == certificate_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -90,15 +107,22 @@ impl Store {
|
||||
sqlx::raw_sql(include_str!("../migrations/0005_certificate_api.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0006_certificate_api_credentials.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../migrations/0006_certificate_api_credentials.sql"
|
||||
))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0007_secret_store.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0008_config_history.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../migrations/0009_certificate_api_grants.sql"
|
||||
))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
|
||||
.bind(serde_json::to_string(initial)?)
|
||||
.execute(&mut *tx)
|
||||
@@ -109,7 +133,7 @@ impl Store {
|
||||
.bind(crate::auth::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA user_version = 8")
|
||||
sqlx::query("PRAGMA user_version = 9")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -313,149 +337,6 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn certificate_api_credentials(&self) -> Result<Vec<CertificateApiCredential>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT key_id, comment, created_at, last_used_at FROM certificate_api_credentials ORDER BY key_id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
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")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn upsert_certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
token_hash: &str,
|
||||
comment: &str,
|
||||
actor: &str,
|
||||
) -> Result<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"
|
||||
);
|
||||
ensure!(
|
||||
comment.len() <= 256 && !comment.chars().any(char::is_control),
|
||||
"invalid certificate API comment"
|
||||
);
|
||||
let now = crate::auth::now();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM certificate_api_credentials WHERE key_id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if exists == 0 {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
ensure!(
|
||||
count < CERTIFICATE_API_CREDENTIAL_CAPACITY,
|
||||
"certificate API credential limit reached"
|
||||
);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) VALUES(?, ?, ?, ?, NULL) \
|
||||
ON CONFLICT(key_id) DO UPDATE SET token_hash=excluded.token_hash, comment=excluded.comment, created_at=excluded.created_at, last_used_at=NULL",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(token_hash)
|
||||
.bind(comment)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.rotate",
|
||||
&format!("key_id={key_id}"),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(CertificateApiCredential {
|
||||
key_id: key_id.to_owned(),
|
||||
comment: comment.to_owned(),
|
||||
created_at: now,
|
||||
last_used_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn revoke_certificate_api_credential(&self, key_id: &str, actor: &str) -> Result<bool> {
|
||||
ensure!(
|
||||
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
|
||||
"invalid certificate API key id"
|
||||
);
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("DELETE FROM certificate_api_credentials WHERE key_id = ?")
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() > 0 {
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.revoke",
|
||||
&format!("key_id={key_id}"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn revoke_all_certificate_api_credentials(&self, actor: &str) -> Result<u64> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("DELETE FROM certificate_api_credentials")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() > 0 {
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.revoke_all",
|
||||
&format!("count={}", result.rows_affected()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn verify_certificate_api_credential(&self, key_id: &str, token_hash: &str) -> Result<bool> {
|
||||
let row = sqlx::query(
|
||||
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
let Some(row) = row else { return Ok(false); };
|
||||
let expected: String = row.try_get("token_hash")?;
|
||||
if !crate::auth::equal_secret(token_hash, &expected) {
|
||||
return Ok(false);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE certificate_api_credentials SET last_used_at = ? WHERE key_id = ?",
|
||||
)
|
||||
.bind(crate::auth::now())
|
||||
.bind(key_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn recent_audit(&self) -> Result<Vec<AuditEntry>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, time, actor, action, detail FROM admin_audit ORDER BY id DESC LIMIT 100",
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use super::{
|
||||
audit_tx, CertificateApiCredential, CertificateApiGrant, Store,
|
||||
CERTIFICATE_API_CREDENTIAL_CAPACITY,
|
||||
};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use sqlx::Row;
|
||||
|
||||
impl Store {
|
||||
pub async fn certificate_api_credentials(&self) -> Result<Vec<CertificateApiCredential>> {
|
||||
let rows = 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 \
|
||||
ORDER BY c.key_id",
|
||||
)
|
||||
.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()
|
||||
}
|
||||
|
||||
pub async fn upsert_certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
token_hash: &str,
|
||||
comment: &str,
|
||||
allow_deploy: bool,
|
||||
certificate_ids: &[String],
|
||||
actor: &str,
|
||||
) -> Result<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"
|
||||
);
|
||||
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 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 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials WHERE key_id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if exists == 0 {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
ensure!(
|
||||
count < CERTIFICATE_API_CREDENTIAL_CAPACITY,
|
||||
"certificate API credential limit reached"
|
||||
);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) VALUES(?, ?, ?, ?, NULL) \
|
||||
ON CONFLICT(key_id) DO UPDATE SET token_hash=excluded.token_hash, comment=excluded.comment, created_at=excluded.created_at, last_used_at=NULL",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(token_hash)
|
||||
.bind(comment)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
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(&certificate_ids_json)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.rotate",
|
||||
&format!(
|
||||
"key_id={key_id}; scope={}; certificate_ids={}",
|
||||
if allow_deploy {
|
||||
"deploy+reload"
|
||||
} else {
|
||||
"reload"
|
||||
},
|
||||
normalized_ids.join(",")
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(CertificateApiCredential {
|
||||
key_id: key_id.to_owned(),
|
||||
comment: comment.to_owned(),
|
||||
created_at: now,
|
||||
last_used_at: None,
|
||||
allow_deploy,
|
||||
certificate_ids: normalized_ids,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn revoke_certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
actor: &str,
|
||||
) -> Result<bool> {
|
||||
ensure!(
|
||||
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
|
||||
"invalid certificate API key id"
|
||||
);
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("DELETE FROM certificate_api_credentials WHERE key_id = ?")
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() > 0 {
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.revoke",
|
||||
&format!("key_id={key_id}"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn revoke_all_certificate_api_credentials(&self, actor: &str) -> Result<u64> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("DELETE FROM certificate_api_credentials")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() > 0 {
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.revoke_all",
|
||||
&format!("count={}", result.rows_affected()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn verify_certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<CertificateApiGrant>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT c.token_hash, 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?;
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let expected: String = row.try_get("token_hash")?;
|
||||
if !crate::auth::equal_secret(token_hash, &expected) {
|
||||
return Ok(None);
|
||||
}
|
||||
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")?;
|
||||
sqlx::query("UPDATE certificate_api_credentials SET last_used_at = ? WHERE key_id = ?")
|
||||
.bind(crate::auth::now())
|
||||
.bind(key_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(Some(CertificateApiGrant {
|
||||
allow_deploy: row.try_get::<i64, _>("allow_deploy")? != 0,
|
||||
certificate_ids,
|
||||
}))
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -105,12 +105,21 @@ mod tests {
|
||||
let token = "b".repeat(64);
|
||||
let hash = crate::auth::secret_hash(&token);
|
||||
let saved = store
|
||||
.upsert_certificate_api_credential("certbot-main", &hash, "production certbot", "owner")
|
||||
.upsert_certificate_api_credential(
|
||||
"certbot-main",
|
||||
&hash,
|
||||
"production certbot",
|
||||
false,
|
||||
&["shared-cert".to_owned()],
|
||||
"owner",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.key_id, "certbot-main");
|
||||
assert_eq!(saved.comment, "production certbot");
|
||||
assert!(saved.last_used_at.is_none());
|
||||
assert!(!saved.allow_deploy);
|
||||
assert_eq!(saved.certificate_ids, vec!["shared-cert"]);
|
||||
|
||||
let raw: String = sqlx::query_scalar(
|
||||
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?",
|
||||
@@ -128,23 +137,30 @@ mod tests {
|
||||
"deploy-hook",
|
||||
&second_hash,
|
||||
"secondary deploy hook",
|
||||
true,
|
||||
&["deploy-cert".to_owned()],
|
||||
"owner",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.certificate_api_credentials().await.unwrap().len(), 2);
|
||||
|
||||
assert!(store
|
||||
let grant = store
|
||||
.verify_certificate_api_credential("certbot-main", &hash)
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(!store
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!grant.allow_deploy);
|
||||
assert!(grant.allows_certificate("shared-cert"));
|
||||
assert!(!grant.allows_certificate("other-cert"));
|
||||
assert!(store
|
||||
.verify_certificate_api_credential(
|
||||
"certbot-main",
|
||||
&crate::auth::secret_hash(&"c".repeat(64)),
|
||||
)
|
||||
.await
|
||||
.unwrap());
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let credentials = store.certificate_api_credentials().await.unwrap();
|
||||
assert!(credentials
|
||||
.iter()
|
||||
|
||||
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.1"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.2"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
|
||||
println!("RustProxy live logs 0.28.1 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.28.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! written to audit/API response payloads.
|
||||
|
||||
include!("tls/types.rs");
|
||||
include!("tls/external_paths.rs");
|
||||
include!("tls/server_config.rs");
|
||||
include!("tls/certificates.rs");
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
pub fn certificate_files_are_external(cert_file: &str, key_file: &str) -> Result<bool> {
|
||||
let cert_external = Path::new(cert_file).is_absolute();
|
||||
let key_external = Path::new(key_file).is_absolute();
|
||||
ensure!(
|
||||
cert_external == key_external,
|
||||
"certificate and private key must both use filenames from the RustProxy certificate directory or both use absolute external paths"
|
||||
);
|
||||
if cert_external {
|
||||
ensure!(
|
||||
!cert_file.is_empty()
|
||||
&& !key_file.is_empty()
|
||||
&& cert_file.len() <= MAX_CERTIFICATE_PATH
|
||||
&& key_file.len() <= MAX_CERTIFICATE_PATH,
|
||||
"external certificate paths must be absolute paths up to 4096 bytes"
|
||||
);
|
||||
Ok(true)
|
||||
} else {
|
||||
safe_name(cert_file)?;
|
||||
safe_name(key_file)?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_external_parent_chain(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("external certificate path has no parent directory")?;
|
||||
for directory in parent.ancestors() {
|
||||
if directory.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let meta = fs::symlink_metadata(directory).with_context(|| {
|
||||
format!(
|
||||
"cannot inspect external certificate parent directory {}",
|
||||
directory.display()
|
||||
)
|
||||
})?;
|
||||
ensure!(
|
||||
meta.is_dir(),
|
||||
"external certificate parent path must contain real directories, not symlinks or special files: {}",
|
||||
directory.display()
|
||||
);
|
||||
let mode = meta.permissions().mode();
|
||||
let owner = meta.uid();
|
||||
let service_uid = unsafe { libc::geteuid() };
|
||||
ensure!(
|
||||
owner == 0 || owner == service_uid,
|
||||
"external certificate parent directory must be owned by root or the RustProxy service user: {}",
|
||||
directory.display()
|
||||
);
|
||||
if mode & 0o022 != 0 {
|
||||
// Root-owned sticky directories such as /tmp and /var/tmp cannot have
|
||||
// entries renamed/unlinked by arbitrary users, so they are safe as an
|
||||
// ancestor of a private service-owned test/custom directory.
|
||||
ensure!(
|
||||
owner == 0 && mode & 0o1000 != 0,
|
||||
"external certificate parent directory must not be group/world-writable unless it is a root-owned sticky directory: {}",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn validate_external_parent_chain(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configured_certificate_path(directory: &Path, value: &str) -> Result<PathBuf> {
|
||||
if Path::new(value).is_absolute() {
|
||||
ensure!(
|
||||
!value.is_empty() && value.len() <= MAX_CERTIFICATE_PATH,
|
||||
"external certificate path must be an absolute path up to 4096 bytes"
|
||||
);
|
||||
let configured = Path::new(value);
|
||||
// The configured path itself may be Certbot's final live/*.pem symlink, but
|
||||
// every parent directory must be a stable, non-writable directory. This
|
||||
// prevents a writable parent from redirecting canonicalize() to attacker-
|
||||
// controlled material between validation and open.
|
||||
validate_external_parent_chain(configured)?;
|
||||
let resolved = fs::canonicalize(configured)
|
||||
.with_context(|| format!("cannot resolve external certificate path {value}"))?;
|
||||
// Validate the resolved archive/custom path as well. Certbot's final file
|
||||
// symlink is intentionally followed on each reload; intermediate symlinks
|
||||
// and group/world-writable parent directories are rejected.
|
||||
validate_external_parent_chain(&resolved)?;
|
||||
return Ok(resolved);
|
||||
}
|
||||
safe_name(value)?;
|
||||
Ok(directory.join(value))
|
||||
}
|
||||
|
||||
fn read_configured_pem(directory: &Path, value: &str, private: bool) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let external = Path::new(value).is_absolute();
|
||||
let path = configured_certificate_path(directory, value)?;
|
||||
read_pem_with_policy(&path, private, external)
|
||||
}
|
||||
|
||||
fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
|
||||
read_pem_with_policy(path, private, false)
|
||||
}
|
||||
|
||||
fn read_pem_with_policy(
|
||||
path: &Path,
|
||||
private: bool,
|
||||
external_group_read: bool,
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = options
|
||||
.open(path)
|
||||
.with_context(|| format!("cannot open certificate input {}", path.display()))?;
|
||||
let meta = file.metadata()?;
|
||||
ensure!(
|
||||
meta.is_file() && meta.len() <= MAX_PEM,
|
||||
"certificate inputs must be regular files up to 128 KiB"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
let mode = meta.permissions().mode();
|
||||
if external_group_read {
|
||||
let owner = meta.uid();
|
||||
let service_uid = unsafe { libc::geteuid() };
|
||||
ensure!(
|
||||
owner == 0 || owner == service_uid,
|
||||
"external certificate/key must be owned by root or the RustProxy service user: {}",
|
||||
path.display()
|
||||
);
|
||||
// POSIX ACL masks are reflected in the traditional group mode bits.
|
||||
// Therefore do not require an exact chmod such as 0600/0640 and do
|
||||
// not reject harmless execute-mask bits. What matters here is that
|
||||
// an untrusted group/other identity cannot modify the material and
|
||||
// that the private key is never readable by "other". A named ACL
|
||||
// such as `u:rustproxy:r--` is intentionally supported.
|
||||
ensure!(
|
||||
mode & 0o022 == 0,
|
||||
"external certificate/key must not be group/world-writable: {}",
|
||||
path.display()
|
||||
);
|
||||
if private {
|
||||
ensure!(
|
||||
mode & 0o004 == 0,
|
||||
"external private key must not be world-readable; use a restricted group or POSIX ACL for the rustproxy user: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
} else if private {
|
||||
ensure!(
|
||||
mode & 0o077 == 0,
|
||||
"private key permissions must not grant group/world access: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = (private, external_group_read);
|
||||
let mut data = Zeroizing::new(Vec::new());
|
||||
file.take(MAX_PEM + 1).read_to_end(&mut data)?;
|
||||
ensure!(
|
||||
data.len() <= MAX_PEM as usize,
|
||||
"PEM file grew beyond its limit"
|
||||
);
|
||||
Ok(data)
|
||||
}
|
||||
+1
-61
@@ -162,68 +162,8 @@ mod tests {
|
||||
assert!(certificate_files_are_external("app.pem", "/etc/tls/app.key").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
include!("tests/external_paths.rs");
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cert_dir = root.path().join("rustproxy-certs");
|
||||
fs::create_dir(&cert_dir).unwrap();
|
||||
initialize_default(&cert_dir).unwrap();
|
||||
|
||||
let archive = root.path().join("archive/example.test");
|
||||
let live = root.path().join("live/example.test");
|
||||
fs::create_dir_all(&archive).unwrap();
|
||||
fs::create_dir_all(&live).unwrap();
|
||||
|
||||
let first = rcgen::generate_simple_self_signed(vec!["example.test".into()]).unwrap();
|
||||
fs::write(archive.join("fullchain1.pem"), first.cert.pem()).unwrap();
|
||||
fs::write(archive.join("privkey1.pem"), first.key_pair.serialize_pem()).unwrap();
|
||||
fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o640)).unwrap();
|
||||
symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap();
|
||||
symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap();
|
||||
|
||||
let cert_path = live.join("fullchain.pem").display().to_string();
|
||||
let key_path = live.join("privkey.pem").display().to_string();
|
||||
assert!(certificate_files_are_external(&cert_path, &key_path).unwrap());
|
||||
let settings = Settings {
|
||||
default_certificate: Some("letsencrypt".into()),
|
||||
certificates: vec![Certificate {
|
||||
issuer: None,
|
||||
id: "letsencrypt".into(),
|
||||
covered_hosts: Vec::new(),
|
||||
hosts: vec!["example.test".into()],
|
||||
cert_file: cert_path.clone(),
|
||||
key_file: key_path.clone(),
|
||||
}],
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
|
||||
assert_eq!(
|
||||
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
|
||||
vec!["example.test"]
|
||||
);
|
||||
|
||||
let second = rcgen::generate_simple_self_signed(vec![
|
||||
"example.test".into(),
|
||||
"www.example.test".into(),
|
||||
])
|
||||
.unwrap();
|
||||
fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap();
|
||||
fs::write(archive.join("privkey2.pem"), second.key_pair.serialize_pem()).unwrap();
|
||||
fs::set_permissions(archive.join("privkey2.pem"), fs::Permissions::from_mode(0o640)).unwrap();
|
||||
fs::remove_file(live.join("fullchain.pem")).unwrap();
|
||||
fs::remove_file(live.join("privkey.pem")).unwrap();
|
||||
symlink("../../archive/example.test/fullchain2.pem", live.join("fullchain.pem")).unwrap();
|
||||
symlink("../../archive/example.test/privkey2.pem", live.join("privkey.pem")).unwrap();
|
||||
|
||||
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
|
||||
assert_eq!(
|
||||
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
|
||||
vec!["example.test", "www.example.test"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn default_is_persistent_and_tls_advertises_both_protocols() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cert_dir = root.path().join("rustproxy-certs");
|
||||
fs::create_dir(&cert_dir).unwrap();
|
||||
initialize_default(&cert_dir).unwrap();
|
||||
|
||||
let archive = root.path().join("archive/example.test");
|
||||
let live = root.path().join("live/example.test");
|
||||
fs::create_dir_all(&archive).unwrap();
|
||||
fs::create_dir_all(&live).unwrap();
|
||||
|
||||
let first = rcgen::generate_simple_self_signed(vec!["example.test".into()]).unwrap();
|
||||
fs::write(archive.join("fullchain1.pem"), first.cert.pem()).unwrap();
|
||||
fs::write(archive.join("privkey1.pem"), first.key_pair.serialize_pem()).unwrap();
|
||||
fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o650)).unwrap();
|
||||
symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap();
|
||||
symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap();
|
||||
|
||||
let cert_path = live.join("fullchain.pem").display().to_string();
|
||||
let key_path = live.join("privkey.pem").display().to_string();
|
||||
assert!(certificate_files_are_external(&cert_path, &key_path).unwrap());
|
||||
let settings = Settings {
|
||||
default_certificate: Some("letsencrypt".into()),
|
||||
certificates: vec![Certificate {
|
||||
issuer: None,
|
||||
id: "letsencrypt".into(),
|
||||
covered_hosts: Vec::new(),
|
||||
hosts: vec!["example.test".into()],
|
||||
cert_file: cert_path.clone(),
|
||||
key_file: key_path.clone(),
|
||||
}],
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
|
||||
assert_eq!(
|
||||
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
|
||||
vec!["example.test"]
|
||||
);
|
||||
|
||||
let second = rcgen::generate_simple_self_signed(vec![
|
||||
"example.test".into(),
|
||||
"www.example.test".into(),
|
||||
])
|
||||
.unwrap();
|
||||
fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap();
|
||||
fs::write(archive.join("privkey2.pem"), second.key_pair.serialize_pem()).unwrap();
|
||||
fs::set_permissions(archive.join("privkey2.pem"), fs::Permissions::from_mode(0o640)).unwrap();
|
||||
fs::remove_file(live.join("fullchain.pem")).unwrap();
|
||||
fs::remove_file(live.join("privkey.pem")).unwrap();
|
||||
symlink("../../archive/example.test/fullchain2.pem", live.join("fullchain.pem")).unwrap();
|
||||
symlink("../../archive/example.test/privkey2.pem", live.join("privkey.pem")).unwrap();
|
||||
|
||||
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
|
||||
assert_eq!(
|
||||
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
|
||||
vec!["example.test", "www.example.test"]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_or_world_read() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let key = root.path().join("external.key");
|
||||
fs::write(&key, b"not-a-real-key").unwrap();
|
||||
|
||||
// POSIX ACL masks can surface through the traditional group class bits.
|
||||
// Group read/execute is not itself a disclosure when `other` has no read
|
||||
// and neither group nor other can write the file.
|
||||
fs::set_permissions(&key, fs::Permissions::from_mode(0o650)).unwrap();
|
||||
assert!(read_pem_with_policy(&key, true, true).is_ok());
|
||||
|
||||
fs::set_permissions(&key, fs::Permissions::from_mode(0o660)).unwrap();
|
||||
assert!(read_pem_with_policy(&key, true, true).is_err());
|
||||
|
||||
fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert!(read_pem_with_policy(&key, true, true).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_paths_reject_writable_non_sticky_parent_directories() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let unsafe_parent = root.path().join("unsafe");
|
||||
fs::create_dir(&unsafe_parent).unwrap();
|
||||
fs::set_permissions(&unsafe_parent, fs::Permissions::from_mode(0o770)).unwrap();
|
||||
let cert = unsafe_parent.join("cert.pem");
|
||||
fs::write(&cert, b"dummy").unwrap();
|
||||
assert!(configured_certificate_path(root.path(), cert.to_str().unwrap()).is_err());
|
||||
}
|
||||
@@ -330,106 +330,6 @@ pub fn safe_name(name: &str) -> Result<()> {
|
||||
/// remain private to `<data-dir>/certs`; an explicitly selected external pair uses
|
||||
/// two absolute paths. Mixing the two modes is rejected so the source is never
|
||||
/// ambiguous.
|
||||
pub fn certificate_files_are_external(cert_file: &str, key_file: &str) -> Result<bool> {
|
||||
let cert_external = Path::new(cert_file).is_absolute();
|
||||
let key_external = Path::new(key_file).is_absolute();
|
||||
ensure!(
|
||||
cert_external == key_external,
|
||||
"certificate and private key must both use filenames from the RustProxy certificate directory or both use absolute external paths"
|
||||
);
|
||||
if cert_external {
|
||||
ensure!(
|
||||
!cert_file.is_empty()
|
||||
&& !key_file.is_empty()
|
||||
&& cert_file.len() <= MAX_CERTIFICATE_PATH
|
||||
&& key_file.len() <= MAX_CERTIFICATE_PATH,
|
||||
"external certificate paths must be absolute paths up to 4096 bytes"
|
||||
);
|
||||
Ok(true)
|
||||
} else {
|
||||
safe_name(cert_file)?;
|
||||
safe_name(key_file)?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_certificate_path(directory: &Path, value: &str) -> Result<PathBuf> {
|
||||
if Path::new(value).is_absolute() {
|
||||
ensure!(
|
||||
!value.is_empty() && value.len() <= MAX_CERTIFICATE_PATH,
|
||||
"external certificate path must be an absolute path up to 4096 bytes"
|
||||
);
|
||||
// Certbot's live/ entries are symlinks to archive/. Resolve them on every
|
||||
// reload, then open the final regular file with O_NOFOLLOW in read_pem.
|
||||
return fs::canonicalize(value)
|
||||
.with_context(|| format!("cannot resolve external certificate path {value}"));
|
||||
}
|
||||
safe_name(value)?;
|
||||
Ok(directory.join(value))
|
||||
}
|
||||
|
||||
fn read_configured_pem(
|
||||
directory: &Path,
|
||||
value: &str,
|
||||
private: bool,
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let external = Path::new(value).is_absolute();
|
||||
let path = configured_certificate_path(directory, value)?;
|
||||
read_pem_with_policy(&path, private, external)
|
||||
}
|
||||
|
||||
fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
|
||||
read_pem_with_policy(path, private, false)
|
||||
}
|
||||
|
||||
fn read_pem_with_policy(
|
||||
path: &Path,
|
||||
private: bool,
|
||||
external_group_read: bool,
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = options
|
||||
.open(path)
|
||||
.with_context(|| format!("cannot open certificate input {}", path.display()))?;
|
||||
let meta = file.metadata()?;
|
||||
ensure!(
|
||||
meta.is_file() && meta.len() <= MAX_PEM,
|
||||
"certificate inputs must be regular files up to 128 KiB"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = meta.permissions().mode();
|
||||
if external_group_read {
|
||||
ensure!(
|
||||
mode & 0o137 == 0,
|
||||
"external private key may be owner/group-readable but must not be executable, group-writable, or world-accessible: {}",
|
||||
path.display()
|
||||
);
|
||||
} else {
|
||||
ensure!(
|
||||
mode & 0o077 == 0,
|
||||
"private key permissions must not grant group/world access: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = (private, external_group_read);
|
||||
let mut data = Zeroizing::new(Vec::new());
|
||||
file.take(MAX_PEM + 1).read_to_end(&mut data)?;
|
||||
ensure!(
|
||||
data.len() <= MAX_PEM as usize,
|
||||
"PEM file grew beyond its limit"
|
||||
);
|
||||
Ok(data)
|
||||
}
|
||||
fn der_tlv<'a>(input: &'a [u8], offset: &mut usize) -> Result<(u8, &'a [u8])> {
|
||||
ensure!(*offset < input.len(), "truncated DER object");
|
||||
let tag = input[*offset];
|
||||
|
||||
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
|
||||
if context.tls { "on" } else { "off" }.into(),
|
||||
);
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.2".into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers
|
||||
.get(CONTENT_TYPE)
|
||||
|
||||
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
|
||||
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
|
||||
));
|
||||
}
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.1</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.28.1</b>"));
|
||||
assert!(body.contains("RustProxy <b>0.28.2</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user