v0.31.2
This commit is contained in:
1 parent
24af7478b8
commit
0facfe6f5c
44 files changed
+898
-321
No files matched your search
@@ -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,5 +1,6 @@
|
||||
use super::*;
|
||||
use flate2::read::GzDecoder;
|
||||
use http::header;
|
||||
use http_body_util::BodyExt;
|
||||
use std::io::Read;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() }) }; }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user