mirror of
https://github.com/telemt/telemt.git
synced 2026-11-05 23:32:00 +03:00
Slot Budget + Config Store Atomic Writer fixes + Trusted Command
This commit is contained in:
+27
-3
@@ -9,8 +9,11 @@ use super::ApiShared;
|
||||
use super::config_store::{
|
||||
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_snapshot_revision, is_editable_section,
|
||||
load_candidate_snapshot, load_config_snapshot, render_server_listeners,
|
||||
render_top_level_section, resolve_single_source_owner, upsert_toml_table, write_atomic,
|
||||
render_top_level_section, resolve_single_source_owner, upsert_toml_table,
|
||||
write_atomic_if_unchanged,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::config_store::write_atomic;
|
||||
use super::model::ApiFailure;
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::hot_reload::classify_config_changes;
|
||||
@@ -43,7 +46,10 @@ pub(super) struct PatchConfigResponse {
|
||||
}
|
||||
|
||||
struct PreparedConfigPatch {
|
||||
config_path: PathBuf,
|
||||
expected_revision: String,
|
||||
owner_path: PathBuf,
|
||||
expected_owner_contents: String,
|
||||
owner_contents: String,
|
||||
desired_config: Arc<ProxyConfig>,
|
||||
response: PatchConfigResponse,
|
||||
@@ -77,7 +83,14 @@ pub(super) async fn patch_config(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
|
||||
write_atomic_if_unchanged(
|
||||
prepared.config_path,
|
||||
prepared.expected_revision,
|
||||
prepared.owner_path,
|
||||
prepared.expected_owner_contents,
|
||||
prepared.owner_contents,
|
||||
)
|
||||
.await?;
|
||||
if let Some(reservation) = reservation {
|
||||
prepared.response.reload = Some(reservation.enqueue(prepared.desired_config));
|
||||
}
|
||||
@@ -111,7 +124,14 @@ pub(super) async fn apply_patch_to_path(
|
||||
expected_revision: Option<String>,
|
||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||
let prepared = prepare_patch_to_path(config_path, patch_json, expected_revision).await?;
|
||||
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
|
||||
write_atomic_if_unchanged(
|
||||
prepared.config_path,
|
||||
prepared.expected_revision,
|
||||
prepared.owner_path,
|
||||
prepared.expected_owner_contents,
|
||||
prepared.owner_contents,
|
||||
)
|
||||
.await?;
|
||||
Ok(prepared.response)
|
||||
}
|
||||
|
||||
@@ -197,6 +217,7 @@ async fn prepare_patch_to_path(
|
||||
.get(&owner_path)
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
||||
let expected_owner_contents = owner_contents.clone();
|
||||
for section in &touched {
|
||||
if *section == "server" {
|
||||
let rendered = render_server_listeners(&requested_cfg)?;
|
||||
@@ -233,7 +254,10 @@ async fn prepare_patch_to_path(
|
||||
deferred_process_fields(&old_cfg, &new_cfg).map_err(ApiFailure::bad_request)?;
|
||||
|
||||
Ok(PreparedConfigPatch {
|
||||
config_path: config_path.to_path_buf(),
|
||||
expected_revision: current,
|
||||
owner_path,
|
||||
expected_owner_contents,
|
||||
owner_contents,
|
||||
desired_config: Arc::new(new_cfg),
|
||||
response: PatchConfigResponse {
|
||||
|
||||
@@ -323,6 +323,30 @@ async fn patch_writes_the_included_section_owner_only() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_patch_rejects_external_edit_before_commit() {
|
||||
let (path, _directory) = temp_config("[censorship]\ntls_domain = \"old.example\"\n");
|
||||
let patch: Json = serde_json::json!({
|
||||
"censorship": {"tls_domain": "api.example"}
|
||||
});
|
||||
let prepared = prepare_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
let external = "[censorship]\ntls_domain = \"external.example\"\n";
|
||||
tokio::fs::write(&path, external).await.unwrap();
|
||||
|
||||
let error = write_atomic_if_unchanged(
|
||||
prepared.config_path,
|
||||
prepared.expected_revision,
|
||||
prepared.owner_path,
|
||||
prepared.expected_owner_contents,
|
||||
prepared.owner_contents,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.code, "revision_conflict");
|
||||
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), external);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_multiple_source_owners_without_writing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+11
-9
@@ -10,13 +10,16 @@ use super::model::ApiFailure;
|
||||
|
||||
// Source-preserving TOML rendering and atomic persistence helpers.
|
||||
mod persistence;
|
||||
// Compare-and-replace file persistence and metadata preservation.
|
||||
mod atomic;
|
||||
|
||||
#[cfg(test)]
|
||||
use persistence::{find_toml_table_bounds, render_access_section, save_sections_to_disk};
|
||||
pub(in crate::api) use persistence::{
|
||||
render_server_listeners, render_top_level_section, save_access_sections_to_disk,
|
||||
upsert_toml_table, write_atomic,
|
||||
save_access_sections_to_disk_if_revision, upsert_toml_table,
|
||||
};
|
||||
pub(in crate::api) use atomic::{write_atomic, write_atomic_if_unchanged};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum AccessSection {
|
||||
@@ -54,22 +57,21 @@ pub(super) fn parse_if_match(headers: &hyper::HeaderMap) -> Option<String> {
|
||||
.map(|value| value.trim_matches('"').to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_expected_revision(
|
||||
/// Loads one mutation base and validates its revision from the same source snapshot.
|
||||
pub(super) async fn load_config_for_mutation(
|
||||
config_path: &Path,
|
||||
expected_revision: Option<&str>,
|
||||
) -> Result<(), ApiFailure> {
|
||||
let Some(expected) = expected_revision else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = current_revision(config_path).await?;
|
||||
if current != expected {
|
||||
) -> Result<(ProxyConfig, String), ApiFailure> {
|
||||
let loaded = load_config_snapshot(config_path, false).await?;
|
||||
let revision = compute_snapshot_revision(&loaded);
|
||||
if expected_revision.is_some_and(|expected| expected != revision) {
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
"revision_conflict",
|
||||
"Config revision mismatch",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
Ok((loaded.config, revision))
|
||||
}
|
||||
|
||||
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||
|
||||
use super::compute_source_revision;
|
||||
use crate::api::model::ApiFailure;
|
||||
use crate::config::ProxyConfig;
|
||||
|
||||
enum AtomicWriteError {
|
||||
Conflict,
|
||||
ReadGraph(String),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
struct ExistingTarget {
|
||||
contents: String,
|
||||
metadata: std::fs::Metadata,
|
||||
}
|
||||
|
||||
/// Replaces one config source through a durable same-directory rename.
|
||||
pub(in crate::api) async fn write_atomic(
|
||||
path: PathBuf,
|
||||
contents: String,
|
||||
) -> Result<(), ApiFailure> {
|
||||
tokio::task::spawn_blocking(move || write_atomic_sync(&path, None, &contents))
|
||||
.await
|
||||
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
|
||||
.map_err(|error| ApiFailure::internal(format!("failed to write config: {error}")))
|
||||
}
|
||||
|
||||
/// Replaces one source only if both its graph revision and owner contents are unchanged.
|
||||
pub(in crate::api) async fn write_atomic_if_unchanged(
|
||||
config_path: PathBuf,
|
||||
expected_revision: String,
|
||||
path: PathBuf,
|
||||
expected_contents: String,
|
||||
contents: String,
|
||||
) -> Result<(), ApiFailure> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let graph = ProxyConfig::read_source_graph(&config_path)
|
||||
.map_err(|error| AtomicWriteError::ReadGraph(error.to_string()))?;
|
||||
if compute_source_revision(&graph) != expected_revision {
|
||||
return Err(AtomicWriteError::Conflict);
|
||||
}
|
||||
write_atomic_sync(&path, Some(&expected_contents), &contents).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
AtomicWriteError::Conflict
|
||||
} else {
|
||||
AtomicWriteError::Io(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
|
||||
.map_err(|error| match error {
|
||||
AtomicWriteError::Conflict => revision_conflict(),
|
||||
AtomicWriteError::ReadGraph(error) => {
|
||||
ApiFailure::internal(format!("failed to verify config graph: {error}"))
|
||||
}
|
||||
AtomicWriteError::Io(error) => {
|
||||
ApiFailure::internal(format!("failed to write config: {error}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn revision_conflict() -> ApiFailure {
|
||||
ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
"revision_conflict",
|
||||
"Config revision changed before persistence",
|
||||
)
|
||||
}
|
||||
|
||||
fn open_existing_target(path: &Path) -> std::io::Result<Option<ExistingTarget>> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
|
||||
let mut file = match options.open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"config target must be a regular file",
|
||||
));
|
||||
}
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
Ok(Some(ExistingTarget { contents, metadata }))
|
||||
}
|
||||
|
||||
fn same_target(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
left.dev() == right.dev() && left.ino() == right.ino()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
left.len() == right.len() && left.modified().ok() == right.modified().ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn write_atomic_sync(
|
||||
path: &Path,
|
||||
expected_contents: Option<&str>,
|
||||
contents: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let existing = open_existing_target(path)?;
|
||||
if expected_contents.is_some_and(|expected| {
|
||||
existing
|
||||
.as_ref()
|
||||
.is_none_or(|target| target.contents != expected)
|
||||
}) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"config source changed before persistence",
|
||||
));
|
||||
}
|
||||
|
||||
let tmp_name = format!(
|
||||
".{}.tmp-{}",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("config.toml"),
|
||||
rand::random::<u64>()
|
||||
);
|
||||
let tmp_path = parent.join(tmp_name);
|
||||
|
||||
let write_result = (|| {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.create_new(true).write(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
let mut file = options.open(&tmp_path)?;
|
||||
#[cfg(unix)]
|
||||
if let Some(existing) = existing.as_ref() {
|
||||
use nix::unistd::{Gid, Uid, fchown};
|
||||
|
||||
fchown(
|
||||
&file,
|
||||
Some(Uid::from_raw(existing.metadata.uid())),
|
||||
Some(Gid::from_raw(existing.metadata.gid())),
|
||||
)
|
||||
.map_err(|error| std::io::Error::from_raw_os_error(error as i32))?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(
|
||||
existing.metadata.mode() & 0o7777,
|
||||
))?;
|
||||
}
|
||||
file.write_all(contents.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
let current = open_existing_target(path)?;
|
||||
let target_unchanged = match (&existing, ¤t) {
|
||||
(Some(expected), Some(current)) => {
|
||||
same_target(&expected.metadata, ¤t.metadata)
|
||||
&& expected.contents == current.contents
|
||||
}
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !target_unchanged {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"config target changed during persistence",
|
||||
));
|
||||
}
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
if let Ok(dir) = std::fs::File::open(parent) {
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if write_result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
write_result
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
@@ -13,9 +9,12 @@ use crate::config::{ProxyConfig, RateLimitBps};
|
||||
#[cfg(test)]
|
||||
use super::compute_revision;
|
||||
use super::{
|
||||
AccessSection, compute_snapshot_revision, compute_source_revision, load_candidate_snapshot,
|
||||
load_config_snapshot, resolve_single_source_owner, toml_path_exists,
|
||||
AccessSection, compute_snapshot_revision, load_candidate_snapshot, load_config_snapshot,
|
||||
resolve_single_source_owner, toml_path_exists,
|
||||
};
|
||||
use super::atomic::write_atomic_if_unchanged;
|
||||
#[cfg(test)]
|
||||
use super::atomic::write_atomic;
|
||||
use crate::api::model::ApiFailure;
|
||||
|
||||
/// Re-render the given top-level tables from `cfg` and upsert each into the
|
||||
@@ -398,58 +397,6 @@ fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)>
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Replaces one config source through a durable same-directory rename.
|
||||
pub(in crate::api) async fn write_atomic(
|
||||
path: PathBuf,
|
||||
contents: String,
|
||||
) -> Result<(), ApiFailure> {
|
||||
tokio::task::spawn_blocking(move || write_atomic_sync(&path, None, &contents))
|
||||
.await
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
||||
}
|
||||
|
||||
/// Replaces one source only if both its graph revision and owner contents are unchanged.
|
||||
pub(in crate::api) async fn write_atomic_if_unchanged(
|
||||
config_path: PathBuf,
|
||||
expected_revision: String,
|
||||
path: PathBuf,
|
||||
expected_contents: String,
|
||||
contents: String,
|
||||
) -> Result<(), ApiFailure> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let graph = ProxyConfig::read_source_graph(&config_path)
|
||||
.map_err(|error| AtomicWriteError::ReadGraph(error.to_string()))?;
|
||||
if compute_source_revision(&graph) != expected_revision {
|
||||
return Err(AtomicWriteError::Conflict);
|
||||
}
|
||||
write_atomic_sync(&path, Some(&expected_contents), &contents)
|
||||
.map_err(AtomicWriteError::Io)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiFailure::internal(format!("failed to join writer: {error}")))?
|
||||
.map_err(|error| match error {
|
||||
AtomicWriteError::Conflict => revision_conflict(),
|
||||
AtomicWriteError::ReadGraph(error) => {
|
||||
ApiFailure::internal(format!("failed to verify config graph: {error}"))
|
||||
}
|
||||
AtomicWriteError::Io(error) => {
|
||||
ApiFailure::internal(format!("failed to write config: {error}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
enum AtomicWriteError {
|
||||
Conflict,
|
||||
ReadGraph(String),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
struct ExistingTarget {
|
||||
contents: String,
|
||||
metadata: std::fs::Metadata,
|
||||
}
|
||||
|
||||
fn revision_conflict() -> ApiFailure {
|
||||
ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
@@ -457,115 +404,3 @@ fn revision_conflict() -> ApiFailure {
|
||||
"Config revision changed before persistence",
|
||||
)
|
||||
}
|
||||
|
||||
fn open_existing_target(path: &Path) -> std::io::Result<Option<ExistingTarget>> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
|
||||
let mut file = match options.open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"config target must be a regular file",
|
||||
));
|
||||
}
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
Ok(Some(ExistingTarget { contents, metadata }))
|
||||
}
|
||||
|
||||
fn same_target(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
left.dev() == right.dev() && left.ino() == right.ino()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
left.len() == right.len() && left.modified().ok() == right.modified().ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn write_atomic_sync(
|
||||
path: &Path,
|
||||
expected_contents: Option<&str>,
|
||||
contents: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let existing = open_existing_target(path)?;
|
||||
if expected_contents.is_some_and(|expected| {
|
||||
existing
|
||||
.as_ref()
|
||||
.is_none_or(|target| target.contents != expected)
|
||||
}) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"config source changed before persistence",
|
||||
));
|
||||
}
|
||||
|
||||
let tmp_name = format!(
|
||||
".{}.tmp-{}",
|
||||
path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("config.toml"),
|
||||
rand::random::<u64>()
|
||||
);
|
||||
let tmp_path = parent.join(tmp_name);
|
||||
|
||||
let write_result = (|| {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
#[cfg(unix)]
|
||||
.mode(0o600)
|
||||
.open(&tmp_path)?;
|
||||
#[cfg(unix)]
|
||||
if let Some(existing) = existing.as_ref() {
|
||||
use nix::unistd::{Gid, Uid, fchown};
|
||||
|
||||
fchown(
|
||||
&file,
|
||||
Some(Uid::from_raw(existing.metadata.uid())),
|
||||
Some(Gid::from_raw(existing.metadata.gid())),
|
||||
)
|
||||
.map_err(|error| std::io::Error::from_raw_os_error(error as i32))?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(
|
||||
existing.metadata.mode() & 0o7777,
|
||||
))?;
|
||||
}
|
||||
file.write_all(contents.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
let current = open_existing_target(path)?;
|
||||
let target_unchanged = match (&existing, ¤t) {
|
||||
(Some(expected), Some(current)) => {
|
||||
same_target(&expected.metadata, ¤t.metadata)
|
||||
&& expected.contents == current.contents
|
||||
}
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !target_unchanged {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"config target changed during persistence",
|
||||
));
|
||||
}
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
if let Ok(dir) = std::fs::File::open(parent) {
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if write_result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
write_result
|
||||
}
|
||||
|
||||
@@ -260,6 +260,60 @@ async fn access_mutation_writes_only_the_single_included_owner() {
|
||||
assert_eq!(revision, current_revision(&root).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_mutation_rejects_source_graph_change_after_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("config.toml");
|
||||
let included = dir.path().join("users.toml");
|
||||
let root_body = "include = \"users.toml\"\n[censorship]\ntls_domain = \"one.example\"\n";
|
||||
let external_root =
|
||||
"include = \"users.toml\"\n[censorship]\ntls_domain = \"two.example\"\n";
|
||||
let included_body = "[access.users]\nalice = \"00000000000000000000000000000000\"\n";
|
||||
tokio::fs::write(&root, root_body).await.unwrap();
|
||||
tokio::fs::write(&included, included_body).await.unwrap();
|
||||
let (mut cfg, revision) = load_config_for_mutation(&root, None).await.unwrap();
|
||||
cfg.access.users.insert(
|
||||
"bob".to_string(),
|
||||
"11111111111111111111111111111111".to_string(),
|
||||
);
|
||||
tokio::fs::write(&root, external_root).await.unwrap();
|
||||
|
||||
let error = save_access_sections_to_disk_if_revision(
|
||||
&root,
|
||||
&cfg,
|
||||
&[AccessSection::Users],
|
||||
Some(&revision),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.code, "revision_conflict");
|
||||
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), external_root);
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&included).await.unwrap(),
|
||||
included_body
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn atomic_write_preserves_existing_file_mode() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
tokio::fs::write(&path, "old").await.unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
let before = std::fs::metadata(&path).unwrap();
|
||||
|
||||
write_atomic(path.clone(), "new".to_string()).await.unwrap();
|
||||
|
||||
let after = std::fs::metadata(&path).unwrap();
|
||||
assert_eq!(after.mode() & 0o7777, 0o640);
|
||||
assert_eq!(after.uid(), before.uid());
|
||||
assert_eq!(after.gid(), before.gid());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_mutation_rejects_sections_with_different_source_owners() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -134,8 +134,8 @@ pub(super) async fn handle(
|
||||
}
|
||||
let expected_revision = parse_if_match(req.headers());
|
||||
let _mutation_guard = shared.mutation_lock.lock().await;
|
||||
let disk_cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
let (disk_cfg, _) =
|
||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
if !disk_cfg.access.users.contains_key(user) {
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ mod web_runtime;
|
||||
mod web_status;
|
||||
|
||||
use config_store::{
|
||||
current_revision, ensure_expected_revision, load_config_for_reload, load_config_from_disk,
|
||||
current_revision, load_config_for_mutation, load_config_for_reload, load_config_from_disk,
|
||||
parse_if_match,
|
||||
};
|
||||
use events::ApiEventStore;
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ use crate::stats::Stats;
|
||||
|
||||
use super::ApiShared;
|
||||
use super::config_store::{
|
||||
AccessSection, current_revision, ensure_expected_revision, load_config_from_disk,
|
||||
save_access_sections_to_disk,
|
||||
AccessSection, current_revision, load_config_for_mutation,
|
||||
save_access_sections_to_disk_if_revision,
|
||||
};
|
||||
use super::model::{
|
||||
ApiFailure, CreateUserRequest, CreateUserResponse, PatchUserRequest, RotateSecretRequest,
|
||||
|
||||
@@ -42,8 +42,8 @@ pub(in crate::api) async fn create_user(
|
||||
|
||||
let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?;
|
||||
let _guard = shared.mutation_lock.lock().await;
|
||||
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
let (mut cfg, base_revision) =
|
||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
|
||||
if cfg.access.users.contains_key(&body.username) {
|
||||
return Err(ApiFailure::new(
|
||||
@@ -122,8 +122,13 @@ pub(in crate::api) async fn create_user(
|
||||
touched_sections.push(AccessSection::UserEnabled);
|
||||
}
|
||||
|
||||
let revision =
|
||||
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||
let revision = save_access_sections_to_disk_if_revision(
|
||||
&shared.config_path,
|
||||
&cfg,
|
||||
&touched_sections,
|
||||
Some(&base_revision),
|
||||
)
|
||||
.await?;
|
||||
shared
|
||||
.proxy_shared
|
||||
.stage_user(
|
||||
|
||||
@@ -15,8 +15,8 @@ pub(in crate::api) async fn rotate_secret(
|
||||
}
|
||||
|
||||
let _guard = shared.mutation_lock.lock().await;
|
||||
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
let (mut cfg, base_revision) =
|
||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
|
||||
if !cfg.access.users.contains_key(user) {
|
||||
return Err(ApiFailure::new(
|
||||
@@ -29,8 +29,13 @@ pub(in crate::api) async fn rotate_secret(
|
||||
cfg.access.users.insert(user.to_string(), secret.clone());
|
||||
cfg.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
let revision =
|
||||
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::Users]).await?;
|
||||
let revision = save_access_sections_to_disk_if_revision(
|
||||
&shared.config_path,
|
||||
&cfg,
|
||||
&[AccessSection::Users],
|
||||
Some(&base_revision),
|
||||
)
|
||||
.await?;
|
||||
shared
|
||||
.proxy_shared
|
||||
.stage_user(user, &secret, cfg.access.is_user_enabled(user))
|
||||
@@ -67,8 +72,8 @@ pub(in crate::api) async fn delete_user(
|
||||
shared: &ApiShared,
|
||||
) -> Result<(String, String), ApiFailure> {
|
||||
let _guard = shared.mutation_lock.lock().await;
|
||||
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
let (mut cfg, base_revision) =
|
||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
|
||||
if !cfg.access.users.contains_key(user) {
|
||||
return Err(ApiFailure::new(
|
||||
@@ -111,8 +116,13 @@ pub(in crate::api) async fn delete_user(
|
||||
|
||||
cfg.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
let revision =
|
||||
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||
let revision = save_access_sections_to_disk_if_revision(
|
||||
&shared.config_path,
|
||||
&cfg,
|
||||
&touched_sections,
|
||||
Some(&base_revision),
|
||||
)
|
||||
.await?;
|
||||
let deleted_incarnation = shared.proxy_shared.delete_user(user).incarnation;
|
||||
let configured_users = cfg.access.users.keys().cloned().collect();
|
||||
if let Err(error) = shared
|
||||
|
||||
+18
-8
@@ -32,8 +32,8 @@ pub(in crate::api) async fn patch_user(
|
||||
}
|
||||
let expiration = parse_patch_expiration(&body.expiration_rfc3339)?;
|
||||
let _guard = shared.mutation_lock.lock().await;
|
||||
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
let (mut cfg, base_revision) =
|
||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
|
||||
if !cfg.access.users.contains_key(user) {
|
||||
return Err(ApiFailure::new(
|
||||
@@ -168,7 +168,13 @@ pub(in crate::api) async fn patch_user(
|
||||
let revision = if touched_sections.is_empty() {
|
||||
current_revision(&shared.config_path).await?
|
||||
} else {
|
||||
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?
|
||||
save_access_sections_to_disk_if_revision(
|
||||
&shared.config_path,
|
||||
&cfg,
|
||||
&touched_sections,
|
||||
Some(&base_revision),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
if touches_users || touches_user_enabled {
|
||||
let secret = cfg
|
||||
@@ -212,8 +218,8 @@ pub(in crate::api) async fn set_user_enabled(
|
||||
shared: &ApiShared,
|
||||
) -> Result<(UserInfo, String), ApiFailure> {
|
||||
let _guard = shared.mutation_lock.lock().await;
|
||||
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
let (mut cfg, base_revision) =
|
||||
load_config_for_mutation(&shared.config_path, expected_revision.as_deref()).await?;
|
||||
|
||||
if !cfg.access.users.contains_key(user) {
|
||||
return Err(ApiFailure::new(
|
||||
@@ -231,9 +237,13 @@ pub(in crate::api) async fn set_user_enabled(
|
||||
|
||||
cfg.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
let revision =
|
||||
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled])
|
||||
.await?;
|
||||
let revision = save_access_sections_to_disk_if_revision(
|
||||
&shared.config_path,
|
||||
&cfg,
|
||||
&[AccessSection::UserEnabled],
|
||||
Some(&base_revision),
|
||||
)
|
||||
.await?;
|
||||
let secret = cfg
|
||||
.access
|
||||
.users
|
||||
|
||||
Reference in New Issue
Block a user