v.34.1
This commit is contained in:
1 parent
4afa88a65e
commit
2ab44c4c62
49 files changed
+1523
-176
No files matched your search
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user