This commit is contained in:
Mateusz Gruszczyński committed 2026-10-19 11:09:48 +02:00
1 parent 4afa88a65e
commit 2ab44c4c62
49 files changed
+1523 -176

No files matched your search

+2
View File
@@ -158,6 +158,7 @@ impl App {
basic_workers: Arc::new(Semaphore::new(2)),
basic_limits: Default::default(),
active_prometheus,
prometheus_token_hash: arc_swap::ArcSwapOption::from(None),
active_administration,
active_administration_tls_policy,
active_administration_certificate_files,
@@ -193,6 +194,7 @@ impl App {
started: Instant::now(),
header_timeout,
body_idle_timeout,
config_file: Mutex::new(None),
update_lock: Mutex::new(()),
update_slots: Arc::new(Semaphore::new(8)),
});
+89
View File
@@ -0,0 +1,89 @@
//! Explicit JSON configuration backing file. SQLite remains the revision/audit journal.
//! File replacement is atomic on the containing filesystem and refuses symlink targets.
use crate::config::{Config, MAX_CONFIG_BYTES};
use anyhow::{bail, ensure, Context, Result};
use sha2::{Digest, Sha256};
use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
};
pub type Fingerprint = [u8; 32];
pub fn read(path: &Path) -> Result<(Config, Fingerprint)> {
ensure!(path.is_absolute(), "--config requires an absolute filename");
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options
.open(path)
.with_context(|| format!("cannot open configuration file {}", path.display()))?;
let meta = file.metadata()?;
ensure!(
meta.is_file() && meta.len() <= MAX_CONFIG_BYTES as u64,
"configuration file must be a regular, non-symlink JSON file within size limit"
);
let mut contents = Vec::new();
file.take((MAX_CONFIG_BYTES as u64) + 1)
.read_to_end(&mut contents)?;
ensure!(
contents.len() <= MAX_CONFIG_BYTES,
"configuration file exceeds size limit"
);
let config = serde_json::from_slice(&contents).context("invalid --config JSON")?;
Ok((config, Sha256::digest(&contents).into()))
}
// Only compare fingerprints of bytes, never raw secrets in logs. Caller serializes
// saves under App::update_lock; conflicts with out-of-band file edits fail closed.
pub fn save(path: &Path, expected: Fingerprint, config: &Config) -> Result<Fingerprint> {
let (_, actual) = read(path)?;
if actual != expected {
bail!("configuration file changed externally; reload before saving");
}
let bytes = serde_json::to_vec_pretty(config)?;
ensure!(
bytes.len() <= MAX_CONFIG_BYTES,
"configuration exceeds file size limit"
);
let parent = path
.parent()
.context("configuration has no parent directory")?;
let name = path
.file_name()
.context("configuration has no filename")?
.to_string_lossy();
let temp: PathBuf = parent.join(format!(
".{name}.rustproxy-{}",
crate::auth::random_secret()
));
let result = (|| -> Result<()> {
let mut options = fs::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(&temp)?;
file.write_all(&bytes)?;
file.sync_all()?;
// An external writer may have changed the source while the temp was prepared.
if read(path)?.1 != expected {
bail!("configuration file changed during save");
}
fs::rename(&temp, path)?;
fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
result?;
Ok(Sha256::digest(&bytes).into())
}
+7 -1
View File
@@ -23,6 +23,7 @@ pub struct App {
pub basic_workers: Arc<Semaphore>,
pub basic_limits: crate::basic_auth::Limits,
pub active_prometheus: crate::prometheus::Settings,
pub prometheus_token_hash: arc_swap::ArcSwapOption<String>,
pub active_administration: crate::settings::Administration,
pub active_administration_tls_policy: crate::tls::ProtocolPolicy,
pub active_administration_certificate_files: Option<(String, String)>,
@@ -58,6 +59,7 @@ pub struct App {
pub started: Instant,
pub header_timeout: Duration,
pub body_idle_timeout: Duration,
pub config_file: Mutex<Option<(std::path::PathBuf, external_config::Fingerprint)>>,
update_lock: Mutex<()>,
update_slots: Arc<Semaphore>,
}
@@ -173,7 +175,11 @@ fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
}
fn vhost_cache_revisions(config: &config::Config) -> HashMap<String, u64> {
config.vhosts.iter().map(|vhost| (vhost.id.clone(), vhost_cache_revision(vhost))).collect()
config
.vhosts
.iter()
.map(|vhost| (vhost.id.clone(), vhost_cache_revision(vhost)))
.collect()
}
fn cache_invalidation(previous: &config::Config, next: &config::Config) -> CacheInvalidation {
+129 -17
View File
@@ -1,7 +1,57 @@
impl App {
pub async fn set_config_file(
&self,
path: std::path::PathBuf,
hash: external_config::Fingerprint,
) {
*self.config_file.lock().await = Some((path, hash));
}
/// Reads an operator-edited JSON file and publishes it through the same
/// validation, history and hot-apply path as a panel save.
pub async fn reload_from_file(
self: &Arc<Self>,
expected_revision: u64,
) -> Result<config::Envelope, ApiError> {
let file = self
.config_file
.lock()
.await
.as_ref()
.map(|(path, _)| path.clone());
let Some(file) = file else {
let config = self.config.load().envelope.config.clone();
return self
.update_as(
Update {
expected_revision,
config,
},
"reload".into(),
)
.await;
};
let (config, fingerprint) =
tokio::task::spawn_blocking(move || external_config::read(&file))
.await
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))?;
self.update_as_action(
Update {
expected_revision,
config,
},
"file-reload".into(),
None,
Some(fingerprint),
)
.await
}
pub fn start_dns_acl_updates(self: &Arc<Self>) {
self.tunnels.spawn(crate::acl::dynamic::poll_loop(
Arc::downgrade(self), self.shutdown.clone(),
Arc::downgrade(self),
self.shutdown.clone(),
));
}
@@ -34,7 +84,7 @@ impl App {
update: Update,
actor: String,
) -> Result<config::Envelope, ApiError> {
self.update_as_action(update, actor, None).await
self.update_as_action(update, actor, None, None).await
}
pub async fn restore_as(
self: &Arc<Self>,
@@ -42,7 +92,7 @@ impl App {
actor: String,
source_revision: u64,
) -> Result<config::Envelope, ApiError> {
self.update_as_action(update, actor, Some(source_revision))
self.update_as_action(update, actor, Some(source_revision), None)
.await
}
async fn update_as_action(
@@ -50,6 +100,7 @@ impl App {
update: Update,
actor: String,
source_revision: Option<u64>,
reload_hash: Option<external_config::Fingerprint>,
) -> Result<config::Envelope, ApiError> {
let slot = self.update_slots.clone().try_acquire_owned().map_err(|_| {
ApiError(
@@ -110,6 +161,39 @@ impl App {
"configuration changed; reload before saving".into(),
));
}
// Explicit --config is the source of truth. A panel save writes the
// JSON file before recording its revision in SQLite, while a manual
// reload imports the operator-edited file without overwriting it.
let file_updated_hash =
if let Some((path, last_hash)) = this.config_file.lock().await.as_ref().cloned() {
let config = prepared.envelope.config.clone();
let target = path.clone();
let result = tokio::task::spawn_blocking(move || {
if let Some(reloaded) = reload_hash {
if external_config::read(&target)?.1 != reloaded {
anyhow::bail!("configuration file changed during reload");
}
Ok(reloaded)
} else {
external_config::save(&target, last_hash, &config)
}
})
.await;
match result {
Ok(Ok(hash)) => Some((path, last_hash, hash)),
Ok(Err(error)) => {
return Err(ApiError(StatusCode::CONFLICT, format!("{error:#}")))
}
Err(_) => {
return Err(ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"configuration file writer failed".into(),
))
}
}
} else {
None
};
let persisted = match source_revision {
Some(source) => {
this.store
@@ -127,21 +211,48 @@ impl App {
.await
}
};
match persisted {
Ok(true) => {}
Ok(false) => {
return Err(ApiError(
if !matches!(&persisted, Ok(true)) {
// The database commit failed. Best-effort file rollback only
// for saves (never for an operator-authored manual reload).
if reload_hash.is_none() {
if let Some((path, _, new_hash)) = &file_updated_hash {
let path = path.clone();
let rollback_path = path.clone();
let old_config = this.config.load().envelope.config.clone();
let new_hash = *new_hash;
match tokio::task::spawn_blocking(move || {
external_config::save(&rollback_path, new_hash, &old_config)
})
.await
{
Ok(Ok(restored_hash)) => {
// The old document may be reformatted, so use its new fingerprint.
*this.config_file.lock().await = Some((path, restored_hash));
}
result => tracing::error!(
?result,
"configuration file rollback failed; investigate before continuing"
),
}
}
}
return match persisted {
Ok(false) => Err(ApiError(
StatusCode::CONFLICT,
"stored revision differs; external SQLite writes are unsupported".into(),
))
}
Err(error) => {
tracing::error!(%error, "configuration persistence failed");
return Err(ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"configuration was not saved".into(),
));
}
)),
Err(error) => {
tracing::error!(%error, "configuration persistence failed");
Err(ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"configuration was not saved".into(),
))
}
Ok(true) => unreachable!(),
};
}
if let Some((path, _, new_hash)) = file_updated_hash.as_ref() {
*this.config_file.lock().await = Some((path.clone(), *new_hash));
}
// No await between successful commit and publication. Cache identity is
// intentionally decoupled from the configuration revision, so unrelated
@@ -164,7 +275,8 @@ impl App {
// A COLD entry is usable after restart only if its scope still has
// the same origin/content policy. Publish the fingerprints after
// the cache generation barriers but before the routing snapshot.
this.cache.replace_scope_revisions(vhost_cache_revisions(&envelope.config));
this.cache
.replace_scope_revisions(vhost_cache_revisions(&envelope.config));
this.cache.configure(envelope.config.cache.clone());
this.rate_limiter.reset();
this.logs.configure(envelope.config.logging.clone());