v0.24.9
This commit is contained in:
1 parent
1297ea2920
commit
dc0fcabf51
49 files changed
+2632
-252
No files matched your search
+133
-2
@@ -1,9 +1,11 @@
|
||||
//! Bounded control-plane adapter for Certbot HTTP-01/DNS-01 providers. Never on traffic paths.
|
||||
//! Provider credentials enter via stdin, not argv, logs, SQLite or API responses.
|
||||
//! Provider credentials enter via stdin, are encrypted in Secret Store, and never
|
||||
//! travel through argv, logs or API responses.
|
||||
use crate::{
|
||||
runtime::{ApiError, App},
|
||||
tls::Certificate,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -39,6 +41,100 @@ pub(crate) fn certbot_log_directory(directory: &std::path::Path, provider: &str,
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizing<Vec<u8>>, ApiError> {
|
||||
fn credential<'a>(request: &'a Value, name: &str) -> Result<&'a str, ApiError> {
|
||||
let invalid = || ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into());
|
||||
let value = request.get(name).and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let valid = (8..=4096).contains(&value.len())
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'_' | b'.' | b'/' | b'+' | b'-' | b'=')
|
||||
});
|
||||
if valid { Ok(value) } else { Err(invalid()) }
|
||||
}
|
||||
let bytes = match provider {
|
||||
"cloudflare" => format!("dns_cloudflare_api_token = {}\n", credential(request, "api_token")?).into_bytes(),
|
||||
"ovh" => {
|
||||
if request.get("endpoint").is_some_and(|value| !value.is_string()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
|
||||
}
|
||||
let endpoint = request.get("endpoint").and_then(Value::as_str).unwrap_or("ovh-eu");
|
||||
if !matches!(endpoint, "ovh-eu" | "ovh-ca") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
|
||||
}
|
||||
format!(
|
||||
"dns_ovh_endpoint = {}\ndns_ovh_application_key = {}\ndns_ovh_application_secret = {}\ndns_ovh_consumer_key = {}\n",
|
||||
endpoint,
|
||||
credential(request, "application_key")?,
|
||||
credential(request, "application_secret")?,
|
||||
credential(request, "consumer_key")?
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
"rfc2136" => {
|
||||
let dns_server = request
|
||||
.get("dns_server")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into()))?;
|
||||
if request.get("dns_port").is_some_and(|value| value.as_u64().is_none()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
|
||||
}
|
||||
let dns_port = request.get("dns_port").and_then(Value::as_u64).unwrap_or(53);
|
||||
if !(1..=65535).contains(&dns_port) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
|
||||
}
|
||||
let raw_name = request.get("key_name").and_then(Value::as_str).unwrap_or("");
|
||||
let key_name = raw_name.to_ascii_lowercase();
|
||||
let key_name = key_name.trim_end_matches('.');
|
||||
let valid_name = !key_name.is_empty()
|
||||
&& key_name.len() <= 253
|
||||
&& key_name.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().enumerate().all(|(index, byte)| {
|
||||
(byte.is_ascii_lowercase() || byte.is_ascii_digit())
|
||||
|| (byte == b'-' && index > 0 && index + 1 < label.len())
|
||||
})
|
||||
});
|
||||
if !valid_name {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()));
|
||||
}
|
||||
if request.get("key_algorithm").is_some_and(|value| !value.is_string()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
|
||||
}
|
||||
let key_algorithm = request
|
||||
.get("key_algorithm")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("HMAC-SHA256");
|
||||
if !matches!(key_algorithm, "HMAC-SHA256" | "HMAC-SHA384" | "HMAC-SHA512") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
|
||||
}
|
||||
let key_secret = request.get("key_secret").and_then(Value::as_str).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()))?;
|
||||
let decoded = Zeroizing::new(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(key_secret)
|
||||
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()))?,
|
||||
);
|
||||
if key_secret.len() > 1024 || !(16..=512).contains(&decoded.len()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()));
|
||||
}
|
||||
if request.get("sign_query").is_some_and(|value| !value.is_boolean()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_sign_query".into()));
|
||||
}
|
||||
let sign_query = request.get("sign_query").and_then(Value::as_bool).unwrap_or(false);
|
||||
format!(
|
||||
"dns_rfc2136_server = {dns_server}\ndns_rfc2136_port = {dns_port}\ndns_rfc2136_name = {key_name}.\ndns_rfc2136_secret = {key_secret}\ndns_rfc2136_algorithm = {key_algorithm}\ndns_rfc2136_sign_query = {sign_query}\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
"http01" => Vec::new(),
|
||||
_ => return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_provider".into())),
|
||||
};
|
||||
Ok(Zeroizing::new(bytes))
|
||||
}
|
||||
|
||||
|
||||
pub struct Manager {
|
||||
slot: Arc<Semaphore>,
|
||||
job: Mutex<Option<Job>>,
|
||||
@@ -137,6 +233,22 @@ impl Manager {
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "acme.job_busy".into()))?;
|
||||
if provider != "http01" {
|
||||
let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| {
|
||||
ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into())
|
||||
})?;
|
||||
let credential = provider_credential_bytes(&request, &provider)?;
|
||||
crate::secret_store::put(
|
||||
&state.store,
|
||||
data_dir,
|
||||
&crate::secret_store::acme_secret_id(&id, &provider),
|
||||
&provider,
|
||||
credential.as_ref(),
|
||||
serde_json::json!({"certificate_id": id.clone(), "purpose": "acme_dns"}),
|
||||
)
|
||||
.await
|
||||
.map_err(crate::auth::internal)?;
|
||||
}
|
||||
let raw = Zeroizing::new(serde_json::to_string(&request).map_err(crate::auth::internal)?);
|
||||
for key in [
|
||||
"key_secret",
|
||||
@@ -789,9 +901,28 @@ impl Manager {
|
||||
.await
|
||||
.map_err(crate::auth::internal)?;
|
||||
*state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(job.clone());
|
||||
let request = serde_json::json!({"action":"renew","certificate_id":cert.id,"provider":issuer.provider,
|
||||
let mut request = serde_json::json!({"action":"renew","certificate_id":cert.id,"provider":issuer.provider,
|
||||
"environment":issuer.environment,"cert_file":cert.cert_file,"key_file":cert.key_file,"dry_run":dry_run});
|
||||
if issuer.provider != "http01" {
|
||||
let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| {
|
||||
ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into())
|
||||
})?;
|
||||
let credential = crate::secret_store::get(
|
||||
&state.store,
|
||||
data_dir,
|
||||
&crate::secret_store::acme_secret_id(&cert.id, &issuer.provider),
|
||||
Some(&issuer.provider),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ApiError(StatusCode::CONFLICT, "acme.secret_missing".into()))?;
|
||||
request["credential_ini_b64"] = Value::String(
|
||||
base64::engine::general_purpose::STANDARD.encode(credential.as_ref())
|
||||
);
|
||||
}
|
||||
let raw = Zeroizing::new(request.to_string());
|
||||
if let Some(Value::String(secret)) = request.get_mut("credential_ini_b64") {
|
||||
secret.zeroize();
|
||||
}
|
||||
let task_job = job.clone();
|
||||
let tasks = state.tunnels.clone();
|
||||
tasks.spawn(async move {
|
||||
|
||||
+5
-2
@@ -1324,8 +1324,11 @@ fn status_payload(state: &App) -> Value {
|
||||
payload["transform_metrics"] = json!(state.transform_metrics.total());
|
||||
payload["logs_dropped"] = json!(state.logs.dropped.load(Ordering::Relaxed));
|
||||
payload["software_update"] = json!(state.software_update.snapshot());
|
||||
payload["logging"] =
|
||||
json!({"settings": &snapshot.envelope.config.logging, "sinks": state.logs.stats()});
|
||||
payload["logging"] = json!({
|
||||
"settings": &snapshot.envelope.config.logging,
|
||||
"sinks": state.logs.stats(),
|
||||
"error_file": crate::logging::error_log_stats(),
|
||||
});
|
||||
payload["tls"] = json!({
|
||||
"policy": &snapshot.envelope.config.tls.policy,
|
||||
"cipher_suites": crate::tls::cipher_suite_names(),
|
||||
|
||||
+110
-23
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.24.8",
|
||||
"version": "0.24.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": [
|
||||
@@ -693,6 +693,30 @@
|
||||
},
|
||||
"transform": {
|
||||
"$ref": "#/components/schemas/TransformSettings"
|
||||
},
|
||||
"error_log": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"default": "/var/log/rustproxy/error.log",
|
||||
"maxLength": 1024
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"JSON",
|
||||
"RAW"
|
||||
],
|
||||
"default": "JSON"
|
||||
}
|
||||
},
|
||||
"description": "Optional asynchronous ERROR-only file sink. Rotation and retention are handled externally by logrotate."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -916,29 +940,80 @@
|
||||
"transform_resources": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_reserved_bytes": { "type": "integer", "format": "int64" },
|
||||
"memory_budget_bytes": { "type": "integer", "format": "int64" },
|
||||
"inflight": { "type": "integer", "format": "int64" },
|
||||
"max_inflight": { "type": "integer", "format": "int64" }
|
||||
"memory_reserved_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"memory_budget_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"inflight": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"max_inflight": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"memory_accounting": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
|
||||
"cache_hot_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_hot_limit_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_transient_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_transient_limit_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_total_bytes": { "type": "integer", "format": "int64" },
|
||||
"transform_reserved_bytes": { "type": "integer", "format": "int64" },
|
||||
"transform_budget_bytes": { "type": "integer", "format": "int64" },
|
||||
"transform_inflight": { "type": "integer", "format": "int64" },
|
||||
"transform_max_inflight": { "type": "integer", "format": "int64" },
|
||||
"non_cache_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
|
||||
"non_cache_rss_includes_transform": { "type": "boolean" },
|
||||
"method": { "type": "string" }
|
||||
"process_rss_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"cache_hot_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"cache_hot_limit_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"cache_transient_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"cache_transient_limit_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"cache_total_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"transform_reserved_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"transform_budget_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"transform_inflight": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"transform_max_inflight": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"non_cache_rss_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"non_cache_rss_includes_transform": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -3239,10 +3314,22 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"memory_reserved_bytes": { "type": "integer", "format": "int64" },
|
||||
"memory_budget_bytes": { "type": "integer", "format": "int64" },
|
||||
"inflight": { "type": "integer", "format": "int64" },
|
||||
"max_inflight": { "type": "integer", "format": "int64" }
|
||||
"memory_reserved_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"memory_budget_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"inflight": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"max_inflight": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"memory_reserved_bytes",
|
||||
@@ -4332,7 +4419,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
|
||||
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
+24
-3
@@ -778,7 +778,7 @@ function renderSettings() {
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.8";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.9";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
@@ -821,14 +821,23 @@ function renderSettings() {
|
||||
updateTransformResourceSizing();
|
||||
}
|
||||
if (!settingsDirty.has("logging-form")) {
|
||||
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
|
||||
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {}, errorLog = config.error_log || {};
|
||||
$("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode();
|
||||
$("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044";
|
||||
$("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || "";
|
||||
$("log-loki-labels").value = JSON.stringify(loki.labels || { job: "rustproxy" }, null, 2); $("log-format-fields").value = JSON.stringify(format.fields || {}, null, 2); $("log-format-static").value = JSON.stringify(format.static_fields || {}, null, 2);
|
||||
$("log-format-preset").value = detectLoggingPreset(format.fields || {});
|
||||
$("error-log-enabled").checked = !!errorLog.enabled;
|
||||
$("error-log-path").value = errorLog.path || "/var/log/rustproxy/error.log";
|
||||
$("error-log-format").value = ["JSON", "RAW"].includes(errorLog.format) ? errorLog.format : "JSON";
|
||||
syncErrorLogMode();
|
||||
}
|
||||
const sink = latestStatus?.logging?.sinks, errorSink = latestStatus?.logging?.error_file;
|
||||
if (sink) {
|
||||
let status = `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}`;
|
||||
if (errorSink) status += ` · ${t("errorLogTitle")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")}`;
|
||||
$("logging-status").textContent = status;
|
||||
}
|
||||
const sink = latestStatus?.logging?.sinks; if (sink) $("logging-status").textContent = `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}`;
|
||||
if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; }
|
||||
if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); }
|
||||
}
|
||||
@@ -991,6 +1000,12 @@ function syncLoggingFileMode() {
|
||||
$("log-file-path").disabled = !enabled || perVhost;
|
||||
$("log-file-directory").disabled = !enabled || !perVhost;
|
||||
}
|
||||
function syncErrorLogMode() {
|
||||
const disabled = !$("error-log-enabled").checked;
|
||||
$("error-log-path").disabled = disabled;
|
||||
$("error-log-format").disabled = disabled;
|
||||
}
|
||||
|
||||
function syncColdCachePolicy() {
|
||||
const enabled = $("cache-cold-enabled").checked;
|
||||
for (const id of ["cache-cold-path", "cache-cold-bytes", "cache-cold-entries", "cache-cold-queue-bytes", "cache-cold-gc-interval", "cache-cold-gc-max-delete", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown"]) $(id).disabled = !enabled;
|
||||
@@ -1113,10 +1128,16 @@ bindSettingsForm("logging-form", next => {
|
||||
loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") },
|
||||
format: { fields: parseObject("log-format-fields"), static_fields: parseObject("log-format-static") }
|
||||
};
|
||||
next.error_log = {
|
||||
enabled: $("error-log-enabled").checked,
|
||||
path: $("error-log-path").value.trim(),
|
||||
format: $("error-log-format").value
|
||||
};
|
||||
}, "logging");
|
||||
bindSettingsForm("forwarding-form", next => { next.forwarding = { trusted_proxies: $("trusted-proxies").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean), trust_forwarded_proto: $("trusted-proto").checked }; }, "forwarding");
|
||||
bindSettingsForm("headers-form", next => { next.headers = { request: collectHeaderRules("request"), response: collectHeaderRules("response") }; }, "headers");
|
||||
$("log-format-preset").addEventListener("change", event => applyLoggingPreset(event.target.value));
|
||||
$("error-log-enabled").addEventListener("change", syncErrorLogMode);
|
||||
$("log-format-open").addEventListener("click", openLoggingBuilder);
|
||||
$("logging-format-close").addEventListener("click", () => $("logging-format-dialog").close());
|
||||
$("logging-format-cancel").addEventListener("click", () => $("logging-format-dialog").close());
|
||||
|
||||
@@ -1391,7 +1391,7 @@
|
||||
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
|
||||
</select>
|
||||
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.8"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.9"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -1798,6 +1798,20 @@
|
||||
<label><span data-i18n="loggingLabels">Labels (JSON object)</span><textarea
|
||||
id="log-loki-labels" rows="4" spellcheck="false"></textarea></label>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3 class="subheading" data-i18n="errorLogTitle">Error file</h3>
|
||||
<label class="check"><input id="error-log-enabled" type="checkbox" /><span
|
||||
data-i18n="enabled">Enabled</span></label>
|
||||
<label><span data-i18n="errorLogPath">Error log path</span><input
|
||||
id="error-log-path" maxlength="1024"
|
||||
placeholder="/var/log/rustproxy/error.log" /></label>
|
||||
<label><span data-i18n="errorLogFormat">Format</span><select
|
||||
id="error-log-format">
|
||||
<option value="JSON">JSON</option>
|
||||
<option value="RAW">RAW</option>
|
||||
</select></label>
|
||||
<p class="fine" data-i18n="errorLogHelp">Only ERROR events are written. Rotation is handled by system logrotate.</p>
|
||||
</article>
|
||||
</div>
|
||||
<h3 class="subheading" data-i18n="loggingFormat">Configurable JSON format</h3>
|
||||
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields. Leave
|
||||
@@ -2098,7 +2112,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.24.8</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.24.9</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -600,7 +600,7 @@
|
||||
authMode.addEventListener("change", syncAuth); syncAuth();
|
||||
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
|
||||
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
|
||||
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.8", "text", { maxLength: 256 });
|
||||
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.9", "text", { maxLength: 256 });
|
||||
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
|
||||
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
|
||||
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
|
||||
|
||||
+748
@@ -0,0 +1,748 @@
|
||||
//! Password-encrypted full backup/restore for RustProxy durable data.
|
||||
//! The format is streaming, authenticated and deliberately excludes caches,
|
||||
//! logs, sockets and temporary runtime state.
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use base64::Engine as _;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const BACKUP_MAGIC: &[u8; 8] = b"RPROXYB1";
|
||||
const ARCHIVE_MAGIC: &[u8; 8] = b"RPROXYA1";
|
||||
const KEY_MAGIC: &[u8; 4] = b"RPK1";
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
const ARCHIVE_VERSION: u32 = 1;
|
||||
const CHUNK_SIZE: usize = 1024 * 1024;
|
||||
const ARGON_MEMORY_KIB: u32 = 64 * 1024;
|
||||
const ARGON_ITERATIONS: u32 = 3;
|
||||
const ARGON_PARALLELISM: u32 = 1;
|
||||
const MAX_HEADER_BYTES: usize = 16 * 1024;
|
||||
const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
|
||||
const KEY_AAD: &[u8] = b"rustproxy-secret-store-key-v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CryptoHeader {
|
||||
format_version: u32,
|
||||
kdf: String,
|
||||
salt_b64: String,
|
||||
memory_kib: u32,
|
||||
iterations: u32,
|
||||
parallelism: u32,
|
||||
aead: String,
|
||||
nonce_prefix_b64: String,
|
||||
chunk_size: usize,
|
||||
}
|
||||
|
||||
struct CryptoContext {
|
||||
header_bytes: Vec<u8>,
|
||||
key: Zeroizing<[u8; 32]>,
|
||||
nonce_prefix: [u8; 4],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Manifest {
|
||||
archive_version: u32,
|
||||
rustproxy_version: String,
|
||||
schema_version: i64,
|
||||
configuration_revision: u64,
|
||||
created_at_unix: i64,
|
||||
secret_store_encryption_version: u32,
|
||||
backup_encryption: String,
|
||||
entries: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ManifestEntry {
|
||||
path: String,
|
||||
size: u64,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
enum EntrySource {
|
||||
File(PathBuf),
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
struct ArchiveEntry {
|
||||
manifest: ManifestEntry,
|
||||
source: EntrySource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupSummary {
|
||||
pub path: PathBuf,
|
||||
pub files: usize,
|
||||
pub configuration_revision: u64,
|
||||
}
|
||||
|
||||
struct TempRoot(PathBuf);
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct PartialFile(PathBuf, bool);
|
||||
impl Drop for PartialFile {
|
||||
fn drop(&mut self) {
|
||||
if !self.1 {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_or_dot(path: &Path) -> &Path {
|
||||
path.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
}
|
||||
|
||||
fn validate_backup_version(version: &str) -> Result<()> {
|
||||
fn parse(value: &str) -> Result<(u64, u64, u64)> {
|
||||
let value = value.split_once('-').map_or(value, |(base, _)| base);
|
||||
let mut parts = value.split('.');
|
||||
let major = parts.next().context("missing major version")?.parse()?;
|
||||
let minor = parts.next().context("missing minor version")?.parse()?;
|
||||
let patch = parts.next().context("missing patch version")?.parse()?;
|
||||
ensure!(parts.next().is_none(), "invalid RustProxy version");
|
||||
Ok((major, minor, patch))
|
||||
}
|
||||
let backup = parse(version).context("invalid RustProxy version in backup manifest")?;
|
||||
let current = parse(env!("CARGO_PKG_VERSION")).context("invalid running RustProxy version")?;
|
||||
ensure!(
|
||||
backup <= current,
|
||||
"backup was created by newer RustProxy version {version}; current version is {}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn harden_tree(path: &Path) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
ensure!(!metadata.file_type().is_symlink(), "restore staging contains a symlink");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = if metadata.is_dir() { 0o700 } else { 0o600 };
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
harden_tree(&entry?.path())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn private_dir(path: &Path) -> Result<()> {
|
||||
fs::create_dir_all(path)?;
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "backup directory must not be a symlink");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn private_file_options() -> OpenOptions {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn create_temp_root(parent: &Path, prefix: &str) -> Result<TempRoot> {
|
||||
ensure!(parent.is_dir(), "backup parent directory does not exist");
|
||||
for _ in 0..32 {
|
||||
let name = format!(".{prefix}-{:016x}", rand::random::<u64>());
|
||||
let path = parent.join(name);
|
||||
match fs::create_dir(&path) {
|
||||
Ok(()) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
return Ok(TempRoot(path));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
bail!("could not allocate private backup staging directory")
|
||||
}
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||
ensure!(!password.is_empty(), "backup password cannot be empty");
|
||||
let params = Params::new(
|
||||
ARGON_MEMORY_KIB,
|
||||
ARGON_ITERATIONS,
|
||||
ARGON_PARALLELISM,
|
||||
Some(32),
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("invalid backup KDF parameters"))?;
|
||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
argon
|
||||
.hash_password_into(password.as_bytes(), salt, key.as_mut())
|
||||
.map_err(|_| anyhow::anyhow!("backup key derivation failed"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn new_crypto(password: &str) -> Result<CryptoContext> {
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce_prefix = [0u8; 4];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce_prefix);
|
||||
let header = CryptoHeader {
|
||||
format_version: FORMAT_VERSION,
|
||||
kdf: "Argon2id".into(),
|
||||
salt_b64: base64::engine::general_purpose::STANDARD.encode(salt),
|
||||
memory_kib: ARGON_MEMORY_KIB,
|
||||
iterations: ARGON_ITERATIONS,
|
||||
parallelism: ARGON_PARALLELISM,
|
||||
aead: "AES-256-GCM".into(),
|
||||
nonce_prefix_b64: base64::engine::general_purpose::STANDARD.encode(nonce_prefix),
|
||||
chunk_size: CHUNK_SIZE,
|
||||
};
|
||||
let header_bytes = serde_json::to_vec(&header)?;
|
||||
let key = derive_key(password, &salt)?;
|
||||
Ok(CryptoContext {
|
||||
header_bytes,
|
||||
key,
|
||||
nonce_prefix,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_crypto_header(bytes: Vec<u8>, password: &str) -> Result<CryptoContext> {
|
||||
let header: CryptoHeader = serde_json::from_slice(&bytes).context("invalid backup header")?;
|
||||
ensure!(
|
||||
header.format_version == FORMAT_VERSION
|
||||
&& header.kdf == "Argon2id"
|
||||
&& header.memory_kib == ARGON_MEMORY_KIB
|
||||
&& header.iterations == ARGON_ITERATIONS
|
||||
&& header.parallelism == ARGON_PARALLELISM
|
||||
&& header.aead == "AES-256-GCM"
|
||||
&& header.chunk_size == CHUNK_SIZE,
|
||||
"unsupported backup cryptographic format"
|
||||
);
|
||||
let salt = base64::engine::general_purpose::STANDARD
|
||||
.decode(&header.salt_b64)
|
||||
.context("invalid backup salt")?;
|
||||
ensure!(salt.len() == 16, "invalid backup salt");
|
||||
let prefix = base64::engine::general_purpose::STANDARD
|
||||
.decode(&header.nonce_prefix_b64)
|
||||
.context("invalid backup nonce prefix")?;
|
||||
ensure!(prefix.len() == 4, "invalid backup nonce prefix");
|
||||
let mut nonce_prefix = [0u8; 4];
|
||||
nonce_prefix.copy_from_slice(&prefix);
|
||||
let key = derive_key(password, &salt)?;
|
||||
Ok(CryptoContext {
|
||||
header_bytes: bytes,
|
||||
key,
|
||||
nonce_prefix,
|
||||
})
|
||||
}
|
||||
|
||||
fn cipher(key: &[u8]) -> Result<LessSafeKey> {
|
||||
Ok(LessSafeKey::new(
|
||||
UnboundKey::new(&AES_256_GCM, key)
|
||||
.map_err(|_| anyhow::anyhow!("cannot initialize backup cipher"))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn nonce(prefix: [u8; 4], counter: u64) -> Nonce {
|
||||
let mut bytes = [0u8; 12];
|
||||
bytes[..4].copy_from_slice(&prefix);
|
||||
bytes[4..].copy_from_slice(&counter.to_be_bytes());
|
||||
Nonce::assume_unique_for_key(bytes)
|
||||
}
|
||||
|
||||
fn chunk_aad(header: &[u8], counter: u64) -> Vec<u8> {
|
||||
let mut aad = Vec::with_capacity(BACKUP_MAGIC.len() + header.len() + 8);
|
||||
aad.extend_from_slice(BACKUP_MAGIC);
|
||||
aad.extend_from_slice(header);
|
||||
aad.extend_from_slice(&counter.to_be_bytes());
|
||||
aad
|
||||
}
|
||||
|
||||
fn encrypt_archive(input: &Path, output: &Path, crypto: &CryptoContext) -> Result<()> {
|
||||
ensure!(!output.exists(), "backup output already exists");
|
||||
let mut source = File::open(input)?;
|
||||
let mut target = private_file_options().create_new(true).open(output)?;
|
||||
let mut guard = PartialFile(output.to_path_buf(), false);
|
||||
target.write_all(BACKUP_MAGIC)?;
|
||||
let header_len: u32 = crypto.header_bytes.len().try_into()?;
|
||||
target.write_all(&header_len.to_be_bytes())?;
|
||||
target.write_all(&crypto.header_bytes)?;
|
||||
let cipher = cipher(crypto.key.as_ref())?;
|
||||
let mut counter = 0u64;
|
||||
let mut buffer = vec![0u8; CHUNK_SIZE];
|
||||
loop {
|
||||
let read = source.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let mut chunk = buffer[..read].to_vec();
|
||||
cipher
|
||||
.seal_in_place_append_tag(
|
||||
nonce(crypto.nonce_prefix, counter),
|
||||
Aad::from(chunk_aad(&crypto.header_bytes, counter)),
|
||||
&mut chunk,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("backup encryption failed"))?;
|
||||
let length: u32 = chunk.len().try_into()?;
|
||||
target.write_all(&length.to_be_bytes())?;
|
||||
target.write_all(&chunk)?;
|
||||
counter = counter.checked_add(1).context("backup is too large")?;
|
||||
}
|
||||
target.write_all(&0u32.to_be_bytes())?;
|
||||
target.sync_all()?;
|
||||
guard.1 = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let metadata = fs::symlink_metadata(input)?;
|
||||
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup must be a regular file");
|
||||
let mut source = File::open(input)?;
|
||||
let mut magic = [0u8; 8];
|
||||
source.read_exact(&mut magic)?;
|
||||
ensure!(&magic == BACKUP_MAGIC, "not a RustProxy backup");
|
||||
let header_len = read_u32(&mut source)? as usize;
|
||||
ensure!(header_len > 0 && header_len <= MAX_HEADER_BYTES, "invalid backup header length");
|
||||
let mut header_bytes = vec![0u8; header_len];
|
||||
source.read_exact(&mut header_bytes)?;
|
||||
let crypto = parse_crypto_header(header_bytes, password)?;
|
||||
let cipher = cipher(crypto.key.as_ref())?;
|
||||
let mut target = private_file_options().create_new(true).open(output)?;
|
||||
let mut counter = 0u64;
|
||||
loop {
|
||||
let length = read_u32(&mut source)? as usize;
|
||||
if length == 0 {
|
||||
break;
|
||||
}
|
||||
ensure!(
|
||||
length >= AES_256_GCM.tag_len() && length <= CHUNK_SIZE + AES_256_GCM.tag_len(),
|
||||
"invalid encrypted backup chunk"
|
||||
);
|
||||
let mut chunk = vec![0u8; length];
|
||||
source.read_exact(&mut chunk)?;
|
||||
let plaintext = cipher
|
||||
.open_in_place(
|
||||
nonce(crypto.nonce_prefix, counter),
|
||||
Aad::from(chunk_aad(&crypto.header_bytes, counter)),
|
||||
&mut chunk,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("incorrect backup password or corrupted archive"))?;
|
||||
target.write_all(plaintext)?;
|
||||
counter = counter.checked_add(1).context("backup is too large")?;
|
||||
}
|
||||
let mut extra = [0u8; 1];
|
||||
ensure!(source.read(&mut extra)? == 0, "trailing data after encrypted backup");
|
||||
target.sync_all()?;
|
||||
Ok(crypto.key)
|
||||
}
|
||||
|
||||
fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result<Vec<u8>> {
|
||||
ensure!(master.len() == 32, "invalid secret store master key");
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let cipher = cipher(backup_key)?;
|
||||
let mut encrypted = Zeroizing::new(master.to_vec());
|
||||
cipher
|
||||
.seal_in_place_append_tag(
|
||||
Nonce::assume_unique_for_key(nonce_bytes),
|
||||
Aad::from(KEY_AAD),
|
||||
encrypted.as_mut(),
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("could not wrap secret store key"))?;
|
||||
let mut output = Vec::with_capacity(4 + 12 + encrypted.len());
|
||||
output.extend_from_slice(KEY_MAGIC);
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(encrypted.as_ref());
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
|
||||
ensure!(blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(), "invalid wrapped secret store key");
|
||||
ensure!(&blob[..4] == KEY_MAGIC, "invalid wrapped secret store key");
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes.copy_from_slice(&blob[4..16]);
|
||||
let mut encrypted = Zeroizing::new(blob[16..].to_vec());
|
||||
let cipher = cipher(backup_key)?;
|
||||
let plaintext = cipher
|
||||
.open_in_place(
|
||||
Nonce::assume_unique_for_key(nonce_bytes),
|
||||
Aad::from(KEY_AAD),
|
||||
encrypted.as_mut(),
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("wrapped secret store key authentication failed"))?;
|
||||
Ok(Zeroizing::new(plaintext.to_vec()))
|
||||
}
|
||||
|
||||
fn hash_file(path: &Path) -> Result<(u64, String)> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup input must be a regular file");
|
||||
let mut file = File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut size = 0u64;
|
||||
let mut buffer = [0u8; 1024 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
size = size.checked_add(read as u64).context("backup input too large")?;
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok((size, format!("{:x}", hasher.finalize())))
|
||||
}
|
||||
|
||||
fn hash_bytes(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn excluded_directory(name: &str) -> bool {
|
||||
matches!(name, "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01")
|
||||
}
|
||||
fn excluded_file(name: &str) -> bool {
|
||||
name == "process.lock"
|
||||
|| name == "config.sqlite3"
|
||||
|| name == "config.sqlite3-wal"
|
||||
|| name == "config.sqlite3-shm"
|
||||
|| name == "master.key"
|
||||
|| name == ".lock"
|
||||
|| name.ends_with(".sock")
|
||||
|| name.ends_with(".pid")
|
||||
|| name.ends_with(".log")
|
||||
|| name.ends_with(".jsonl")
|
||||
}
|
||||
|
||||
fn collect_data_files(root: &Path, current: &Path, entries: &mut Vec<ArchiveEntry>) -> Result<()> {
|
||||
for item in fs::read_dir(current)? {
|
||||
let item = item?;
|
||||
let path = item.path();
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
ensure!(!meta.file_type().is_symlink(), "backup refuses symlinks in the data directory: {}", path.display());
|
||||
let name = item.file_name();
|
||||
let name_text = name.to_string_lossy();
|
||||
if meta.is_dir() {
|
||||
if excluded_directory(&name_text) || (path == root.join("secrets")) {
|
||||
continue;
|
||||
}
|
||||
collect_data_files(root, &path, entries)?;
|
||||
} else if meta.is_file() {
|
||||
if excluded_file(&name_text) {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(root)?;
|
||||
let archive_path = format!("data/{}", relative.to_string_lossy().replace('\\', "/"));
|
||||
let (size, sha256) = hash_file(&path)?;
|
||||
entries.push(ArchiveEntry {
|
||||
manifest: ManifestEntry { path: archive_path, size, sha256 },
|
||||
source: EntrySource::File(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry]) -> Result<()> {
|
||||
let mut file = private_file_options().create_new(true).open(path)?;
|
||||
file.write_all(ARCHIVE_MAGIC)?;
|
||||
let manifest_bytes = serde_json::to_vec(manifest)?;
|
||||
ensure!(manifest_bytes.len() <= MAX_MANIFEST_BYTES, "backup manifest is too large");
|
||||
file.write_all(&(manifest_bytes.len() as u32).to_be_bytes())?;
|
||||
file.write_all(&manifest_bytes)?;
|
||||
let mut buffer = [0u8; 1024 * 1024];
|
||||
for entry in entries {
|
||||
let path_bytes = entry.manifest.path.as_bytes();
|
||||
ensure!(!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize, "backup path is too long");
|
||||
file.write_all(&(path_bytes.len() as u16).to_be_bytes())?;
|
||||
file.write_all(path_bytes)?;
|
||||
file.write_all(&entry.manifest.size.to_be_bytes())?;
|
||||
match &entry.source {
|
||||
EntrySource::File(source) => {
|
||||
let mut input = File::open(source)?;
|
||||
let mut remaining = entry.manifest.size;
|
||||
while remaining > 0 {
|
||||
let take = usize::try_from(remaining.min(buffer.len() as u64))?;
|
||||
let read = input.read(&mut buffer[..take])?;
|
||||
ensure!(read > 0, "backup input changed while being read");
|
||||
file.write_all(&buffer[..read])?;
|
||||
remaining -= read as u64;
|
||||
}
|
||||
ensure!(input.read(&mut buffer[..1])? == 0, "backup input changed while being read");
|
||||
}
|
||||
EntrySource::Bytes(bytes) => file.write_all(bytes)?,
|
||||
}
|
||||
}
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_u16<R: Read>(reader: &mut R) -> Result<u16> {
|
||||
let mut bytes = [0u8; 2];
|
||||
reader.read_exact(&mut bytes)?;
|
||||
Ok(u16::from_be_bytes(bytes))
|
||||
}
|
||||
fn read_u32<R: Read>(reader: &mut R) -> Result<u32> {
|
||||
let mut bytes = [0u8; 4];
|
||||
reader.read_exact(&mut bytes)?;
|
||||
Ok(u32::from_be_bytes(bytes))
|
||||
}
|
||||
fn read_u64<R: Read>(reader: &mut R) -> Result<u64> {
|
||||
let mut bytes = [0u8; 8];
|
||||
reader.read_exact(&mut bytes)?;
|
||||
Ok(u64::from_be_bytes(bytes))
|
||||
}
|
||||
|
||||
fn safe_relative(path: &str) -> Result<PathBuf> {
|
||||
ensure!(!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'), "unsafe backup path");
|
||||
let value = Path::new(path);
|
||||
ensure!(!value.is_absolute(), "unsafe absolute backup path");
|
||||
for component in value.components() {
|
||||
ensure!(matches!(component, Component::Normal(_)), "unsafe backup path component");
|
||||
}
|
||||
Ok(value.to_path_buf())
|
||||
}
|
||||
|
||||
fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>)> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut magic = [0u8; 8];
|
||||
file.read_exact(&mut magic)?;
|
||||
ensure!(&magic == ARCHIVE_MAGIC, "invalid decrypted RustProxy backup payload");
|
||||
let manifest_len = read_u32(&mut file)? as usize;
|
||||
ensure!(manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES, "invalid backup manifest length");
|
||||
let mut manifest_bytes = vec![0u8; manifest_len];
|
||||
file.read_exact(&mut manifest_bytes)?;
|
||||
let manifest: Manifest = serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?;
|
||||
ensure!(manifest.archive_version == ARCHIVE_VERSION, "unsupported backup archive version");
|
||||
validate_backup_version(&manifest.rustproxy_version)?;
|
||||
ensure!(manifest.schema_version <= crate::db::SCHEMA_VERSION, "backup database schema is newer than this RustProxy binary");
|
||||
ensure!(manifest.entries.len() <= 100_000, "backup contains too many files");
|
||||
let mut wrapped_key = None;
|
||||
let mut buffer = vec![0u8; 1024 * 1024];
|
||||
for expected in &manifest.entries {
|
||||
let path_len = read_u16(&mut file)? as usize;
|
||||
ensure!(path_len > 0 && path_len <= 4096, "invalid backup path length");
|
||||
let mut path_bytes = vec![0u8; path_len];
|
||||
file.read_exact(&mut path_bytes)?;
|
||||
let archive_path = String::from_utf8(path_bytes).context("backup path is not UTF-8")?;
|
||||
ensure!(archive_path == expected.path, "backup manifest entry order/path mismatch");
|
||||
let size = read_u64(&mut file)?;
|
||||
ensure!(size == expected.size, "backup manifest size mismatch");
|
||||
let mut hasher = Sha256::new();
|
||||
if archive_path == "keys/secret-store-key.enc" {
|
||||
ensure!(size <= 256, "wrapped secret store key is too large");
|
||||
let mut bytes = vec![0u8; size as usize];
|
||||
file.read_exact(&mut bytes)?;
|
||||
hasher.update(&bytes);
|
||||
wrapped_key = Some(bytes);
|
||||
} else {
|
||||
let relative = archive_path
|
||||
.strip_prefix("data/")
|
||||
.ok_or_else(|| anyhow::anyhow!("unsupported backup entry"))?;
|
||||
let relative = safe_relative(relative)?;
|
||||
let target = stage.join(relative);
|
||||
if let Some(parent) = target.parent() {
|
||||
private_dir(parent)?;
|
||||
}
|
||||
let mut output = private_file_options().create_new(true).open(&target)?;
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let take = usize::try_from(remaining.min(buffer.len() as u64))?;
|
||||
file.read_exact(&mut buffer[..take])?;
|
||||
output.write_all(&buffer[..take])?;
|
||||
hasher.update(&buffer[..take]);
|
||||
remaining -= take as u64;
|
||||
}
|
||||
output.sync_all()?;
|
||||
}
|
||||
ensure!(format!("{:x}", hasher.finalize()) == expected.sha256, "backup entry checksum mismatch");
|
||||
}
|
||||
let mut extra = [0u8; 1];
|
||||
ensure!(file.read(&mut extra)? == 0, "trailing data in backup payload");
|
||||
let key = wrapped_key.context("backup is missing Secret Store key material")?;
|
||||
ensure!(stage.join("config.sqlite3").is_file(), "backup is missing database");
|
||||
Ok((manifest, key))
|
||||
}
|
||||
|
||||
pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<BackupSummary> {
|
||||
crate::auth::prepare_directory(data_dir)?;
|
||||
let _lock = crate::auth::lock_directory(data_dir)
|
||||
.context("stop RustProxy before creating a full backup")?;
|
||||
let database = data_dir.join("config.sqlite3");
|
||||
ensure!(database.is_file(), "database does not exist at {}", database.display());
|
||||
crate::auth::prepare_database(&database)?;
|
||||
let store = crate::db::Store::open(&database, &crate::config::Config::default()).await?;
|
||||
crate::secret_store::initialize(data_dir)?;
|
||||
let cert_dir = data_dir.join("certs");
|
||||
if cert_dir.is_dir() {
|
||||
crate::secret_store::migrate_acme_credentials(&store, data_dir, &cert_dir).await?;
|
||||
}
|
||||
let parent = parent_or_dot(output);
|
||||
let data_root = fs::canonicalize(data_dir).context("cannot resolve data directory")?;
|
||||
let output_parent = fs::canonicalize(parent).context("cannot resolve backup output directory")?;
|
||||
ensure!(
|
||||
!output_parent.starts_with(&data_root),
|
||||
"backup output must be outside the RustProxy data directory"
|
||||
);
|
||||
let temp = create_temp_root(parent, "rustproxy-backup")?;
|
||||
let snapshot = temp.0.join("config.sqlite3");
|
||||
store.backup_to(&snapshot).await?;
|
||||
let envelope = store.load().await?;
|
||||
let crypto = new_crypto(password)?;
|
||||
let master = crate::secret_store::read_master_key_for_backup(data_dir)?;
|
||||
let key_blob = seal_master_key(master.as_ref(), crypto.key.as_ref())?;
|
||||
|
||||
let (db_size, db_hash) = hash_file(&snapshot)?;
|
||||
let mut entries = vec![ArchiveEntry {
|
||||
manifest: ManifestEntry {
|
||||
path: "data/config.sqlite3".into(),
|
||||
size: db_size,
|
||||
sha256: db_hash,
|
||||
},
|
||||
source: EntrySource::File(snapshot),
|
||||
}];
|
||||
collect_data_files(data_dir, data_dir, &mut entries)?;
|
||||
entries.push(ArchiveEntry {
|
||||
manifest: ManifestEntry {
|
||||
path: "keys/secret-store-key.enc".into(),
|
||||
size: key_blob.len() as u64,
|
||||
sha256: hash_bytes(&key_blob),
|
||||
},
|
||||
source: EntrySource::Bytes(key_blob),
|
||||
});
|
||||
entries.sort_by(|a, b| a.manifest.path.cmp(&b.manifest.path));
|
||||
let manifest = Manifest {
|
||||
archive_version: ARCHIVE_VERSION,
|
||||
rustproxy_version: env!("CARGO_PKG_VERSION").into(),
|
||||
schema_version: crate::db::SCHEMA_VERSION,
|
||||
configuration_revision: envelope.revision,
|
||||
created_at_unix: crate::auth::now(),
|
||||
secret_store_encryption_version: 1,
|
||||
backup_encryption: "Argon2id + AES-256-GCM chunked AEAD".into(),
|
||||
entries: entries.iter().map(|entry| entry.manifest.clone()).collect(),
|
||||
};
|
||||
let plaintext = temp.0.join("archive.rpa");
|
||||
write_inner_archive(&plaintext, &manifest, &entries)?;
|
||||
encrypt_archive(&plaintext, output, &crypto)?;
|
||||
store.close().await;
|
||||
Ok(BackupSummary {
|
||||
path: output.to_path_buf(),
|
||||
files: manifest.entries.len(),
|
||||
configuration_revision: manifest.configuration_revision,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<BackupSummary> {
|
||||
let archive_meta = fs::symlink_metadata(archive).context("backup archive does not exist")?;
|
||||
ensure!(archive_meta.is_file() && !archive_meta.file_type().is_symlink(), "backup archive must be a regular file");
|
||||
let parent = parent_or_dot(data_dir);
|
||||
ensure!(parent.is_dir(), "restore parent directory does not exist");
|
||||
let old_lock = if data_dir.exists() {
|
||||
crate::auth::prepare_directory(data_dir)?;
|
||||
Some(crate::auth::lock_directory(data_dir).context("stop RustProxy before restore")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let temp = create_temp_root(parent, "rustproxy-restore")?;
|
||||
let plaintext = temp.0.join("archive.rpa");
|
||||
let backup_key = decrypt_archive(archive, &plaintext, password)?;
|
||||
let stage = temp.0.join("stage");
|
||||
private_dir(&stage)?;
|
||||
let (manifest, wrapped_key) = extract_inner_archive(&plaintext, &stage)?;
|
||||
let master = open_master_key(&wrapped_key, backup_key.as_ref())?;
|
||||
crate::secret_store::write_restored_master_key(&stage, master.as_ref())?;
|
||||
harden_tree(&stage)?;
|
||||
|
||||
crate::auth::prepare_directory(&stage)?;
|
||||
let stage_db = stage.join("config.sqlite3");
|
||||
crate::auth::prepare_database(&stage_db)?;
|
||||
let store = crate::db::Store::open(&stage_db, &crate::config::Config::default()).await?;
|
||||
let envelope = store.load().await.context("restored configuration is unreadable")?;
|
||||
crate::secret_store::validate_all(&store, &stage)
|
||||
.await
|
||||
.context("restored Secret Store cannot be decrypted")?;
|
||||
store.close().await;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let mut name = stage_db.as_os_str().to_os_string();
|
||||
name.push(suffix);
|
||||
let _ = fs::remove_file(PathBuf::from(name));
|
||||
}
|
||||
let stage_lock = crate::auth::lock_directory(&stage)?;
|
||||
let previous = temp.0.join("previous-data");
|
||||
if data_dir.exists() {
|
||||
fs::rename(data_dir, &previous).context("could not move current data directory aside")?;
|
||||
}
|
||||
if let Err(error) = fs::rename(&stage, data_dir) {
|
||||
if previous.exists() {
|
||||
let _ = fs::rename(&previous, data_dir);
|
||||
}
|
||||
return Err(error).context("could not atomically activate restored data directory");
|
||||
}
|
||||
drop(old_lock);
|
||||
if previous.exists() {
|
||||
fs::remove_dir_all(&previous).context("restore succeeded but old data directory could not be removed")?;
|
||||
}
|
||||
drop(stage_lock);
|
||||
Ok(BackupSummary {
|
||||
path: archive.to_path_buf(),
|
||||
files: manifest.entries.len(),
|
||||
configuration_revision: envelope.revision,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generated_password() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn password_encryption_rejects_wrong_password_and_tampering() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("input");
|
||||
let encrypted = dir.path().join("backup.rpb");
|
||||
let output = dir.path().join("output");
|
||||
fs::write(&input, b"private backup payload").unwrap();
|
||||
let crypto = new_crypto("correct horse battery staple").unwrap();
|
||||
encrypt_archive(&input, &encrypted, &crypto).unwrap();
|
||||
assert!(decrypt_archive(&encrypted, &output, "wrong password").is_err());
|
||||
decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap();
|
||||
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_paths_cannot_escape_staging() {
|
||||
assert!(safe_relative("certs/example.pem").is_ok());
|
||||
assert!(safe_relative("../outside").is_err());
|
||||
assert!(safe_relative("/absolute").is_err());
|
||||
assert!(safe_relative("a\\b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_newer_rustproxy_version() {
|
||||
assert!(validate_backup_version(env!("CARGO_PKG_VERSION")).is_ok());
|
||||
assert!(validate_backup_version("999.0.0").is_err());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub logging: crate::logging::Settings,
|
||||
#[serde(default)]
|
||||
pub error_log: crate::logging::ErrorLogSettings,
|
||||
#[serde(default)]
|
||||
pub prometheus: crate::prometheus::Settings,
|
||||
#[serde(default)]
|
||||
pub listeners: crate::settings::Listeners,
|
||||
@@ -813,6 +815,7 @@ pub fn compile_with_active(
|
||||
) -> Result<Snapshot> {
|
||||
config.administration.validate(&config.tls)?;
|
||||
config.logging.validate()?;
|
||||
config.error_log.validate()?;
|
||||
let desired_admin = config.administration.resolved_listen(policy.admin_addr);
|
||||
// A persisted non-loopback address is only accepted with administration TLS.
|
||||
// When `listen` is omitted, the startup/CLI address remains authoritative and
|
||||
|
||||
@@ -9,7 +9,7 @@ use sqlx::{
|
||||
};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
pub const SCHEMA_VERSION: i64 = 6;
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
|
||||
pub const AUDIT_CAPACITY: i64 = 10_000;
|
||||
|
||||
@@ -91,16 +91,50 @@ impl Store {
|
||||
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::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
|
||||
.bind(serde_json::to_string(initial)?)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA user_version = 6")
|
||||
sqlx::query("PRAGMA user_version = 7")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
pub async fn backup_to(&self, path: &Path) -> Result<()> {
|
||||
ensure!(!path.exists(), "backup database destination already exists");
|
||||
let path_text = path
|
||||
.to_str()
|
||||
.context("backup database path must be valid UTF-8")?;
|
||||
sqlx::query("VACUUM INTO ?")
|
||||
.bind(path_text)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("could not create consistent SQLite snapshot")?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.read_only(true)
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
let check_pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
let result: String = sqlx::query_scalar("PRAGMA quick_check")
|
||||
.fetch_one(&check_pool)
|
||||
.await?;
|
||||
check_pool.close().await;
|
||||
ensure!(result == "ok", "SQLite snapshot integrity check failed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load(&self) -> Result<Envelope> {
|
||||
let row = sqlx::query("SELECT revision, document FROM configuration WHERE id = 1")
|
||||
.fetch_one(&self.pool)
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod admin;
|
||||
#[path = "auth/mod.rs"]
|
||||
pub mod auth;
|
||||
pub mod basic_auth;
|
||||
pub mod backup;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod demo;
|
||||
@@ -33,6 +34,7 @@ pub mod forwarding;
|
||||
pub mod geoip;
|
||||
pub mod http3;
|
||||
pub mod security;
|
||||
pub mod secret_store;
|
||||
pub mod tinyauth;
|
||||
pub mod vhost;
|
||||
|
||||
|
||||
+445
-4
@@ -16,20 +16,23 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
io::{self, Write as IoWrite},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
Arc, Mutex, OnceLock,
|
||||
},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::{
|
||||
fs::{File, OpenOptions},
|
||||
io::AsyncWriteExt,
|
||||
net::TcpStream,
|
||||
sync::{broadcast, mpsc},
|
||||
sync::{broadcast, mpsc, watch},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::{layer::Context as LayerContext, Layer};
|
||||
|
||||
pub const LIVE_WINDOW: usize = 256;
|
||||
pub const HISTORY_CAPACITY: usize = 4096;
|
||||
@@ -238,6 +241,411 @@ fn source_value_name(name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ErrorLogFormat {
|
||||
#[serde(rename = "JSON")]
|
||||
Json,
|
||||
#[serde(rename = "RAW")]
|
||||
Raw,
|
||||
}
|
||||
impl Default for ErrorLogFormat {
|
||||
fn default() -> Self { Self::Json }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ErrorLogSettings {
|
||||
pub enabled: bool,
|
||||
pub path: String,
|
||||
pub format: ErrorLogFormat,
|
||||
}
|
||||
impl Default for ErrorLogSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, path: "/var/log/rustproxy/error.log".into(), format: ErrorLogFormat::Json }
|
||||
}
|
||||
}
|
||||
impl ErrorLogSettings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.enabled {
|
||||
ensure!(
|
||||
!self.path.trim().is_empty() && self.path.len() <= 1024 && !self.path.chars().any(char::is_control),
|
||||
"error_log.path must be 1..1024 bytes without controls"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const ERROR_LOG_QUEUE: usize = 512;
|
||||
const ERROR_LOG_RETRY: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ErrorLogStats {
|
||||
pub queue_capacity: usize,
|
||||
pub queue_dropped: u64,
|
||||
pub written: u64,
|
||||
pub errors: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ErrorEvent {
|
||||
timestamp: String,
|
||||
target: String,
|
||||
message: String,
|
||||
fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub struct ErrorLogController {
|
||||
settings: Arc<ArcSwap<ErrorLogSettings>>,
|
||||
settings_changed: watch::Sender<()>,
|
||||
sender: mpsc::Sender<ErrorEvent>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
written: Arc<AtomicU64>,
|
||||
errors: Arc<AtomicU64>,
|
||||
}
|
||||
impl ErrorLogController {
|
||||
pub fn start() -> Arc<Self> {
|
||||
let (sender, receiver) = mpsc::channel(ERROR_LOG_QUEUE);
|
||||
let (settings_changed, settings_watch) = watch::channel(());
|
||||
let settings = Arc::new(ArcSwap::from_pointee(ErrorLogSettings::default()));
|
||||
let dropped = Arc::new(AtomicU64::new(0));
|
||||
let written = Arc::new(AtomicU64::new(0));
|
||||
let errors = Arc::new(AtomicU64::new(0));
|
||||
tokio::spawn(error_log_loop(
|
||||
receiver,
|
||||
settings.clone(),
|
||||
settings_watch,
|
||||
written.clone(),
|
||||
errors.clone(),
|
||||
));
|
||||
Arc::new(Self { settings, settings_changed, sender, dropped, written, errors })
|
||||
}
|
||||
pub fn configure(&self, settings: ErrorLogSettings) {
|
||||
self.settings.store(Arc::new(settings));
|
||||
self.settings_changed.send_replace(());
|
||||
}
|
||||
fn push(&self, event: ErrorEvent) {
|
||||
if self.settings.load().enabled && self.sender.try_send(event).is_err() {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn stats(&self) -> ErrorLogStats {
|
||||
ErrorLogStats {
|
||||
queue_capacity: ERROR_LOG_QUEUE,
|
||||
queue_dropped: self.dropped.load(Ordering::Relaxed),
|
||||
written: self.written.load(Ordering::Relaxed),
|
||||
errors: self.errors.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ERROR_LOG: OnceLock<Arc<ErrorLogController>> = OnceLock::new();
|
||||
|
||||
pub fn install_error_log_controller(controller: Arc<ErrorLogController>) -> Result<()> {
|
||||
ERROR_LOG.set(controller).map_err(|_| anyhow::anyhow!("error logger already initialized"))
|
||||
}
|
||||
pub fn configure_error_log(settings: ErrorLogSettings) {
|
||||
if let Some(controller) = ERROR_LOG.get() { controller.configure(settings); }
|
||||
}
|
||||
pub fn error_log_stats() -> Option<ErrorLogStats> {
|
||||
ERROR_LOG.get().map(|controller| controller.stats())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ErrorLogLayer {
|
||||
controller: Arc<ErrorLogController>,
|
||||
}
|
||||
impl ErrorLogLayer {
|
||||
pub fn new(controller: Arc<ErrorLogController>) -> Self { Self { controller } }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EventVisitor {
|
||||
message: Option<String>,
|
||||
fields: BTreeMap<String, String>,
|
||||
}
|
||||
impl tracing::field::Visit for EventVisitor {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
let value = format!("{value:?}");
|
||||
if field.name() == "message" { self.message = Some(value); }
|
||||
else { self.fields.insert(field.name().to_owned(), value); }
|
||||
}
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
if field.name() == "message" { self.message = Some(value.to_owned()); }
|
||||
else { self.fields.insert(field.name().to_owned(), value.to_owned()); }
|
||||
}
|
||||
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
}
|
||||
impl<S: Subscriber> Layer<S> for ErrorLogLayer {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
|
||||
if *event.metadata().level() != Level::ERROR { return; }
|
||||
let mut visitor = EventVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
let mut fields = visitor.fields;
|
||||
for (key, value) in &mut fields {
|
||||
*value = if is_secret_field(key) {
|
||||
"[REDACTED]".into()
|
||||
} else {
|
||||
redact_text(value)
|
||||
};
|
||||
}
|
||||
self.controller.push(ErrorEvent {
|
||||
timestamp: rfc3339_now(),
|
||||
target: event.metadata().target().to_owned(),
|
||||
message: redact_text(visitor.message.as_deref().unwrap_or("error")),
|
||||
fields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct RedactingStderr;
|
||||
pub struct RedactingWriter { buffer: Vec<u8> }
|
||||
impl IoWrite for RedactingWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { self.buffer.extend_from_slice(bytes); Ok(bytes.len()) }
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if self.buffer.is_empty() { return Ok(()); }
|
||||
let text = String::from_utf8_lossy(&self.buffer);
|
||||
let redacted = redact_text(&text);
|
||||
let mut stderr = io::stderr().lock();
|
||||
stderr.write_all(redacted.as_bytes())?;
|
||||
stderr.flush()?;
|
||||
self.buffer.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for RedactingWriter { fn drop(&mut self) { let _ = self.flush(); } }
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingStderr {
|
||||
type Writer = RedactingWriter;
|
||||
fn make_writer(&'a self) -> Self::Writer { RedactingWriter { buffer: Vec::with_capacity(512) } }
|
||||
}
|
||||
|
||||
fn redact_regex(pattern: &'static str) -> &'static regex::Regex {
|
||||
static AUTH: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static JSON: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static KV: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static PEM: OnceLock<regex::Regex> = OnceLock::new();
|
||||
match pattern {
|
||||
"auth" => AUTH.get_or_init(|| regex::Regex::new(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+" ).expect("redaction regex")),
|
||||
"query" => QUERY.get_or_init(|| regex::Regex::new(r"(?i)([?&](?:token|access_token|api_token|password|backup_password|secret|credential|credentials|key_secret|application_secret|consumer_key)=)[^&\s]+" ).expect("redaction regex")),
|
||||
"json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\"\s*:\s*\")[^\"]*(\")"#).expect("redaction regex")),
|
||||
"kv" => KV.get_or_init(|| regex::Regex::new(r"(?i)((?:authorization|proxy[_-]?authorization|api[_-]?token|access[_-]?token|application[_-]?secret|consumer[_-]?key|key[_-]?secret|backup[_-]?password|password|credential|credentials|secret|token|private[_-]?key|master[_-]?key)\s*[=:]\s*)[^\s,;\"']+" ).expect("redaction regex")),
|
||||
"pem" => PEM.get_or_init(|| regex::Regex::new(r"(?s)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----" ).expect("redaction regex")),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_secret_field(name: &str) -> bool {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"authorization"
|
||||
| "proxy_authorization"
|
||||
| "api_token"
|
||||
| "access_token"
|
||||
| "application_secret"
|
||||
| "consumer_key"
|
||||
| "key_secret"
|
||||
| "backup_password"
|
||||
| "password"
|
||||
| "credential"
|
||||
| "credentials"
|
||||
| "secret"
|
||||
| "token"
|
||||
| "private_key"
|
||||
| "master_key"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn redact_text(input: &str) -> String {
|
||||
let mut value = redact_regex("pem").replace_all(input, "[REDACTED PRIVATE KEY]").into_owned();
|
||||
value = redact_regex("auth").replace_all(&value, "$1[REDACTED]").into_owned();
|
||||
value = redact_regex("query").replace_all(&value, "$1[REDACTED]").into_owned();
|
||||
value = redact_regex("json").replace_all(&value, "$1[REDACTED]$2").into_owned();
|
||||
redact_regex("kv").replace_all(&value, "$1[REDACTED]").into_owned()
|
||||
}
|
||||
|
||||
fn redact_value(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(text) => *text = redact_text(text),
|
||||
Value::Array(items) => for item in items { redact_value(item); },
|
||||
Value::Object(items) => {
|
||||
for (key, item) in items {
|
||||
if is_secret_field(key) {
|
||||
*item = Value::String("[REDACTED]".into());
|
||||
} else {
|
||||
redact_value(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn rfc3339_now() -> String {
|
||||
let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64;
|
||||
let seconds = ms / 1000;
|
||||
let millis = ms % 1000;
|
||||
let days = seconds.div_euclid(86_400);
|
||||
let day_seconds = seconds.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let mut year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = mp + if mp < 10 { 3 } else { -9 };
|
||||
if month <= 2 { year += 1; }
|
||||
let hour = day_seconds / 3600;
|
||||
let minute = (day_seconds % 3600) / 60;
|
||||
let second = day_seconds % 60;
|
||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
|
||||
}
|
||||
|
||||
async fn open_error_file(path: PathBuf) -> Result<File> {
|
||||
let file = tokio::task::spawn_blocking(move || -> Result<std::fs::File> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.create(true).append(true).write(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = options.open(&path)?;
|
||||
let metadata = file.metadata()?;
|
||||
ensure!(metadata.is_file(), "error log path is not a regular file");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(file)
|
||||
}).await.map_err(|_| anyhow::anyhow!("error log open worker failed"))??;
|
||||
Ok(File::from_std(file))
|
||||
}
|
||||
|
||||
fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec<u8> {
|
||||
match format {
|
||||
ErrorLogFormat::Json => {
|
||||
let mut object = Map::new();
|
||||
object.insert("timestamp".into(), Value::String(event.timestamp.clone()));
|
||||
object.insert("level".into(), Value::String("ERROR".into()));
|
||||
object.insert("target".into(), Value::String(event.target.clone()));
|
||||
object.insert("message".into(), Value::String(event.message.clone()));
|
||||
for (key, value) in &event.fields {
|
||||
if !matches!(key.as_str(), "timestamp" | "level" | "target" | "message") {
|
||||
object.insert(key.clone(), Value::String(value.clone()));
|
||||
}
|
||||
}
|
||||
let mut line = serde_json::to_vec(&Value::Object(object)).unwrap_or_default();
|
||||
line.push(b'\n');
|
||||
line
|
||||
}
|
||||
ErrorLogFormat::Raw => {
|
||||
let one_line = |value: &str| value.replace('\r', "\\r").replace('\n', "\\n");
|
||||
let mut line = format!(
|
||||
"{} ERROR {} {}",
|
||||
event.timestamp,
|
||||
event.target,
|
||||
one_line(&event.message)
|
||||
);
|
||||
for (key, value) in &event.fields {
|
||||
line.push(' ');
|
||||
line.push_str(key);
|
||||
line.push('=');
|
||||
line.push_str(&one_line(value));
|
||||
}
|
||||
line.push('\n');
|
||||
line.into_bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn error_log_loop(
|
||||
mut receiver: mpsc::Receiver<ErrorEvent>,
|
||||
settings: Arc<ArcSwap<ErrorLogSettings>>,
|
||||
mut settings_changed: watch::Receiver<()>,
|
||||
written: Arc<AtomicU64>,
|
||||
errors: Arc<AtomicU64>,
|
||||
) {
|
||||
let mut open_path: Option<PathBuf> = None;
|
||||
let mut file: Option<File> = None;
|
||||
let mut retry_after: Option<Instant> = None;
|
||||
let mut last_warning: Option<Instant> = None;
|
||||
loop {
|
||||
let event = tokio::select! {
|
||||
event = receiver.recv() => match event {
|
||||
Some(event) => event,
|
||||
None => break,
|
||||
},
|
||||
changed = settings_changed.changed() => {
|
||||
if changed.is_err() && receiver.is_closed() {
|
||||
break;
|
||||
}
|
||||
let config = settings.load_full();
|
||||
let path = PathBuf::from(&config.path);
|
||||
if !config.enabled || open_path.as_ref() != Some(&path) {
|
||||
// Dropping Tokio's File closes the descriptor without blocking
|
||||
// the request path. A changed path is reopened on the next event.
|
||||
file = None;
|
||||
open_path = None;
|
||||
retry_after = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let config = settings.load_full();
|
||||
if !config.enabled {
|
||||
file = None;
|
||||
open_path = None;
|
||||
retry_after = None;
|
||||
continue;
|
||||
}
|
||||
let path = PathBuf::from(&config.path);
|
||||
if open_path.as_ref() != Some(&path) {
|
||||
file = None;
|
||||
open_path = Some(path.clone());
|
||||
retry_after = None;
|
||||
}
|
||||
if file.is_none() && retry_after.is_none_or(|until| Instant::now() >= until) {
|
||||
match open_error_file(path.clone()).await {
|
||||
Ok(opened) => {
|
||||
file = Some(opened);
|
||||
retry_after = None;
|
||||
}
|
||||
Err(error) => {
|
||||
errors.fetch_add(1, Ordering::Relaxed);
|
||||
retry_after = Some(Instant::now() + ERROR_LOG_RETRY);
|
||||
if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) {
|
||||
tracing::warn!(path=%path.display(), %error, "optional error log file is unavailable");
|
||||
last_warning = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(opened) = file.as_mut() else { continue; };
|
||||
let line = format_error_event(&event, config.format);
|
||||
if let Err(error) = opened.write_all(&line).await {
|
||||
errors.fetch_add(1, Ordering::Relaxed);
|
||||
file = None;
|
||||
retry_after = Some(Instant::now() + ERROR_LOG_RETRY);
|
||||
if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) {
|
||||
tracing::warn!(path=%path.display(), %error, "optional error log write failed");
|
||||
last_warning = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
written.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub vhost: Option<String>,
|
||||
@@ -476,7 +884,8 @@ async fn sink_loop(
|
||||
entry = rx.recv() => match entry { Some(entry) => entry, None => break },
|
||||
};
|
||||
let config = settings.load_full();
|
||||
let payload = format_entry(&entry, &config.format);
|
||||
let mut payload = format_entry(&entry, &config.format);
|
||||
redact_value(&mut payload);
|
||||
let mut attempted = false;
|
||||
let mut failed = false;
|
||||
if config.file.enabled {
|
||||
@@ -716,6 +1125,38 @@ mod tests {
|
||||
assert_eq!(value["response_bytes"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_log_defaults_off_and_redacts_common_secret_shapes() {
|
||||
let settings = ErrorLogSettings::default();
|
||||
assert!(!settings.enabled);
|
||||
assert_eq!(settings.format, ErrorLogFormat::Json);
|
||||
let text = redact_text(
|
||||
r#"Authorization: Bearer topsecret url=https://x.invalid/?api_token=abc password=hunter2 {\"private_key\":\"pem-data\"}"#,
|
||||
);
|
||||
assert!(!text.contains("topsecret"));
|
||||
assert!(!text.contains("api_token=abc"));
|
||||
assert!(!text.contains("hunter2"));
|
||||
assert!(!text.contains("pem-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_json_is_one_record_and_reserved_fields_cannot_be_overwritten() {
|
||||
let event = ErrorEvent {
|
||||
timestamp: "2026-10-03T12:34:56.123Z".into(),
|
||||
target: "rust_proxy::http".into(),
|
||||
message: "upstream failed\nsecond line".into(),
|
||||
fields: BTreeMap::from([
|
||||
("message".into(), "attacker override".into()),
|
||||
("request_id".into(), "abc".into()),
|
||||
]),
|
||||
};
|
||||
let line = format_error_event(&event, ErrorLogFormat::Json);
|
||||
assert_eq!(line.iter().filter(|byte| **byte == b'\n').count(), 1);
|
||||
let value: Value = serde_json::from_slice(&line).unwrap();
|
||||
assert_eq!(value["message"], "upstream failed\nsecond line");
|
||||
assert_eq!(value["request_id"], "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_history_is_larger_than_the_live_snapshot_but_still_bounded() {
|
||||
let logs = Logs::default();
|
||||
|
||||
+97
-8
@@ -35,12 +35,13 @@ use tokio::{
|
||||
task::JoinSet,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy 0.24.8"
|
||||
about = "RustProxy - independent Rust reverse proxy 0.24.9"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
@@ -94,6 +95,11 @@ enum Command {
|
||||
#[command(subcommand)]
|
||||
action: ConfigCommand,
|
||||
},
|
||||
/// Create or restore a password-encrypted full application backup.
|
||||
Backup {
|
||||
#[command(subcommand)]
|
||||
action: BackupCommand,
|
||||
},
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum AdminCommand {
|
||||
@@ -127,6 +133,20 @@ enum ConfigCommand {
|
||||
/// Validate JSON, ACLs, headers and backend policy without changing the database.
|
||||
Validate { file: PathBuf },
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum BackupCommand {
|
||||
/// Create an encrypted full backup. Stop the RustProxy service first.
|
||||
Create {
|
||||
/// Destination .rpb file. Existing files are never overwritten.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Restore an encrypted full backup atomically. Stop the RustProxy service first.
|
||||
Restore {
|
||||
archive: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TlsCommand {
|
||||
/// Generate a self-signed development certificate. Existing files are never overwritten.
|
||||
@@ -243,13 +263,18 @@ fn resolve_installed_data_dir(configured: PathBuf, explicitly_configured: bool)
|
||||
}
|
||||
|
||||
async fn application() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "rust_proxy=info,warn".into()),
|
||||
let error_log = rust_proxy::logging::ErrorLogController::start();
|
||||
rust_proxy::logging::install_error_log_controller(error_log.clone())?;
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "rust_proxy=info,warn".into());
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_target(false)
|
||||
.with_writer(rust_proxy::logging::RedactingStderr),
|
||||
)
|
||||
.with_target(false)
|
||||
.with_writer(std::io::stderr)
|
||||
.with(rust_proxy::logging::ErrorLogLayer::new(error_log))
|
||||
.init();
|
||||
let matches = Cli::command().get_matches();
|
||||
let explicit = |matches: &clap::ArgMatches, id: &str| {
|
||||
@@ -309,6 +334,13 @@ async fn application() -> Result<()> {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Backup { action } => {
|
||||
backup_command(
|
||||
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
|
||||
action,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Serve(options) => {
|
||||
let update_check = rust_proxy::update_check::Config::embedded()?.with_overrides(
|
||||
cli.update_repository_url,
|
||||
@@ -328,6 +360,57 @@ async fn application() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()> {
|
||||
match action {
|
||||
BackupCommand::Create { output } => {
|
||||
let output = output.unwrap_or_else(|| {
|
||||
PathBuf::from(format!("rustproxy-backup-{}.rpb", rust_proxy::auth::now()))
|
||||
});
|
||||
let mut generated = false;
|
||||
let password = if io::stdin().is_terminal() {
|
||||
let value = Zeroizing::new(
|
||||
rpassword::prompt_password("Backup password (leave empty to generate): ")?
|
||||
);
|
||||
if value.is_empty() {
|
||||
generated = true;
|
||||
Zeroizing::new(rust_proxy::backup::generated_password())
|
||||
} else {
|
||||
let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
|
||||
ensure!(value.as_str() == confirmation.as_str(), "backup passwords do not match");
|
||||
value
|
||||
}
|
||||
} else {
|
||||
generated = true;
|
||||
Zeroizing::new(rust_proxy::backup::generated_password())
|
||||
};
|
||||
let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?;
|
||||
println!(
|
||||
"Encrypted backup created: {} ({} files, configuration revision {})",
|
||||
summary.path.display(), summary.files, summary.configuration_revision
|
||||
);
|
||||
if generated {
|
||||
println!("Generated backup password (shown once): {}", password.as_str());
|
||||
println!("Store this password securely; restore is impossible without it.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
BackupCommand::Restore { archive } => {
|
||||
ensure!(
|
||||
io::stdin().is_terminal(),
|
||||
"backup restore requires an interactive TTY for the password"
|
||||
);
|
||||
let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?);
|
||||
ensure!(!password.is_empty(), "backup password cannot be empty");
|
||||
let summary = rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
|
||||
println!(
|
||||
"Backup restored to {} ({} files, configuration revision {})",
|
||||
data_dir.display(), summary.files, summary.configuration_revision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_admin_binding(admin_addr: SocketAddr, tls: bool, allow_remote: bool) -> Result<bool> {
|
||||
let remote_admin = !admin_addr.ip().is_loopback();
|
||||
ensure!(
|
||||
@@ -445,6 +528,11 @@ async fn run(
|
||||
let directory = data_dir.join("certs");
|
||||
let initialize = directory.clone();
|
||||
tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??;
|
||||
rust_proxy::secret_store::initialize(&data_dir)?;
|
||||
let migrated = rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
|
||||
if migrated > 0 {
|
||||
tracing::info!(migrated, "legacy ACME credentials migrated to encrypted Secret Store");
|
||||
}
|
||||
let cert_dir = Some(directory.clone());
|
||||
let policy = ValidationPolicy {
|
||||
proxy_addr: options.proxy_listen,
|
||||
@@ -459,6 +547,7 @@ async fn run(
|
||||
})
|
||||
.await?
|
||||
.context("stored configuration is invalid; no automatic reset was performed")?;
|
||||
rust_proxy::logging::configure_error_log(snapshot.envelope.config.error_log.clone());
|
||||
let admin_tls_config = {
|
||||
let tls = snapshot.envelope.config.tls.clone();
|
||||
let administration = snapshot.envelope.config.administration.clone();
|
||||
@@ -1426,7 +1515,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.24.8 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.24.9 - 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();
|
||||
|
||||
@@ -439,6 +439,7 @@ impl App {
|
||||
this.cache.configure(envelope.config.cache.clone());
|
||||
this.rate_limiter.reset();
|
||||
this.logs.configure(envelope.config.logging.clone());
|
||||
crate::logging::configure_error_log(envelope.config.error_log.clone());
|
||||
this.config.store(Arc::new(prepared));
|
||||
tracing::info!(revision, "configuration applied");
|
||||
Ok(envelope)
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Central encrypted secret storage for provider/ACME credentials.
|
||||
//! Plaintext exists only for the duration of an explicit read/write operation.
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
|
||||
use serde::Serialize;
|
||||
use sqlx::Row;
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
const KEY_BYTES: usize = 32;
|
||||
const NONCE_BYTES: usize = 12;
|
||||
const ENCRYPTION_VERSION: i64 = 1;
|
||||
const MAX_SECRET_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SecretMetadata {
|
||||
pub secret_id: String,
|
||||
pub provider: String,
|
||||
pub encryption_version: i64,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
fn secrets_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("secrets")
|
||||
}
|
||||
pub fn master_key_path(data_dir: &Path) -> PathBuf {
|
||||
secrets_dir(data_dir).join("master.key")
|
||||
}
|
||||
|
||||
fn private_directory(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(path)?;
|
||||
}
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
ensure!(
|
||||
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
||||
"secret store directory must be a real directory"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if metadata.permissions().mode() & 0o077 != 0 {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_key(path: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
|
||||
let metadata = fs::symlink_metadata(path).context("secret store master key is missing")?;
|
||||
ensure!(
|
||||
metadata.is_file() && !metadata.file_type().is_symlink(),
|
||||
"secret store master key must be a regular file"
|
||||
);
|
||||
ensure!(metadata.len() == KEY_BYTES as u64, "invalid secret store master key size");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
ensure!(
|
||||
metadata.permissions().mode() & 0o077 == 0,
|
||||
"secret store master key must have mode 0600"
|
||||
);
|
||||
}
|
||||
let mut bytes = Zeroizing::new([0u8; KEY_BYTES]);
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let mut file = options.open(path)?;
|
||||
file.read_exact(bytes.as_mut())?;
|
||||
let mut extra = [0u8; 1];
|
||||
ensure!(file.read(&mut extra)? == 0, "invalid secret store master key size");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn initialize(data_dir: &Path) -> Result<()> {
|
||||
let directory = secrets_dir(data_dir);
|
||||
private_directory(&directory)?;
|
||||
let path = master_key_path(data_dir);
|
||||
if path.exists() {
|
||||
let _ = read_key(&path)?;
|
||||
return Ok(());
|
||||
}
|
||||
let mut key = Zeroizing::new([0u8; KEY_BYTES]);
|
||||
OsRng.fill_bytes(key.as_mut());
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(key.as_ref())?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let _ = read_key(&path)?;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_identity(secret_id: &str, provider: &str) -> Result<()> {
|
||||
ensure!(
|
||||
!secret_id.is_empty()
|
||||
&& secret_id.len() <= 256
|
||||
&& secret_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'_' | b'-' | b'.')),
|
||||
"invalid secret id"
|
||||
);
|
||||
ensure!(
|
||||
!provider.is_empty()
|
||||
&& provider.len() <= 64
|
||||
&& provider
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')),
|
||||
"invalid secret provider"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn aad(secret_id: &str, provider: &str) -> Vec<u8> {
|
||||
format!("rustproxy-secret-v{ENCRYPTION_VERSION}\0{secret_id}\0{provider}").into_bytes()
|
||||
}
|
||||
|
||||
fn key(data_dir: &Path) -> Result<LessSafeKey> {
|
||||
let bytes = read_key(&master_key_path(data_dir))?;
|
||||
let unbound = UnboundKey::new(&AES_256_GCM, bytes.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("cannot initialize secret store cipher"))?;
|
||||
Ok(LessSafeKey::new(unbound))
|
||||
}
|
||||
|
||||
pub async fn put(
|
||||
store: &crate::db::Store,
|
||||
data_dir: &Path,
|
||||
secret_id: &str,
|
||||
provider: &str,
|
||||
plaintext: &[u8],
|
||||
metadata: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
validate_identity(secret_id, provider)?;
|
||||
ensure!(!plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES, "invalid secret size");
|
||||
let metadata = serde_json::to_string(&metadata)?;
|
||||
ensure!(metadata.len() <= 4096, "secret metadata is too large");
|
||||
initialize(data_dir)?;
|
||||
let cipher = key(data_dir)?;
|
||||
let mut nonce = [0u8; NONCE_BYTES];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let mut ciphertext = Zeroizing::new(plaintext.to_vec());
|
||||
cipher
|
||||
.seal_in_place_append_tag(
|
||||
Nonce::assume_unique_for_key(nonce),
|
||||
Aad::from(aad(secret_id, provider)),
|
||||
ciphertext.as_mut(),
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("secret encryption failed"))?;
|
||||
let now = crate::auth::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO secret_store(secret_id, provider, encryption_version, nonce, ciphertext, metadata, created_at, updated_at) \
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(secret_id) DO UPDATE SET provider=excluded.provider, encryption_version=excluded.encryption_version, \
|
||||
nonce=excluded.nonce, ciphertext=excluded.ciphertext, metadata=excluded.metadata, updated_at=excluded.updated_at",
|
||||
)
|
||||
.bind(secret_id)
|
||||
.bind(provider)
|
||||
.bind(ENCRYPTION_VERSION)
|
||||
.bind(nonce.to_vec())
|
||||
.bind(ciphertext.as_slice())
|
||||
.bind(metadata)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&store.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
store: &crate::db::Store,
|
||||
data_dir: &Path,
|
||||
secret_id: &str,
|
||||
expected_provider: Option<&str>,
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT provider, encryption_version, nonce, ciphertext FROM secret_store WHERE secret_id = ?",
|
||||
)
|
||||
.bind(secret_id)
|
||||
.fetch_optional(&store.pool)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("secret not found"))?;
|
||||
let provider: String = row.try_get("provider")?;
|
||||
if let Some(expected) = expected_provider {
|
||||
ensure!(provider == expected, "secret provider mismatch");
|
||||
}
|
||||
validate_identity(secret_id, &provider)?;
|
||||
let version: i64 = row.try_get("encryption_version")?;
|
||||
ensure!(version == ENCRYPTION_VERSION, "unsupported secret encryption version");
|
||||
let nonce: Vec<u8> = row.try_get("nonce")?;
|
||||
ensure!(nonce.len() == NONCE_BYTES, "invalid secret nonce");
|
||||
let mut ciphertext = Zeroizing::new(row.try_get::<Vec<u8>, _>("ciphertext")?);
|
||||
ensure!(
|
||||
ciphertext.len() >= AES_256_GCM.tag_len() && ciphertext.len() <= MAX_SECRET_BYTES + AES_256_GCM.tag_len(),
|
||||
"invalid encrypted secret size"
|
||||
);
|
||||
let mut nonce_bytes = [0u8; NONCE_BYTES];
|
||||
nonce_bytes.copy_from_slice(&nonce);
|
||||
let cipher = key(data_dir)?;
|
||||
let plaintext = cipher
|
||||
.open_in_place(
|
||||
Nonce::assume_unique_for_key(nonce_bytes),
|
||||
Aad::from(aad(secret_id, &provider)),
|
||||
ciphertext.as_mut(),
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("secret authentication failed"))?;
|
||||
let output = Zeroizing::new(plaintext.to_vec());
|
||||
ciphertext.zeroize();
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub async fn delete(store: &crate::db::Store, secret_id: &str) -> Result<bool> {
|
||||
let result = sqlx::query("DELETE FROM secret_store WHERE secret_id = ?")
|
||||
.bind(secret_id)
|
||||
.execute(&store.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn metadata(store: &crate::db::Store) -> Result<Vec<SecretMetadata>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT secret_id, provider, encryption_version, metadata, created_at, updated_at FROM secret_store ORDER BY secret_id",
|
||||
)
|
||||
.fetch_all(&store.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let raw: String = row.try_get("metadata")?;
|
||||
Ok(SecretMetadata {
|
||||
secret_id: row.try_get("secret_id")?,
|
||||
provider: row.try_get("provider")?,
|
||||
encryption_version: row.try_get("encryption_version")?,
|
||||
metadata: serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null),
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn validate_all(store: &crate::db::Store, data_dir: &Path) -> Result<()> {
|
||||
for item in metadata(store).await? {
|
||||
let value = get(store, data_dir, &item.secret_id, Some(&item.provider)).await?;
|
||||
ensure!(!value.is_empty(), "empty secret store record");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn acme_secret_id(certificate_id: &str, provider: &str) -> String {
|
||||
format!("acme/{certificate_id}/{provider}/credential")
|
||||
}
|
||||
|
||||
pub async fn migrate_acme_credentials(
|
||||
store: &crate::db::Store,
|
||||
data_dir: &Path,
|
||||
cert_dir: &Path,
|
||||
) -> Result<usize> {
|
||||
initialize(data_dir)?;
|
||||
let root = cert_dir.join("acme");
|
||||
if !root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let root_meta = fs::symlink_metadata(&root)?;
|
||||
ensure!(root_meta.is_dir() && !root_meta.file_type().is_symlink(), "invalid ACME directory");
|
||||
let mut migrated = 0usize;
|
||||
for entry in fs::read_dir(&root)? {
|
||||
let entry = entry?;
|
||||
let profile_dir = entry.path();
|
||||
let meta = fs::symlink_metadata(&profile_dir)?;
|
||||
if !meta.is_dir() || meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let profile_path = profile_dir.join("profile.json");
|
||||
if !profile_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let profile: serde_json::Value = serde_json::from_slice(&fs::read(&profile_path)?)?;
|
||||
let provider = profile.get("provider").and_then(serde_json::Value::as_str).unwrap_or("");
|
||||
let certificate_id = profile
|
||||
.get("certificate_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("");
|
||||
if provider == "http01" {
|
||||
continue;
|
||||
}
|
||||
validate_identity(&acme_secret_id(certificate_id, provider), provider)?;
|
||||
let credential_path = profile_dir.join(format!("{provider}.ini"));
|
||||
if !credential_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let credential_meta = fs::symlink_metadata(&credential_path)?;
|
||||
ensure!(
|
||||
credential_meta.is_file()
|
||||
&& !credential_meta.file_type().is_symlink()
|
||||
&& credential_meta.len() <= MAX_SECRET_BYTES as u64,
|
||||
"unsafe legacy ACME credential file"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
ensure!(
|
||||
credential_meta.permissions().mode() & 0o077 == 0,
|
||||
"legacy ACME credential file must be private"
|
||||
);
|
||||
}
|
||||
let mut plaintext = Zeroizing::new(fs::read(&credential_path)?);
|
||||
ensure!(!plaintext.is_empty(), "empty legacy ACME credential file");
|
||||
put(
|
||||
store,
|
||||
data_dir,
|
||||
&acme_secret_id(certificate_id, provider),
|
||||
provider,
|
||||
plaintext.as_ref(),
|
||||
serde_json::json!({"source":"acme_credential_migration","certificate_id":certificate_id}),
|
||||
)
|
||||
.await?;
|
||||
plaintext.zeroize();
|
||||
fs::remove_file(&credential_path).context("legacy ACME credential was encrypted but could not be removed")?;
|
||||
migrated += 1;
|
||||
}
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
pub fn read_master_key_for_backup(data_dir: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
|
||||
initialize(data_dir)?;
|
||||
read_key(&master_key_path(data_dir))
|
||||
}
|
||||
|
||||
pub fn write_restored_master_key(data_dir: &Path, key: &[u8]) -> Result<()> {
|
||||
ensure!(key.len() == KEY_BYTES, "invalid restored secret store key");
|
||||
let directory = secrets_dir(data_dir);
|
||||
private_directory(&directory)?;
|
||||
let path = master_key_path(data_dir);
|
||||
ensure!(!path.exists(), "restored secret store key already exists");
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let mut file = options.open(&path)?;
|
||||
file.write_all(key)?;
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn secret_round_trip_is_encrypted_in_sqlite_and_survives_key_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
crate::auth::prepare_directory(dir.path()).unwrap();
|
||||
let database = dir.path().join("config.sqlite3");
|
||||
crate::auth::prepare_database(&database).unwrap();
|
||||
let store = crate::db::Store::open(&database, &crate::config::Config::default())
|
||||
.await
|
||||
.unwrap();
|
||||
initialize(dir.path()).unwrap();
|
||||
let secret = b"cloudflare-example-token-0123456789";
|
||||
put(
|
||||
&store,
|
||||
dir.path(),
|
||||
"provider/cloudflare/demo/api_token",
|
||||
"cloudflare",
|
||||
secret,
|
||||
serde_json::json!({"id":"demo"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let row: Vec<u8> = sqlx::query_scalar(
|
||||
"SELECT ciphertext FROM secret_store WHERE secret_id='provider/cloudflare/demo/api_token'",
|
||||
)
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!row.windows(secret.len()).any(|window| window == secret));
|
||||
let value = get(
|
||||
&store,
|
||||
dir.path(),
|
||||
"provider/cloudflare/demo/api_token",
|
||||
Some("cloudflare"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value.as_slice(), secret);
|
||||
drop(value);
|
||||
assert_eq!(read_key(&master_key_path(dir.path())).unwrap().len(), KEY_BYTES);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_ciphertext_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
crate::auth::prepare_directory(dir.path()).unwrap();
|
||||
let database = dir.path().join("config.sqlite3");
|
||||
crate::auth::prepare_database(&database).unwrap();
|
||||
let store = crate::db::Store::open(&database, &crate::config::Config::default())
|
||||
.await
|
||||
.unwrap();
|
||||
put(&store, dir.path(), "acme/site/cloudflare/credential", "cloudflare", b"secret-value", serde_json::json!({})).await.unwrap();
|
||||
sqlx::query("UPDATE secret_store SET ciphertext = zeroblob(length(ciphertext))")
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(get(&store, dir.path(), "acme/site/cloudflare/credential", Some("cloudflare")).await.is_err());
|
||||
}
|
||||
}
|
||||
+149
-28
@@ -17,6 +17,9 @@ use hyper_util::{
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
env,
|
||||
io::{BufReader, Cursor},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -26,6 +29,19 @@ use url::Url;
|
||||
const EMBEDDED_DEFAULTS: &str = include_str!("../UPDATE_CHECK.toml");
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_VERSION_DOCUMENT_BYTES: usize = 256 * 1024;
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
const SYSTEM_CA_BUNDLES: &[&str] = &[
|
||||
// FreeBSD / OPNsense and common ca_root_nss locations.
|
||||
"/etc/ssl/cert.pem",
|
||||
"/usr/local/share/certs/ca-root-nss.crt",
|
||||
"/usr/local/etc/ssl/cert.pem",
|
||||
// Debian/Ubuntu, RHEL/Fedora and Alpine families.
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
|
||||
"/etc/openssl/cert.pem",
|
||||
"/etc/openssl/certs/ca-certificates.crt",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
@@ -188,7 +204,11 @@ pub async fn poll_loop(status: Arc<SharedStatus>, config: Config, shutdown: Canc
|
||||
loop {
|
||||
match check_once(&config).await {
|
||||
Ok(version) => status.record_success(version),
|
||||
Err(error) => tracing::debug!(%error, "update check failed; retrying on the next interval"),
|
||||
Err(error) => tracing::error!(
|
||||
error = %format!("{error:#}"),
|
||||
version_url = %config.version_url,
|
||||
"update check failed; retrying on the next interval"
|
||||
),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => return,
|
||||
@@ -205,8 +225,7 @@ async fn check_once(config: &Config) -> Result<String> {
|
||||
}
|
||||
|
||||
async fn fetch(url: &str) -> Result<String> {
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let roots = load_root_store();
|
||||
let tls = ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
@@ -223,32 +242,117 @@ async fn fetch(url: &str) -> Result<String> {
|
||||
.enable_http1()
|
||||
.wrap_connector(http);
|
||||
let client: Client<_, Body> = Client::builder(TokioExecutor::new()).build(connector);
|
||||
let uri: Uri = url.parse().context("invalid update version URI")?;
|
||||
let request = Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(uri)
|
||||
.header(header::ACCEPT, "text/plain, text/x-toml;q=0.9")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
concat!("RustProxy/", env!("CARGO_PKG_VERSION"), " update-check"),
|
||||
)
|
||||
.body(Body::empty())?;
|
||||
let response = client.request(request).await.context("update version request failed")?;
|
||||
ensure!(
|
||||
response.status().is_success(),
|
||||
"update version request returned HTTP {}",
|
||||
response.status()
|
||||
);
|
||||
let body = Limited::new(response.into_body(), MAX_VERSION_DOCUMENT_BYTES)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"update version document is too large or incomplete: {error}"
|
||||
let mut current = Url::parse(url).context("invalid update version URL")?;
|
||||
|
||||
for redirects in 0..=MAX_REDIRECTS {
|
||||
let uri: Uri = current
|
||||
.as_str()
|
||||
.parse()
|
||||
.context("invalid update version URI")?;
|
||||
let request = Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(uri)
|
||||
.header(header::ACCEPT, "text/plain, text/x-toml;q=0.9")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
concat!("RustProxy/", env!("CARGO_PKG_VERSION"), " update-check"),
|
||||
)
|
||||
})?
|
||||
.to_bytes();
|
||||
String::from_utf8(body.to_vec()).context("update version document is not UTF-8")
|
||||
.body(Body::empty())?;
|
||||
let response = client
|
||||
.request(request)
|
||||
.await
|
||||
.with_context(|| format!("update version request failed for {}", current))?;
|
||||
|
||||
if response.status().is_redirection() {
|
||||
ensure!(
|
||||
redirects < MAX_REDIRECTS,
|
||||
"update version request exceeded redirect limit"
|
||||
);
|
||||
let location = response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.context("update version redirect is missing Location")?
|
||||
.to_str()
|
||||
.context("update version redirect Location is not valid UTF-8")?;
|
||||
current = redirect_target(¤t, location)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
ensure!(
|
||||
response.status().is_success(),
|
||||
"update version request returned HTTP {}",
|
||||
response.status()
|
||||
);
|
||||
let body = Limited::new(response.into_body(), MAX_VERSION_DOCUMENT_BYTES)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"update version document is too large or incomplete: {error}"
|
||||
)
|
||||
})?
|
||||
.to_bytes();
|
||||
return String::from_utf8(body.to_vec())
|
||||
.context("update version document is not UTF-8");
|
||||
}
|
||||
|
||||
unreachable!("redirect loop always returns or fails at the configured limit")
|
||||
}
|
||||
|
||||
fn load_root_store() -> RootCertStore {
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let mut bundles = Vec::<PathBuf>::new();
|
||||
for variable in ["SSL_CERT_FILE", "CURL_CA_BUNDLE"] {
|
||||
if let Some(path) = env::var_os(variable).filter(|value| !value.is_empty()) {
|
||||
bundles.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
bundles.extend(SYSTEM_CA_BUNDLES.iter().map(PathBuf::from));
|
||||
bundles.sort();
|
||||
bundles.dedup();
|
||||
|
||||
for path in bundles {
|
||||
add_system_ca_bundle(&mut roots, &path);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn add_system_ca_bundle(roots: &mut RootCertStore, path: &Path) {
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return;
|
||||
};
|
||||
let mut reader = BufReader::new(Cursor::new(bytes));
|
||||
let certificates =
|
||||
rustls_pemfile::certs(&mut reader).filter_map(|certificate| certificate.ok());
|
||||
let (added, _ignored) = roots.add_parsable_certificates(certificates);
|
||||
if added > 0 {
|
||||
tracing::debug!(path=%path.display(), added, "loaded system CA certificates for update checker");
|
||||
}
|
||||
}
|
||||
|
||||
fn redirect_target(base: &Url, location: &str) -> Result<Url> {
|
||||
let target = base
|
||||
.join(location)
|
||||
.context("invalid update version redirect URL")?;
|
||||
ensure!(
|
||||
matches!(target.scheme(), "http" | "https"),
|
||||
"update version redirect must use http:// or https://"
|
||||
);
|
||||
ensure!(
|
||||
target.host_str().is_some(),
|
||||
"update version redirect must contain a host"
|
||||
);
|
||||
ensure!(
|
||||
base.scheme() != "https" || target.scheme() == "https",
|
||||
"update version redirect must not downgrade HTTPS to HTTP"
|
||||
);
|
||||
ensure!(
|
||||
target.username().is_empty() && target.password().is_none(),
|
||||
"update version redirect must not contain credentials"
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn package_version(document: &str) -> Result<String> {
|
||||
@@ -345,6 +449,23 @@ foo = { version = "100.0.0" }
|
||||
assert_eq!(package_version(document).unwrap(), "0.19.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_redirects_accept_relative_https_and_reject_unsafe_targets() {
|
||||
let base =
|
||||
Url::parse("https://updates.example.invalid/releases/current/Cargo.toml").unwrap();
|
||||
assert_eq!(
|
||||
redirect_target(&base, "../next/Cargo.toml").unwrap().as_str(),
|
||||
"https://updates.example.invalid/releases/next/Cargo.toml"
|
||||
);
|
||||
assert!(redirect_target(&base, "ftp://updates.example.invalid/Cargo.toml").is_err());
|
||||
assert!(redirect_target(&base, "http://updates.example.invalid/Cargo.toml").is_err());
|
||||
assert!(redirect_target(
|
||||
&base,
|
||||
"https://user:secret@updates.example.invalid/Cargo.toml"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_release_versions() {
|
||||
assert!(is_newer_version("0.19.5", "0.19.4"));
|
||||
|
||||
Reference in new issue
Block a user