This commit is contained in:
Mateusz Gruszczyński committed 2026-10-10 23:41:54 +02:00
1 parent 24af7478b8
commit 0facfe6f5c
44 files changed
+898 -321

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.31.1",
"version": "0.31.2",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -10893,4 +10893,4 @@
}
}
}
}
}
+1
View File
@@ -1,5 +1,6 @@
use super::*;
use flate2::read::GzDecoder;
use http::header;
use http_body_util::BodyExt;
use std::io::Read;
+2 -2
View File
@@ -284,7 +284,7 @@
let first_body = json!({
"certificate_id": "opnsense-cert",
"cert_pem": first.cert.pem(),
"key_pem": first.key_pair.serialize_pem(),
"key_pem": first.signing_key.serialize_pem(),
})
.to_string();
@@ -326,7 +326,7 @@
let second_body = json!({
"certificate_id": "opnsense-cert",
"cert_pem": second.cert.pem(),
"key_pem": second.key_pair.serialize_pem(),
"key_pem": second.signing_key.serialize_pem(),
})
.to_string();
let response = router.oneshot(deploy(second_body)).await.unwrap();
+9 -7
View File
@@ -46,14 +46,16 @@
interception.append(toggle, codesLabel, hint); parent.append(interception);
const errors = errorsEditor(parent, values.errors);
parent.append(label("p", "pagesSafetyHint", "fine"));
return { read: () => {
const selectedTokens = enabled.checked ? statuses.value.split(/[\s,]+/).filter(Boolean) : [];
const selected = selectedTokens.map(Number);
if (enabled.checked && (!selected.length || selectedTokens.some(token => !/^\d{3}$/.test(token)) || selected.length > codes.length || new Set(selected).size !== selected.length || selected.some(code => !codes.includes(code)))) {
throw new Error(t("vhostInterceptOriginInvalid"));
return {
read: () => {
const selectedTokens = enabled.checked ? statuses.value.split(/[\s,]+/).filter(Boolean) : [];
const selected = selectedTokens.map(Number);
if (enabled.checked && (!selected.length || selectedTokens.some(token => !/^\d{3}$/.test(token)) || selected.length > codes.length || new Set(selected).size !== selected.length || selected.some(code => !codes.includes(code)))) {
throw new Error(t("vhostInterceptOriginInvalid"));
}
return { response: response.read(), fallback: fallback.read(), errors: errors.read(), intercept_statuses: selected };
}
return { response: response.read(), fallback: fallback.read(), errors: errors.read(), intercept_statuses: selected };
} };
};
}
let global = null;
function render() { if (!envelope || settingsDirty.has("pages-form")) return; const values = envelope.config.pages || {}, parent = $("global-pages-fields"); parent.replaceChildren(); const ip = pageEditor(parent, "pageDirectIp", values.direct_ip), unknown = pageEditor(parent, "pageUnknownHost", values.unknown_host), errors = errorsEditor(parent, values.errors); global = { read: () => ({ direct_ip: ip.read(), unknown_host: unknown.read(), errors: errors.read() }) }; }
+2 -2
View File
@@ -214,7 +214,7 @@ curl --fail --show-error --request POST \
$("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current;
$("admin-hosts").value = (admin.allowed_hosts || []).join("\n");
$("admin-session-days").value = Number(admin.session_days || 30);
for (const codec of ["zstd", "br", "gzip"]) $( `admin-compression-${codec}` ).checked = admin.panel_compression?.[codec] !== false;
for (const codec of ["zstd", "br", "gzip"]) $(`admin-compression-${codec}`).checked = admin.panel_compression?.[codec] !== false;
}
const listenLocked = !!adminStatus?.listen_locked;
$("admin-listen").disabled = listenLocked; $("admin-listen-lock-note").hidden = !listenLocked;
@@ -300,7 +300,7 @@ curl --fail --show-error --request POST \
}, "prometheus");
bindSettingsForm("administration-form", next => {
const locked = !!latestStatus?.administration?.listen_locked;
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $( `admin-compression-${codec}` ).checked])) };
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $(`admin-compression-${codec}`).checked])) };
}, "administration");
bindSettingsForm("tls-policy-form", next => {
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
+93 -25
View File
@@ -252,15 +252,21 @@ html.app-startup-failed .app-startup-recovery {
}
@keyframes app-startup-spin {
to { transform: rotate(360deg) }
to {
transform: rotate(360deg)
}
}
@keyframes app-startup-reveal {
to { visibility: visible }
to {
visibility: visible
}
}
@media (prefers-reduced-motion: reduce) {
.app-startup-spinner { animation: none }
.app-startup-spinner {
animation: none
}
}
body {
@@ -5416,7 +5422,9 @@ dialog .window-heading {
}
@media (max-width: 650px) {
.vh-www-pair-directions { grid-template-columns: minmax(0, 1fr) }
.vh-www-pair-directions {
grid-template-columns: minmax(0, 1fr)
}
}
.vh-www-choice {
@@ -7701,9 +7709,17 @@ body[data-density="compact"] .page-footer {
}
/* The legend keys must use the same theme/preset palette as the SVG strokes. */
.stat-chart .stat-chart-legend .legend-h1 i { background: var(--traffic-h1); }
.stat-chart .stat-chart-legend .legend-h2 i { background: var(--traffic-h2); }
.stat-chart .stat-chart-legend .legend-h3 i { background: var(--traffic-h3); }
.stat-chart .stat-chart-legend .legend-h1 i {
background: var(--traffic-h1);
}
.stat-chart .stat-chart-legend .legend-h2 i {
background: var(--traffic-h2);
}
.stat-chart .stat-chart-legend .legend-h3 i {
background: var(--traffic-h3);
}
.stat-chart .stat-chart-legend b {
font-family: var(--mono);
@@ -9946,6 +9962,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
width: 100%
}
}
.listener-active-summary {
margin-top: 10px;
padding: 10px 12px;
@@ -10002,7 +10019,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
margin-top: 10px
}
.location-header-editor .vh-header-rule-head > button {
.location-header-editor .vh-header-rule-head>button {
margin-left: auto;
flex: 0 0 auto
}
@@ -10013,7 +10030,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
}
}
/* 0.29.7: PHP-FPM local/profile toggle rows with accessible info popovers. */
.php-switch-row {
display: flex;
align-items: center;
@@ -10023,6 +10039,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
min-height: 30px;
margin: 4px 0;
}
.php-switch-row .php-switch-label {
display: inline-flex !important;
flex: 0 1 auto;
@@ -10032,14 +10049,26 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
margin: 0 !important;
line-height: 1.5;
}
.php-switch-row .php-switch-label input[type="checkbox"] {
position: static;
flex: 0 0 var(--ui-check-size);
margin: 0;
}
.php-switch-row .php-switch-label span { min-width: 0; }
.php-switch-row .php-field-info { flex: 0 0 auto; }
.php-switch-row .php-field-info .metric-info-popover { right: auto; left: 0; }
.php-switch-row .php-switch-label span {
min-width: 0;
}
.php-switch-row .php-field-info {
flex: 0 0 auto;
}
.php-switch-row .php-field-info .metric-info-popover {
right: auto;
left: 0;
}
.php-reference pre {
overflow-x: auto;
white-space: pre;
@@ -10049,6 +10078,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
background: var(--panel2);
font: 12px/1.6 var(--mono);
}
.php-profile-help-divider {
width: 100%;
height: 0;
@@ -10056,12 +10086,27 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
border: 0;
border-top: 1px solid var(--line);
}
.php-origin-errors { grid-column: 1 / -1; display: grid; gap: 8px; padding: 10px 0; border-top: 1px solid var(--line); }
.php-origin-errors > label { max-width: 430px; }
.php-origin-errors > .check { margin: 0 !important; }
.php-origin-errors .fine { margin: 0; }
/* Keep the Overview refresh action on the same row as the page title. */
.php-origin-errors {
grid-column: 1 / -1;
display: grid;
gap: 8px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.php-origin-errors>label {
max-width: 430px;
}
.php-origin-errors>.check {
margin: 0 !important;
}
.php-origin-errors .fine {
margin: 0;
}
.page-intro-copy,
.page-intro-title-row {
min-width: 0;
@@ -10090,30 +10135,47 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
font-size: clamp(18px, 5vw, 24px);
letter-spacing: -.6px
}
.page-intro-title-row .overview-refresh {
padding: 7px 9px;
font-size: 11px
}
}
/* Keep the system API indicator next to its action, aligned as one unit. */
.system-info-heading-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }
.system-info-heading-actions .pill { white-space: nowrap; }
.system-info-heading-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-shrink: 0;
}
.system-info-heading-actions .pill {
white-space: nowrap;
}
/* Contextual, keyboard-accessible curl examples next to the cache allowlist. */
.cache-maintenance-help {
display: flex;
align-items: center;
gap: 8px;
position: relative;
}
.cache-maintenance-help .subheading { margin: 0; }
.cache-maintenance-info { position: static; flex-shrink: 0; }
.cache-maintenance-help .subheading {
margin: 0;
}
.cache-maintenance-info {
position: static;
flex-shrink: 0;
}
.cache-maintenance-info .metric-info-popover {
max-height: min(60vh, 420px);
overflow-y: auto;
width: min(440px, calc(100vw - 56px));
}
.cache-maintenance-info .metric-info-popover code {
display: block;
white-space: pre-wrap;
@@ -10122,5 +10184,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
font-size: 11px;
line-height: 1.55;
}
.cache-maintenance-info .metric-info-popover .fine + code { margin-top: 6px; }
.cache-maintenance-info .metric-info-popover code + .fine { margin-top: 8px; }
.cache-maintenance-info .metric-info-popover .fine+code {
margin-top: 6px;
}
.cache-maintenance-info .metric-info-popover code+.fine {
margin-top: 8px;
}
+4 -2
View File
@@ -1,7 +1,7 @@
//! Local filesystem authority. The process owner can initialize or recover the sole admin.
use anyhow::{ensure, Context, Result};
use fs2::FileExt;
use rand::{rngs::OsRng, RngCore};
use rand::{rngs::SysRng, TryRng};
use std::{
fs::{self, File, OpenOptions},
io::Write,
@@ -86,7 +86,9 @@ pub fn load_or_create_setup_token(dir: &Path) -> Result<String> {
return load_setup_token(dir);
}
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
SysRng
.try_fill_bytes(&mut bytes)
.expect("secure operating system random source unavailable");
let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let mut file = match private_options().create_new(true).open(&path) {
Ok(file) => file,
+9 -9
View File
@@ -5,7 +5,7 @@ mod store;
use crate::{db::Store, runtime::ApiError};
use anyhow::{ensure, Context, Result};
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
password_hash::{phc::PasswordHash, PasswordHasher, PasswordVerifier},
Algorithm, Argon2, Params, Version,
};
pub use files::{
@@ -13,7 +13,7 @@ pub use files::{
prepare_directory, try_lock_directory,
};
use http::{header::COOKIE, HeaderMap, StatusCode};
use rand::{rngs::OsRng, RngCore};
use rand::{rngs::SysRng, TryRng};
use sha2::{Digest, Sha256};
use std::{
path::PathBuf,
@@ -56,7 +56,9 @@ pub fn now() -> i64 {
}
pub fn random_secret() -> String {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
SysRng
.try_fill_bytes(&mut bytes)
.expect("secure operating system random source unavailable");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn secret_hash(secret: &str) -> String {
@@ -118,17 +120,15 @@ pub fn validate_password(value: &str) -> Result<()> {
}
pub(crate) fn hash_sync(password: &str) -> Result<String> {
let params = Params::new(19_456, 2, 1, None).context("Argon2 parameters")?;
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
.hash_password(password.as_bytes(), &salt)?
.hash_password(password.as_bytes())?
.to_string())
}
fn verify_sync(password: &str, encoded: &str) -> bool {
PasswordHash::new(encoded).ok().is_some_and(|hash| {
Argon2::default()
.verify_password(password.as_bytes(), &hash)
PasswordHash::new(encoded).is_ok()
&& Argon2::default()
.verify_password(password.as_bytes(), encoded)
.is_ok()
})
}
pub async fn hash_password_offline(password: String) -> Result<String> {
validate_password(&password)?;
+4 -4
View File
@@ -1,7 +1,7 @@
use anyhow::{bail, ensure, Context, Result};
use argon2::{Algorithm, Argon2, Params, Version};
use base64::Engine as _;
use rand::{rngs::OsRng, RngCore};
use rand::{rngs::SysRng, TryRng};
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -231,8 +231,8 @@ fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
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);
SysRng.try_fill_bytes(&mut salt).expect("secure operating system random source unavailable");
SysRng.try_fill_bytes(&mut nonce_prefix).expect("secure operating system random source unavailable");
let header = CryptoHeader {
format_version: FORMAT_VERSION,
kdf: "Argon2id".into(),
@@ -395,7 +395,7 @@ fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroiz
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);
SysRng.try_fill_bytes(&mut nonce_bytes).expect("secure operating system random source unavailable");
let cipher = cipher(backup_key)?;
let mut encrypted = Zeroizing::new(master.to_vec());
cipher
+1 -1
View File
@@ -426,6 +426,6 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
pub fn generated_password() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
SysRng.try_fill_bytes(&mut bytes).expect("secure operating system random source unavailable");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
+5 -2
View File
@@ -3,7 +3,7 @@ use crate::{config::CompiledBackend, vhost::PoolMember};
use anyhow::{ensure, Result};
use axum::body::Body;
use http::{HeaderMap, HeaderValue, Request};
use rand::{distributions::Alphanumeric, Rng};
use rand::{distr::Alphanumeric, RngExt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
@@ -218,7 +218,10 @@ impl Default for Settings {
impl Settings {
/// Resolve active health per member; a disabled monitor never probes.
pub fn health_for(&self, member: &PoolMember) -> HealthCheck {
let mut health = member.health_check.clone().unwrap_or_else(|| self.health_check.clone());
let mut health = member
.health_check
.clone()
.unwrap_or_else(|| self.health_check.clone());
if health.host.is_none() && health.mode == HealthMode::Http {
health.host = self.health_check.host.clone();
}
+10 -8
View File
@@ -24,8 +24,8 @@ impl Pool {
state: Arc::new(BackendState::new(settings.health_for(&definition).mode)),
definition,
backend,
token: rand::thread_rng()
.sample_iter(&Alphanumeric)
token: rand::rng()
.sample_iter(Alphanumeric)
.take(32)
.map(char::from)
.collect(),
@@ -53,14 +53,16 @@ impl Pool {
};
let same_origin = serde_json::to_vec(&member.definition.backend()).ok()
== serde_json::to_vec(&old.definition.backend()).ok();
let same_health =
serde_json::to_vec(&(
self.settings.health_for(&member.definition),
self.settings.passive_for(&member.definition),
)).ok() == serde_json::to_vec(&(
let same_health = serde_json::to_vec(&(
self.settings.health_for(&member.definition),
self.settings.passive_for(&member.definition),
))
.ok()
== serde_json::to_vec(&(
previous.settings.health_for(&old.definition),
previous.settings.passive_for(&old.definition),
)).ok();
))
.ok();
let same_clients = member.backend.client.reusable_with(&old.backend.client)
&& member
.backend
+7 -6
View File
@@ -2,7 +2,7 @@
//! ACLs run first; credentials are never cached with content or written to logs.
use anyhow::{ensure, Result};
use argon2::{
password_hash::{PasswordHash, PasswordVerifier},
password_hash::{phc::PasswordHash, PasswordVerifier},
Argon2,
};
use axum::response::{IntoResponse, Response};
@@ -88,8 +88,10 @@ impl Settings {
);
let hash = PasswordHash::new(&user.password_hash)
.map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?;
// Match the former 16-character minimum for the PHC-encoded salt.
// phc 0.6 Salt::len() now measures decoded bytes instead.
ensure!(
hash.salt.is_some_and(|s| s.as_str().len() >= 16)
hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16)
&& hash.hash.is_some_and(|h| h.len() == 32),
"basic.invalid_hash"
);
@@ -221,11 +223,10 @@ impl Compiled {
.map_err(|_| denied(StatusCode::SERVICE_UNAVAILABLE, None))?;
let verified = tokio::task::spawn_blocking(move || {
let _permit = permit;
PasswordHash::new(&encoded).ok().is_some_and(|hash| {
Argon2::default()
.verify_password(password.as_bytes(), &hash)
PasswordHash::new(&encoded).is_ok()
&& Argon2::default()
.verify_password(password.as_bytes(), encoded.as_str())
.is_ok()
})
})
.await
.unwrap_or(false);
+22 -16
View File
@@ -256,23 +256,29 @@ async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_tra
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
{
// COLD entries intentionally keep validators and headers on disk to bound RAM.
// Inspect them through the same verified metadata loader used on COLD hits.
let (key, transformed) = {
let cold = restarted.cold.lock().unwrap();
let transformed = cold
.entries
.values()
.find(|entry| entry.path == "/transformed.json")
.expect("transformed cold entry");
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert_eq!(transformed.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
transformed.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(transformed.headers["etag"], "\"rustproxy-representation\"");
assert!(transformed.transformed);
}
cold.entries
.iter()
.find(|(_, entry)| entry.path == "/transformed.json")
.map(|(key, entry)| (*key, entry.clone()))
.expect("transformed cold entry")
};
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert!(transformed.has_validator);
assert!(transformed.transformed);
let metadata = read_cold_metadata(&key, &transformed)
.await
.expect("persisted transformed metadata");
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
metadata.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
+24 -9
View File
@@ -1,10 +1,11 @@
fn request_id_hash(sequence: u64) -> String {
use rand::RngCore;
use rand::TryRng;
use sha2::{Digest, Sha256};
let mut nonce = [0u8; 32];
let mut rng = rand::rngs::OsRng;
rng.fill_bytes(&mut nonce);
let mut rng = rand::rngs::SysRng;
rng.try_fill_bytes(&mut nonce)
.expect("secure operating system random source unavailable");
let mut hash = Sha256::new();
hash.update(sequence.to_be_bytes());
hash.update(nonce);
@@ -13,9 +14,18 @@ fn request_id_hash(sequence: u64) -> String {
fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool {
!minimal_welcome
&& response.extensions().get::<ErrorFallbackResponse>().is_none()
&& response.extensions().get::<crate::pages::OriginProvidedResponse>().is_none()
&& response.extensions().get::<crate::pages::PageServed>().is_none()
&& response
.extensions()
.get::<ErrorFallbackResponse>()
.is_none()
&& response
.extensions()
.get::<crate::pages::OriginProvidedResponse>()
.is_none()
&& response
.extensions()
.get::<crate::pages::PageServed>()
.is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
@@ -298,14 +308,19 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
let global_compression = &snapshot.envelope.config.compression;
let mut compression = global_compression.clone();
if let Some(frontend) = frontend_context.as_ref() {
compression = frontend.compression.resolve(global_compression, &compression);
compression = frontend
.compression
.resolve(global_compression, &compression);
}
if let Some(route) = effective_route_context {
if let crate::config::CompiledSource::WebServer(service) = &route.source {
compression = service.compression.resolve(global_compression, &compression);
compression = service
.compression
.resolve(global_compression, &compression);
}
}
response = crate::compression::encode(response, &original_headers, &method, &compression).await;
response =
crate::compression::encode(response, &original_headers, &method, &compression).await;
if let Some(encoding) = response
.extensions()
.get::<crate::compression::AppliedEncoding>()
+3 -3
View File
@@ -13,7 +13,7 @@ use http_body::{Body as HttpBody, Frame, SizeHint};
use http_body_util::BodyExt;
use hyper::upgrade::{OnUpgrade, Upgraded};
use hyper_util::rt::TokioIo;
use rand::{rngs::OsRng, RngCore};
use rand::{rngs::SysRng, TryRng};
use serde::Serialize;
use sha1::{Digest, Sha1};
use std::{
@@ -186,7 +186,7 @@ pub fn validate_h3(request: &Request) -> Result<Handshake> {
"WebSocket version must be 13"
);
let mut nonce = [0u8; 16];
OsRng.fill_bytes(&mut nonce);
SysRng.try_fill_bytes(&mut nonce).expect("secure operating system random source unavailable");
let key = STANDARD.encode(nonce);
Ok(Handshake {
accept: accept_for_key(&key),
@@ -229,7 +229,7 @@ pub fn validate_h2(request: &Request) -> Result<Handshake> {
"WebSocket version must be 13"
);
let mut nonce = [0u8; 16];
OsRng.fill_bytes(&mut nonce);
SysRng.try_fill_bytes(&mut nonce).expect("secure operating system random source unavailable");
let key = STANDARD.encode(nonce);
Ok(Handshake {
accept: accept_for_key(&key),
+35 -11
View File
@@ -1,7 +1,7 @@
//! Central encrypted secret storage for provider/ACME credentials.
//! Plaintext exists only for the duration of an explicit read/write operation.
use anyhow::{ensure, Context, Result};
use rand::{rngs::OsRng, RngCore};
use rand::{rngs::SysRng, TryRng};
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
use serde::Serialize;
use sqlx::Row;
@@ -59,7 +59,10 @@ fn read_key(path: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
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");
ensure!(
metadata.len() == KEY_BYTES as u64,
"invalid secret store master key size"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -79,7 +82,10 @@ fn read_key(path: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
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");
ensure!(
file.read(&mut extra)? == 0,
"invalid secret store master key size"
);
Ok(bytes)
}
@@ -92,7 +98,9 @@ pub fn initialize(data_dir: &Path) -> Result<()> {
return Ok(());
}
let mut key = Zeroizing::new([0u8; KEY_BYTES]);
OsRng.fill_bytes(key.as_mut());
SysRng
.try_fill_bytes(key.as_mut())
.expect("secure operating system random source unavailable");
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
@@ -158,13 +166,18 @@ pub async fn put(
metadata: serde_json::Value,
) -> Result<()> {
validate_identity(secret_id, provider)?;
ensure!(!plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES, "invalid secret size");
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);
SysRng
.try_fill_bytes(&mut nonce)
.expect("secure operating system random source unavailable");
let mut ciphertext = Zeroizing::new(plaintext.to_vec());
cipher
.seal_in_place_append_tag(
@@ -212,12 +225,16 @@ pub async fn get(
}
validate_identity(secret_id, &provider)?;
let version: i64 = row.try_get("encryption_version")?;
ensure!(version == ENCRYPTION_VERSION, "unsupported secret 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(),
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];
@@ -287,7 +304,10 @@ pub async fn migrate_acme_credentials(
return Ok(0);
}
let root_meta = fs::symlink_metadata(&root)?;
ensure!(root_meta.is_dir() && !root_meta.file_type().is_symlink(), "invalid ACME directory");
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?;
@@ -301,7 +321,10 @@ pub async fn migrate_acme_credentials(
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 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)
@@ -341,7 +364,8 @@ pub async fn migrate_acme_credentials(
)
.await?;
plaintext.zeroize();
fs::remove_file(&credential_path).context("legacy ACME credential was encrypted but could not be removed")?;
fs::remove_file(&credential_path)
.context("legacy ACME credential was encrypted but could not be removed")?;
migrated += 1;
}
Ok(migrated)
+2 -2
View File
@@ -80,8 +80,8 @@ fn generate_locked(directory: &Path, name: &str, hosts: Vec<String>) -> Result<(
}
})
.collect::<Result<Vec<_>>>()?;
let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(hosts)?;
let key = Zeroizing::new(key_pair.serialize_pem());
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(hosts)?;
let key = Zeroizing::new(signing_key.serialize_pem());
store_pair(directory, name, cert.pem().as_bytes(), key.as_bytes())
}
pub fn initialize_default(directory: &Path) -> Result<()> {
+5 -5
View File
@@ -77,7 +77,7 @@ mod tests {
dir.path(),
"shared-cert",
generated.cert.pem().as_bytes(),
generated.key_pair.serialize_pem().as_bytes(),
generated.signing_key.serialize_pem().as_bytes(),
)
.unwrap();
assert!(cert_file.starts_with("external-shared-cert-"));
@@ -100,7 +100,7 @@ mod tests {
let generated =
rcgen::generate_simple_self_signed(vec!["reconcile.example.test".into()]).unwrap();
let certificate = generated.cert.pem();
let key = generated.key_pair.serialize_pem();
let key = generated.signing_key.serialize_pem();
let (cert_file, key_file, _) = stage_external_pair(
dir.path(),
"reconcile-cert",
@@ -281,7 +281,7 @@ mod tests {
let b = rcgen::generate_simple_self_signed(vec!["b.localhost".into()]).unwrap();
assert!(parse_pair(
a.cert.pem().as_bytes(),
b.key_pair.serialize_pem().as_bytes()
b.signing_key.serialize_pem().as_bytes()
)
.is_err());
let dir = tempfile::tempdir().unwrap();
@@ -338,7 +338,7 @@ mod tests {
assert!(upload(
dir.path(),
pair.cert.pem().as_bytes(),
pair.key_pair.serialize_pem().as_bytes(),
pair.signing_key.serialize_pem().as_bytes(),
hosts,
)
.is_ok());
@@ -353,7 +353,7 @@ mod tests {
])
.unwrap();
let pem = pair.cert.pem();
let key = pair.key_pair.serialize_pem();
let key = pair.signing_key.serialize_pem();
assert!(upload(
dir.path(),
pem.as_bytes(),
+2 -2
View File
@@ -10,7 +10,7 @@ mod acme_tests {
dir.path(),
"acme-fixture",
cert.cert.pem().as_bytes(),
cert.key_pair.serialize_pem().as_bytes(),
cert.signing_key.serialize_pem().as_bytes(),
)
.unwrap();
let requested = vec!["example.org".into(), "*.example.org".into()];
@@ -62,7 +62,7 @@ mod acme_tests {
dir.path(),
"acme-many",
cert.cert.pem().as_bytes(),
cert.key_pair.serialize_pem().as_bytes(),
cert.signing_key.serialize_pem().as_bytes(),
)
.unwrap();
assert!(validate_acme(
+2 -2
View File
@@ -15,7 +15,7 @@ fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
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::write(archive.join("privkey1.pem"), first.signing_key.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o650)).unwrap();
symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap();
@@ -47,7 +47,7 @@ fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
])
.unwrap();
fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap();
fs::write(archive.join("privkey2.pem"), second.key_pair.serialize_pem()).unwrap();
fs::write(archive.join("privkey2.pem"), second.signing_key.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();
+1 -1
View File
@@ -40,7 +40,7 @@ async fn php_fpm_gzip_response_finishes_after_fastcgi_eof() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'placeholder';").unwrap();
let address = fastcgi_fixture(expected).await;
let configured = Service {
let mut configured = Service {
id: "php-compression-regression".into(),
name: "PHP compression regression".into(),
document_root: tmp.path().to_path_buf(),