This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 10:36:34 +02:00
1 parent 3b3f0db377
commit d940f9995f
47 files changed
+542 -187

No files matched your search

+10
View File
@@ -40,6 +40,16 @@ async fn certificate_deploy(
),
));
}
if crate::tls::certificate_files_are_external(&selected.cert_file, &selected.key_file)
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?
{
return Err(ApiError(
StatusCode::CONFLICT,
format!(
"certificate {certificate_id} uses external file paths; renew those files in place and call the certificate reload endpoint instead of deploy"
),
));
}
Some((selected.cert_file.clone(), selected.key_file.clone()))
} else {
None
+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.3_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.2_IMPLEMENTATION.md", "technical"),
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.28.0",
"version": "0.28.1",
"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": [
+24 -3
View File
@@ -20,6 +20,25 @@
}
})();
function certificateUsesExternalFiles(cert) {
return String(cert?.cert_file || "").startsWith("/") && String(cert?.key_file || "").startsWith("/");
}
function syncCertificateFileSource() {
const external = $("certificate-file-source").value === "external";
const cert = $("certificate-file"), key = $("certificate-key");
for (const input of [cert, key]) {
input.maxLength = external ? 4096 : 96;
if (external) input.removeAttribute("pattern");
else input.pattern = "[A-Za-z0-9_.-]+";
}
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
const helpKey = external ? "certificateSourceExternalHelp" : "certificateSourceManagedHelp";
$("certificate-file-source-help").dataset.i18n = helpKey;
$("certificate-file-source-help").textContent = t(helpKey);
}
$("certificate-file-source").addEventListener("change", syncCertificateFileSource);
function renderCertificates() {
if (!envelope) return;
const settings = envelope.config.tls || {};
@@ -41,6 +60,7 @@ function renderCertificates() {
identity.append(el("span", cert.id, "entity-title"));
const statePills = el("div", undefined, "vhost-policy-pills certificate-state-pills");
if (managed) statePills.append(el("small", `${t("certificateManaged")} · ${cert.issuer.provider} / ${cert.issuer.environment}`, "pill"));
else if (certificateUsesExternalFiles(cert)) statePills.append(el("small", t("certificateExternalFiles"), "pill"));
if (settings.default_certificate === cert.id) statePills.append(el("small", t("defaultLabel"), "pill"));
if (inventory?.expiry_status === "expired") statePills.append(el("small", t("certificateExpired"), "pill status-error"));
else if (inventory?.expiry_status === "warning") statePills.append(el("small", t("certificateExpiresSoon"), "pill status-error"));
@@ -91,7 +111,7 @@ function renderCertificates() {
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
} catch (error) { toast(error.message, true); } finally { busy = false; }
}));
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file-source").value = certificateUsesExternalFiles(cert) ? "external" : "managed"; syncCertificateFileSource(); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
}
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
@@ -101,7 +121,7 @@ function renderCertificates() {
$("certificate-list").replaceChildren(...entries);
if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty"));
}
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); syncCertificateFileSource(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
$("reload-certificates").addEventListener("click", async () => {
if (!envelope || busy) return;
try {
@@ -114,7 +134,8 @@ $("reload-certificates").addEventListener("click", async () => {
} catch (error) { toast(error.message, true); }
finally { busy = false; }
});
$("reset-certificate").addEventListener("click", () => $("certificate-form").reset());
$("reset-certificate").addEventListener("click", () => { $("certificate-form").reset(); $("certificate-file-source").value = "managed"; syncCertificateFileSource(); });
syncCertificateFileSource();
// Dashboard metrics are actual server samples. Null means unavailable, not zero.
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null,
+8 -2
View File
@@ -1180,6 +1180,12 @@
placeholder="app" required="" />
<label data-i18n="certLegacyNames" for="certificate-hosts"></label><input
id="certificate-hosts" placeholder="app.localhost" />
<label data-i18n="certificateFileSource" for="certificate-file-source"></label><select
id="certificate-file-source">
<option data-i18n="certificateSourceManaged" value="managed"></option>
<option data-i18n="certificateSourceExternal" value="external"></option>
</select>
<p class="fine" id="certificate-file-source-help" data-i18n="certificateSourceManagedHelp"></p>
<label data-i18n="certificateFile" for="certificate-file"></label><input
id="certificate-file" maxlength="96" pattern="[A-Za-z0-9_.-]+"
placeholder="app.pem" required="" />
@@ -2878,7 +2884,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.28.0</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.28.1</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3622,7 +3628,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.28.0</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.28.1</strong></div>
</div>
<dl class="about-meta">
<div>
+9 -1
View File
@@ -259,8 +259,16 @@ fn validate_snapshot_config(
) -> Result<()> {
let cert_dir = snapshot_root.join("certs");
for certificate in &envelope.config.tls.certificates {
if crate::tls::certificate_files_are_external(
&certificate.cert_file,
&certificate.key_file,
)? {
// External certificate material is intentionally outside the RustProxy
// data tree and is therefore referenced by configuration, not copied
// into a RustProxy backup. The operator must restore/mount it separately.
continue;
}
for name in [&certificate.cert_file, &certificate.key_file] {
crate::tls::safe_name(name)?;
let path = cert_dir.join(name);
let meta = fs::symlink_metadata(&path).with_context(|| {
format!("configured certificate file changed during backup: {name}")
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.0"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.1"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.28.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.28.1 - 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();
+5 -8
View File
@@ -310,16 +310,14 @@ fn certificate_public_metadata(certificate: &[u8]) -> Result<CertificateMetadata
/// Used by the authenticated certificate inventory to enrich older/manual entries
/// that predate persisted `covered_hosts` metadata. Private key material is not read.
pub fn certificate_covered_hosts(directory: &Path, cert_file: &str) -> Result<Vec<String>> {
safe_name(cert_file)?;
let certificate = read_pem(&directory.join(cert_file), false)?;
let certificate = read_configured_pem(directory, cert_file, false)?;
certificate_dns_names(&certificate)
}
/// Read non-secret metadata directly from the configured public leaf certificate.
/// Private keys are never opened by this path.
pub fn certificate_metadata(directory: &Path, cert_file: &str) -> Result<CertificateMetadata> {
safe_name(cert_file)?;
let certificate = read_pem(&directory.join(cert_file), false)?;
let certificate = read_configured_pem(directory, cert_file, false)?;
certificate_public_metadata(&certificate)
}
@@ -340,11 +338,10 @@ fn parse_pair(certificate: &[u8], key: &[u8]) -> Result<CertifiedKey> {
Ok(certified)
}
fn load_pair(directory: &Path, cert: &str, key: &str) -> Result<CertifiedKey> {
safe_name(cert)?;
safe_name(key)?;
certificate_files_are_external(cert, key)?;
parse_pair(
&read_pem(&directory.join(cert), false)?,
&read_pem(&directory.join(key), true)?,
&read_configured_pem(directory, cert, false)?,
&read_configured_pem(directory, key, true)?,
)
}
/// Runs on a bounded control-plane blocking worker, never from a handshake.
+65
View File
@@ -158,6 +158,71 @@ mod tests {
assert!(safe_name(path).is_err(), "{path}");
}
assert!(safe_name("app-v2.key").is_ok());
assert!(certificate_files_are_external("app.pem", "app.key").is_ok());
assert!(certificate_files_are_external("app.pem", "/etc/tls/app.key").is_err());
}
#[cfg(unix)]
#[test]
fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
use std::os::unix::fs::{symlink, PermissionsExt};
let root = tempfile::tempdir().unwrap();
let cert_dir = root.path().join("rustproxy-certs");
fs::create_dir(&cert_dir).unwrap();
initialize_default(&cert_dir).unwrap();
let archive = root.path().join("archive/example.test");
let live = root.path().join("live/example.test");
fs::create_dir_all(&archive).unwrap();
fs::create_dir_all(&live).unwrap();
let first = rcgen::generate_simple_self_signed(vec!["example.test".into()]).unwrap();
fs::write(archive.join("fullchain1.pem"), first.cert.pem()).unwrap();
fs::write(archive.join("privkey1.pem"), first.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o640)).unwrap();
symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap();
let cert_path = live.join("fullchain.pem").display().to_string();
let key_path = live.join("privkey.pem").display().to_string();
assert!(certificate_files_are_external(&cert_path, &key_path).unwrap());
let settings = Settings {
default_certificate: Some("letsencrypt".into()),
certificates: vec![Certificate {
issuer: None,
id: "letsencrypt".into(),
covered_hosts: Vec::new(),
hosts: vec!["example.test".into()],
cert_file: cert_path.clone(),
key_file: key_path.clone(),
}],
..Settings::default()
};
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test"]
);
let second = rcgen::generate_simple_self_signed(vec![
"example.test".into(),
"www.example.test".into(),
])
.unwrap();
fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap();
fs::write(archive.join("privkey2.pem"), second.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey2.pem"), fs::Permissions::from_mode(0o640)).unwrap();
fs::remove_file(live.join("fullchain.pem")).unwrap();
fs::remove_file(live.join("privkey.pem")).unwrap();
symlink("../../archive/example.test/fullchain2.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey2.pem", live.join("privkey.pem")).unwrap();
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test", "www.example.test"]
);
}
#[test]
fn default_is_persistent_and_tls_advertises_both_protocols() {
+77 -6
View File
@@ -16,6 +16,7 @@ use std::{
use zeroize::Zeroizing;
const MAX_PEM: u64 = 131_072;
const MAX_CERTIFICATE_PATH: usize = 4096;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
@@ -324,7 +325,68 @@ pub fn safe_name(name: &str) -> Result<()> {
);
Ok(())
}
/// Validate how a configured certificate pair is addressed. Legacy/simple names
/// remain private to `<data-dir>/certs`; an explicitly selected external pair uses
/// two absolute paths. Mixing the two modes is rejected so the source is never
/// ambiguous.
pub fn certificate_files_are_external(cert_file: &str, key_file: &str) -> Result<bool> {
let cert_external = Path::new(cert_file).is_absolute();
let key_external = Path::new(key_file).is_absolute();
ensure!(
cert_external == key_external,
"certificate and private key must both use filenames from the RustProxy certificate directory or both use absolute external paths"
);
if cert_external {
ensure!(
!cert_file.is_empty()
&& !key_file.is_empty()
&& cert_file.len() <= MAX_CERTIFICATE_PATH
&& key_file.len() <= MAX_CERTIFICATE_PATH,
"external certificate paths must be absolute paths up to 4096 bytes"
);
Ok(true)
} else {
safe_name(cert_file)?;
safe_name(key_file)?;
Ok(false)
}
}
fn configured_certificate_path(directory: &Path, value: &str) -> Result<PathBuf> {
if Path::new(value).is_absolute() {
ensure!(
!value.is_empty() && value.len() <= MAX_CERTIFICATE_PATH,
"external certificate path must be an absolute path up to 4096 bytes"
);
// Certbot's live/ entries are symlinks to archive/. Resolve them on every
// reload, then open the final regular file with O_NOFOLLOW in read_pem.
return fs::canonicalize(value)
.with_context(|| format!("cannot resolve external certificate path {value}"));
}
safe_name(value)?;
Ok(directory.join(value))
}
fn read_configured_pem(
directory: &Path,
value: &str,
private: bool,
) -> Result<Zeroizing<Vec<u8>>> {
let external = Path::new(value).is_absolute();
let path = configured_certificate_path(directory, value)?;
read_pem_with_policy(&path, private, external)
}
fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
read_pem_with_policy(path, private, false)
}
fn read_pem_with_policy(
path: &Path,
private: bool,
external_group_read: bool,
) -> Result<Zeroizing<Vec<u8>>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
@@ -343,14 +405,23 @@ fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
ensure!(
meta.permissions().mode() & 0o077 == 0,
"private key permissions must be 600: {}",
path.display()
);
let mode = meta.permissions().mode();
if external_group_read {
ensure!(
mode & 0o137 == 0,
"external private key may be owner/group-readable but must not be executable, group-writable, or world-accessible: {}",
path.display()
);
} else {
ensure!(
mode & 0o077 == 0,
"private key permissions must not grant group/world access: {}",
path.display()
);
}
}
#[cfg(not(unix))]
let _ = private;
let _ = (private, external_group_read);
let mut data = Zeroizing::new(Vec::new());
file.take(MAX_PEM + 1).read_to_end(&mut data)?;
ensure!(
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.0".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.1".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.1</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.28.0</b>"));
assert!(body.contains("RustProxy <b>0.28.1</b>"));
}
#[tokio::test]