This commit is contained in:
Mateusz Gruszczyński committed 2026-10-03 18:10:20 +02:00
1 parent 1297ea2920
commit dc0fcabf51
49 files changed
+2632 -252

No files matched your search

+133 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
+16 -2
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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());
}
}
+3
View File
@@ -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
+36 -2
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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)
+435
View File
@@ -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
View File
@@ -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(&current, 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"));