mirror of
https://github.com/telemt/telemt.git
synced 2026-10-10 19:35:58 +03:00
Direct-since-epoch
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::Write;
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -10,8 +13,8 @@ use crate::config::{ProxyConfig, RateLimitBps};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::compute_revision;
|
use super::compute_revision;
|
||||||
use super::{
|
use super::{
|
||||||
AccessSection, compute_snapshot_revision, load_candidate_snapshot, load_config_snapshot,
|
AccessSection, compute_snapshot_revision, compute_source_revision, load_candidate_snapshot,
|
||||||
resolve_single_source_owner, toml_path_exists,
|
load_config_snapshot, resolve_single_source_owner, toml_path_exists,
|
||||||
};
|
};
|
||||||
use crate::api::model::ApiFailure;
|
use crate::api::model::ApiFailure;
|
||||||
|
|
||||||
@@ -99,8 +102,22 @@ pub(in crate::api) async fn save_access_sections_to_disk(
|
|||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
cfg: &ProxyConfig,
|
cfg: &ProxyConfig,
|
||||||
sections: &[AccessSection],
|
sections: &[AccessSection],
|
||||||
|
) -> Result<String, ApiFailure> {
|
||||||
|
save_access_sections_to_disk_if_revision(config_path, cfg, sections, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists access tables only while the complete source graph remains unchanged.
|
||||||
|
pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
|
||||||
|
config_path: &Path,
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
sections: &[AccessSection],
|
||||||
|
expected_revision: Option<&str>,
|
||||||
) -> Result<String, ApiFailure> {
|
) -> Result<String, ApiFailure> {
|
||||||
let loaded = load_config_snapshot(config_path, false).await?;
|
let loaded = load_config_snapshot(config_path, false).await?;
|
||||||
|
let loaded_revision = compute_snapshot_revision(&loaded);
|
||||||
|
if expected_revision.is_some_and(|expected| expected != loaded_revision) {
|
||||||
|
return Err(revision_conflict());
|
||||||
|
}
|
||||||
let mut applied = Vec::new();
|
let mut applied = Vec::new();
|
||||||
for section in sections {
|
for section in sections {
|
||||||
if applied.contains(section) {
|
if applied.contains(section) {
|
||||||
@@ -117,7 +134,7 @@ pub(in crate::api) async fn save_access_sections_to_disk(
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if applied.is_empty() {
|
if applied.is_empty() {
|
||||||
return Ok(compute_snapshot_revision(&loaded));
|
return Ok(loaded_revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets = applied
|
let targets = applied
|
||||||
@@ -130,6 +147,7 @@ pub(in crate::api) async fn save_access_sections_to_disk(
|
|||||||
.get(&owner_path)
|
.get(&owner_path)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
||||||
|
let expected_owner_contents = owner_contents.clone();
|
||||||
for section in applied {
|
for section in applied {
|
||||||
let rendered = render_access_section(cfg, section)?;
|
let rendered = render_access_section(cfg, section)?;
|
||||||
owner_contents = upsert_toml_table(&owner_contents, section.table_name(), &rendered);
|
owner_contents = upsert_toml_table(&owner_contents, section.table_name(), &rendered);
|
||||||
@@ -143,7 +161,14 @@ pub(in crate::api) async fn save_access_sections_to_disk(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let revision = compute_snapshot_revision(&candidate);
|
let revision = compute_snapshot_revision(&candidate);
|
||||||
write_atomic(owner_path, owner_contents).await?;
|
write_atomic_if_unchanged(
|
||||||
|
config_path.to_path_buf(),
|
||||||
|
loaded_revision,
|
||||||
|
owner_path,
|
||||||
|
expected_owner_contents,
|
||||||
|
owner_contents,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(revision)
|
Ok(revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,15 +403,112 @@ pub(in crate::api) async fn write_atomic(
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
contents: String,
|
contents: String,
|
||||||
) -> Result<(), ApiFailure> {
|
) -> Result<(), ApiFailure> {
|
||||||
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
|
tokio::task::spawn_blocking(move || write_atomic_sync(&path, None, &contents))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
/// 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,
|
||||||
|
"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("."));
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
std::fs::create_dir_all(parent)?;
|
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!(
|
let tmp_name = format!(
|
||||||
".{}.tmp-{}",
|
".{}.tmp-{}",
|
||||||
@@ -401,9 +523,40 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
|||||||
let mut file = std::fs::OpenOptions::new()
|
let mut file = std::fs::OpenOptions::new()
|
||||||
.create_new(true)
|
.create_new(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
|
#[cfg(unix)]
|
||||||
|
.mode(0o600)
|
||||||
.open(&tmp_path)?;
|
.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.write_all(contents.as_bytes())?;
|
||||||
file.sync_all()?;
|
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)?;
|
std::fs::rename(&tmp_path, path)?;
|
||||||
if let Ok(dir) = std::fs::File::open(parent) {
|
if let Ok(dir) = std::fs::File::open(parent) {
|
||||||
let _ = dir.sync_all();
|
let _ = dir.sync_all();
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ pub(super) async fn build_runtime_gates_data(
|
|||||||
&& cfg.general.me2dc_fallback
|
&& cfg.general.me2dc_fallback
|
||||||
&& matches!(route_state.mode, RelayRouteMode::Direct);
|
&& matches!(route_state.mode, RelayRouteMode::Direct);
|
||||||
let reroute_to_direct_at_epoch_secs = if reroute_active {
|
let reroute_to_direct_at_epoch_secs = if reroute_active {
|
||||||
shared.route_runtime.direct_since_epoch_secs()
|
route_state.direct_since_epoch_secs
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
+127
-13
@@ -1,6 +1,6 @@
|
|||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{ErrorKind, Read, Write};
|
use std::io::{ErrorKind, Read, Write};
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use nix::fcntl::{Flock, FlockArg};
|
use nix::fcntl::{Flock, FlockArg};
|
||||||
@@ -14,9 +14,25 @@ pub struct PidFile {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
lock_path: PathBuf,
|
lock_path: PathBuf,
|
||||||
pid_file: Option<File>,
|
pid_file: Option<File>,
|
||||||
|
pid_identity: Option<FileIdentity>,
|
||||||
lock_file: Option<Flock<File>>,
|
lock_file: Option<Flock<File>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct FileIdentity {
|
||||||
|
device: u64,
|
||||||
|
inode: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileIdentity {
|
||||||
|
fn from_metadata(metadata: &fs::Metadata) -> Self {
|
||||||
|
Self {
|
||||||
|
device: metadata.dev(),
|
||||||
|
inode: metadata.ino(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl PidFile {
|
impl PidFile {
|
||||||
/// Creates a new PID file manager for the given path.
|
/// Creates a new PID file manager for the given path.
|
||||||
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
||||||
@@ -26,6 +42,7 @@ impl PidFile {
|
|||||||
path,
|
path,
|
||||||
lock_path,
|
lock_path,
|
||||||
pid_file: None,
|
pid_file: None,
|
||||||
|
pid_identity: None,
|
||||||
lock_file: None,
|
lock_file: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +77,7 @@ impl PidFile {
|
|||||||
.create(true)
|
.create(true)
|
||||||
.truncate(false)
|
.truncate(false)
|
||||||
.mode(0o644)
|
.mode(0o644)
|
||||||
|
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
|
||||||
.open(&self.lock_path)
|
.open(&self.lock_path)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
DaemonError::PidFile(format!(
|
DaemonError::PidFile(format!(
|
||||||
@@ -68,6 +86,7 @@ impl PidFile {
|
|||||||
error
|
error
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
validate_regular_single_link(&lock_file, &self.lock_path)?;
|
||||||
let lock_file =
|
let lock_file =
|
||||||
Flock::lock(lock_file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
Flock::lock(lock_file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
||||||
if let Some(pid) = self.check_running().ok().flatten() {
|
if let Some(pid) = self.check_running().ok().flatten() {
|
||||||
@@ -91,10 +110,13 @@ impl PidFile {
|
|||||||
.create(true)
|
.create(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.mode(0o644)
|
.mode(0o644)
|
||||||
|
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
|
||||||
.open(&self.path)
|
.open(&self.path)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), error))
|
DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), error))
|
||||||
})?;
|
})?;
|
||||||
|
let pid_metadata = validate_regular_single_link(&pid_file, &self.path)?;
|
||||||
|
let pid_identity = FileIdentity::from_metadata(&pid_metadata);
|
||||||
let pid = getpid();
|
let pid = getpid();
|
||||||
writeln!(pid_file, "{}", pid).map_err(|error| {
|
writeln!(pid_file, "{}", pid).map_err(|error| {
|
||||||
DaemonError::PidFile(format!(
|
DaemonError::PidFile(format!(
|
||||||
@@ -103,8 +125,16 @@ impl PidFile {
|
|||||||
error
|
error
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
pid_file.sync_data().map_err(|error| {
|
||||||
|
DaemonError::PidFile(format!(
|
||||||
|
"cannot sync PID file {}: {}",
|
||||||
|
self.path.display(),
|
||||||
|
error
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
self.pid_file = Some(pid_file);
|
self.pid_file = Some(pid_file);
|
||||||
|
self.pid_identity = Some(pid_identity);
|
||||||
self.lock_file = Some(lock_file);
|
self.lock_file = Some(lock_file);
|
||||||
info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created");
|
info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -114,19 +144,36 @@ impl PidFile {
|
|||||||
pub fn release(&mut self) -> Result<(), DaemonError> {
|
pub fn release(&mut self) -> Result<(), DaemonError> {
|
||||||
if self.lock_file.is_none() {
|
if self.lock_file.is_none() {
|
||||||
self.pid_file = None;
|
self.pid_file = None;
|
||||||
|
self.pid_identity = None;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let removal = match fs::remove_file(&self.path) {
|
let removal = match fs::symlink_metadata(&self.path) {
|
||||||
Ok(()) => Ok(()),
|
Ok(metadata)
|
||||||
|
if self.pid_identity == Some(FileIdentity::from_metadata(&metadata))
|
||||||
|
&& metadata.is_file() =>
|
||||||
|
{
|
||||||
|
fs::remove_file(&self.path).map_err(|error| {
|
||||||
|
DaemonError::PidFile(format!(
|
||||||
|
"cannot remove {}: {}",
|
||||||
|
self.path.display(),
|
||||||
|
error
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(_) => Err(DaemonError::PidFile(format!(
|
||||||
|
"refusing to remove replaced PID file {}",
|
||||||
|
self.path.display()
|
||||||
|
))),
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
Err(error) => Err(DaemonError::PidFile(format!(
|
Err(error) => Err(DaemonError::PidFile(format!(
|
||||||
"cannot remove {}: {}",
|
"cannot inspect {} before removal: {}",
|
||||||
self.path.display(),
|
self.path.display(),
|
||||||
error
|
error
|
||||||
))),
|
))),
|
||||||
};
|
};
|
||||||
self.pid_file = None;
|
self.pid_file = None;
|
||||||
|
self.pid_identity = None;
|
||||||
self.lock_file = None;
|
self.lock_file = None;
|
||||||
removal?;
|
removal?;
|
||||||
debug!(path = %self.path.display(), "PID file removed");
|
debug!(path = %self.path.display(), "PID file removed");
|
||||||
@@ -161,7 +208,11 @@ fn sibling_lock_path(path: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn read_pid_file_if_exists(path: &Path) -> Result<Option<i32>, DaemonError> {
|
fn read_pid_file_if_exists(path: &Path) -> Result<Option<i32>, DaemonError> {
|
||||||
let mut file = match File::open(path) {
|
let mut file = match OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
|
||||||
|
.open(path)
|
||||||
|
{
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -172,17 +223,46 @@ fn read_pid_file_if_exists(path: &Path) -> Result<Option<i32>, DaemonError> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let metadata = validate_regular_single_link(&file, path)?;
|
||||||
|
if metadata.len() > 64 {
|
||||||
|
return Err(DaemonError::PidFile(format!(
|
||||||
|
"invalid PID in {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
let mut contents = String::new();
|
let mut contents = String::new();
|
||||||
file.read_to_string(&mut contents).map_err(|error| {
|
file.read_to_string(&mut contents).map_err(|error| {
|
||||||
DaemonError::PidFile(format!("cannot read {}: {}", path.display(), error))
|
DaemonError::PidFile(format!("cannot read {}: {}", path.display(), error))
|
||||||
})?;
|
})?;
|
||||||
let pid = contents
|
let pid: i32 = contents
|
||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display())))?;
|
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display())))?;
|
||||||
|
if pid <= 1 {
|
||||||
|
return Err(DaemonError::PidFile(format!(
|
||||||
|
"invalid PID in {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
Ok(Some(pid))
|
Ok(Some(pid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_regular_single_link(
|
||||||
|
file: &File,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<fs::Metadata, DaemonError> {
|
||||||
|
let metadata = file.metadata().map_err(|error| {
|
||||||
|
DaemonError::PidFile(format!("cannot inspect {}: {}", path.display(), error))
|
||||||
|
})?;
|
||||||
|
if !metadata.is_file() || metadata.nlink() != 1 {
|
||||||
|
return Err(DaemonError::PidFile(format!(
|
||||||
|
"{} must be a regular file with one directory entry",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads a PID from a PID file.
|
/// Reads a PID from a PID file.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn read_pid_file<P: AsRef<Path>>(path: P) -> Result<i32, DaemonError> {
|
pub fn read_pid_file<P: AsRef<Path>>(path: P) -> Result<i32, DaemonError> {
|
||||||
@@ -202,13 +282,6 @@ pub fn signal_pid_file<P: AsRef<Path>>(
|
|||||||
signal: nix::sys::signal::Signal,
|
signal: nix::sys::signal::Signal,
|
||||||
) -> Result<(), DaemonError> {
|
) -> Result<(), DaemonError> {
|
||||||
let pid = read_pid_file(&path)?;
|
let pid = read_pid_file(&path)?;
|
||||||
if !is_process_running(pid) {
|
|
||||||
return Err(DaemonError::PidFile(format!(
|
|
||||||
"process {} from {} is not running",
|
|
||||||
pid,
|
|
||||||
path.as_ref().display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
nix::sys::signal::kill(Pid::from_raw(pid), signal)
|
nix::sys::signal::kill(Pid::from_raw(pid), signal)
|
||||||
.map_err(|error| DaemonError::PidFile(format!("cannot signal process {}: {}", pid, error)))
|
.map_err(|error| DaemonError::PidFile(format!("cannot signal process {}: {}", pid, error)))
|
||||||
}
|
}
|
||||||
@@ -243,6 +316,7 @@ fn is_process_running(pid: i32) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -367,6 +441,46 @@ mod tests {
|
|||||||
assert!(pid_path.exists());
|
assert!(pid_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquire_rejects_pid_symlink_without_truncating_target() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let pid_path = directory.path().join("telemt.pid");
|
||||||
|
let target_path = directory.path().join("target");
|
||||||
|
fs::write(&target_path, b"preserve\n").unwrap();
|
||||||
|
symlink(&target_path, &pid_path).unwrap();
|
||||||
|
let mut pid_file = PidFile::new(&pid_path);
|
||||||
|
|
||||||
|
assert!(pid_file.acquire().is_err());
|
||||||
|
assert_eq!(fs::read(&target_path).unwrap(), b"preserve\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_does_not_remove_replacement_path() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let pid_path = directory.path().join("telemt.pid");
|
||||||
|
let owned_path = directory.path().join("owned.pid");
|
||||||
|
let mut pid_file = PidFile::new(&pid_path);
|
||||||
|
pid_file.acquire().unwrap();
|
||||||
|
fs::rename(&pid_path, &owned_path).unwrap();
|
||||||
|
fs::write(&pid_path, b"replacement\n").unwrap();
|
||||||
|
|
||||||
|
let error = pid_file.release().unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("refusing to remove replaced PID file"));
|
||||||
|
assert_eq!(fs::read(&pid_path).unwrap(), b"replacement\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pid_parser_rejects_process_group_values() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let pid_path = directory.path().join("telemt.pid");
|
||||||
|
|
||||||
|
for value in ["-1\n", "0\n", "1\n"] {
|
||||||
|
fs::write(&pid_path, value).unwrap();
|
||||||
|
assert!(read_pid_file(&pid_path).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pid_file_release_keeps_lock_inode() {
|
fn pid_file_release_keeps_lock_inode() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::io::{Error as IoError, ErrorKind};
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::{FileTypeExt, MetadataExt};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use socket2::Socket;
|
use socket2::Socket;
|
||||||
@@ -187,6 +195,61 @@ fn print_configured_links(
|
|||||||
print_proxy_links(host, port, config);
|
print_proxy_links(host, port, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn unix_path_identity(metadata: &std::fs::Metadata) -> (u64, u64) {
|
||||||
|
(metadata.dev(), metadata.ino())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn remove_stale_unix_socket(path: &Path) -> std::io::Result<()> {
|
||||||
|
let metadata = match std::fs::symlink_metadata(path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
if !metadata.file_type().is_socket() {
|
||||||
|
return Err(IoError::new(
|
||||||
|
ErrorKind::AlreadyExists,
|
||||||
|
format!(
|
||||||
|
"refusing to remove non-socket Unix listener path {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match StdUnixStream::connect(path) {
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(IoError::new(
|
||||||
|
ErrorKind::AddrInUse,
|
||||||
|
format!("Unix listener {} is already active", path.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == ErrorKind::ConnectionRefused => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
let current = std::fs::symlink_metadata(path)?;
|
||||||
|
if !current.file_type().is_socket()
|
||||||
|
|| unix_path_identity(¤t) != unix_path_identity(&metadata)
|
||||||
|
{
|
||||||
|
return Err(IoError::new(
|
||||||
|
ErrorKind::AlreadyExists,
|
||||||
|
format!("Unix listener path {} changed during cleanup", path.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::fs::remove_file(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn verify_bound_unix_socket(path: &Path, expected: (u64, u64)) -> std::io::Result<()> {
|
||||||
|
let metadata = std::fs::symlink_metadata(path)?;
|
||||||
|
if metadata.file_type().is_socket() && unix_path_identity(&metadata) == expected {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(IoError::new(
|
||||||
|
ErrorKind::AlreadyExists,
|
||||||
|
format!("Unix listener path {} was replaced", path.display()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Binds every eligible configured listener or fails without a partial inventory.
|
/// Binds every eligible configured listener or fails without a partial inventory.
|
||||||
pub(crate) async fn bind_listeners(
|
pub(crate) async fn bind_listeners(
|
||||||
config: &Arc<ProxyConfig>,
|
config: &Arc<ProxyConfig>,
|
||||||
@@ -217,27 +280,39 @@ pub(crate) async fn bind_listeners(
|
|||||||
let mut unix_listener_out = None;
|
let mut unix_listener_out = None;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
if let Some(unix_path) = &config.server.listen_unix_sock {
|
if let Some(unix_path) = &config.server.listen_unix_sock {
|
||||||
let _ = tokio::fs::remove_file(unix_path).await;
|
let unix_path = Path::new(unix_path);
|
||||||
|
remove_stale_unix_socket(unix_path)?;
|
||||||
let unix_listener = UnixListener::bind(unix_path)?;
|
let unix_listener = UnixListener::bind(unix_path)?;
|
||||||
|
let socket_metadata = std::fs::symlink_metadata(unix_path)?;
|
||||||
|
if !socket_metadata.file_type().is_socket() {
|
||||||
|
return Err(IoError::new(
|
||||||
|
ErrorKind::AlreadyExists,
|
||||||
|
format!("Unix listener path {} was replaced", unix_path.display()),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let socket_identity = unix_path_identity(&socket_metadata);
|
||||||
if let Some(perm_str) = &config.server.listen_unix_sock_perm {
|
if let Some(perm_str) = &config.server.listen_unix_sock_perm {
|
||||||
match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) {
|
match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) {
|
||||||
Ok(mode) => {
|
Ok(mode) => {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let permissions = std::fs::Permissions::from_mode(mode);
|
let permissions = std::fs::Permissions::from_mode(mode);
|
||||||
|
verify_bound_unix_socket(unix_path, socket_identity)?;
|
||||||
if let Err(error_value) = std::fs::set_permissions(unix_path, permissions) {
|
if let Err(error_value) = std::fs::set_permissions(unix_path, permissions) {
|
||||||
error!(
|
error!(
|
||||||
path = %unix_path,
|
path = %unix_path.display(),
|
||||||
permissions = %perm_str,
|
permissions = %perm_str,
|
||||||
error = %error_value,
|
error = %error_value,
|
||||||
"Failed to set Unix socket permissions"
|
"Failed to set Unix socket permissions"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
info!(path = %unix_path, permissions = %perm_str, "Listening on Unix socket");
|
verify_bound_unix_socket(unix_path, socket_identity)?;
|
||||||
|
info!(path = %unix_path.display(), permissions = %perm_str, "Listening on Unix socket");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error_value) => {
|
Err(error_value) => {
|
||||||
warn!(
|
warn!(
|
||||||
path = %unix_path,
|
path = %unix_path.display(),
|
||||||
permissions = %perm_str,
|
permissions = %perm_str,
|
||||||
error = %error_value,
|
error = %error_value,
|
||||||
"Invalid Unix socket permissions; keeping umask-derived mode"
|
"Invalid Unix socket permissions; keeping umask-derived mode"
|
||||||
@@ -245,7 +320,7 @@ pub(crate) async fn bind_listeners(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
info!(path = %unix_path, "Listening on Unix socket");
|
info!(path = %unix_path.display(), "Listening on Unix socket");
|
||||||
}
|
}
|
||||||
unix_listener_out = Some(unix_listener);
|
unix_listener_out = Some(unix_listener);
|
||||||
}
|
}
|
||||||
@@ -271,3 +346,49 @@ pub(crate) async fn bind_listeners(
|
|||||||
unix_listener: unix_listener_out,
|
unix_listener: unix_listener_out,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod tests {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
use std::os::unix::net::UnixListener as StdUnixListener;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_socket_cleanup_refuses_regular_file_and_symlink() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let regular = directory.path().join("regular");
|
||||||
|
let link = directory.path().join("listener.sock");
|
||||||
|
std::fs::write(®ular, b"preserve").unwrap();
|
||||||
|
symlink(®ular, &link).unwrap();
|
||||||
|
|
||||||
|
assert!(remove_stale_unix_socket(®ular).is_err());
|
||||||
|
assert!(remove_stale_unix_socket(&link).is_err());
|
||||||
|
assert_eq!(std::fs::read(®ular).unwrap(), b"preserve");
|
||||||
|
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_socket_cleanup_removes_only_stale_socket() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().join("listener.sock");
|
||||||
|
let listener = StdUnixListener::bind(&path).unwrap();
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
remove_stale_unix_socket(&path).unwrap();
|
||||||
|
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_socket_cleanup_preserves_live_listener() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().join("listener.sock");
|
||||||
|
let _listener = StdUnixListener::bind(&path).unwrap();
|
||||||
|
|
||||||
|
let error = remove_stale_unix_socket(&path).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.kind(), ErrorKind::AddrInUse);
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
-22
@@ -1,5 +1,4 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
@@ -24,11 +23,11 @@ impl RelayRouteMode {
|
|||||||
pub(crate) struct RouteCutoverState {
|
pub(crate) struct RouteCutoverState {
|
||||||
pub mode: RelayRouteMode,
|
pub mode: RelayRouteMode,
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
|
pub direct_since_epoch_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct RouteRuntimeController {
|
pub(crate) struct RouteRuntimeController {
|
||||||
direct_since_epoch_secs: Arc<AtomicU64>,
|
|
||||||
tx: watch::Sender<RouteCutoverState>,
|
tx: watch::Sender<RouteCutoverState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,17 +36,11 @@ impl RouteRuntimeController {
|
|||||||
let initial = RouteCutoverState {
|
let initial = RouteCutoverState {
|
||||||
mode: initial_mode,
|
mode: initial_mode,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
direct_since_epoch_secs: matches!(initial_mode, RelayRouteMode::Direct)
|
||||||
|
.then(now_epoch_secs),
|
||||||
};
|
};
|
||||||
let (tx, _rx) = watch::channel(initial);
|
let (tx, _rx) = watch::channel(initial);
|
||||||
let direct_since_epoch_secs = if matches!(initial_mode, RelayRouteMode::Direct) {
|
Self { tx }
|
||||||
now_epoch_secs()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
direct_since_epoch_secs: Arc::new(AtomicU64::new(direct_since_epoch_secs)),
|
|
||||||
tx,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn snapshot(&self) -> RouteCutoverState {
|
pub(crate) fn snapshot(&self) -> RouteCutoverState {
|
||||||
@@ -58,25 +51,16 @@ impl RouteRuntimeController {
|
|||||||
self.tx.subscribe()
|
self.tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn direct_since_epoch_secs(&self) -> Option<u64> {
|
|
||||||
let value = self.direct_since_epoch_secs.load(Ordering::Relaxed);
|
|
||||||
(value > 0).then_some(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_mode(&self, mode: RelayRouteMode) -> Option<RouteCutoverState> {
|
pub(crate) fn set_mode(&self, mode: RelayRouteMode) -> Option<RouteCutoverState> {
|
||||||
let mut next = None;
|
let mut next = None;
|
||||||
let changed = self.tx.send_if_modified(|state| {
|
let changed = self.tx.send_if_modified(|state| {
|
||||||
if state.mode == mode {
|
if state.mode == mode {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if matches!(mode, RelayRouteMode::Direct) {
|
|
||||||
self.direct_since_epoch_secs
|
|
||||||
.store(now_epoch_secs(), Ordering::Relaxed);
|
|
||||||
} else {
|
|
||||||
self.direct_since_epoch_secs.store(0, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
state.mode = mode;
|
state.mode = mode;
|
||||||
state.generation = state.generation.saturating_add(1);
|
state.generation = state.generation.saturating_add(1);
|
||||||
|
state.direct_since_epoch_secs =
|
||||||
|
matches!(mode, RelayRouteMode::Direct).then(now_epoch_secs);
|
||||||
next = Some(*state);
|
next = Some(*state);
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ fn positive_direct_cutover_sets_timestamp_and_snapshot_coherently() {
|
|||||||
let rx = runtime.subscribe();
|
let rx = runtime.subscribe();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_none(),
|
runtime.snapshot().direct_since_epoch_secs.is_none(),
|
||||||
"middle startup must not expose direct-since timestamp"
|
"middle startup must not expose direct-since timestamp"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ fn positive_direct_cutover_sets_timestamp_and_snapshot_coherently() {
|
|||||||
);
|
);
|
||||||
assert_eq!(observed.mode, RelayRouteMode::Direct);
|
assert_eq!(observed.mode, RelayRouteMode::Direct);
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_some(),
|
observed.direct_since_epoch_secs.is_some(),
|
||||||
"direct cutover must publish a non-empty direct-since timestamp"
|
"direct cutover must publish a non-empty direct-since timestamp"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,12 +34,12 @@ fn negative_idempotent_set_mode_does_not_mutate_timestamp_or_generation() {
|
|||||||
let runtime = RouteRuntimeController::new(RelayRouteMode::Direct);
|
let runtime = RouteRuntimeController::new(RelayRouteMode::Direct);
|
||||||
|
|
||||||
let before_state = runtime.snapshot();
|
let before_state = runtime.snapshot();
|
||||||
let before_ts = runtime.direct_since_epoch_secs();
|
let before_ts = before_state.direct_since_epoch_secs;
|
||||||
|
|
||||||
let changed = runtime.set_mode(RelayRouteMode::Direct);
|
let changed = runtime.set_mode(RelayRouteMode::Direct);
|
||||||
|
|
||||||
let after_state = runtime.snapshot();
|
let after_state = runtime.snapshot();
|
||||||
let after_ts = runtime.direct_since_epoch_secs();
|
let after_ts = after_state.direct_since_epoch_secs;
|
||||||
|
|
||||||
assert!(changed.is_none(), "idempotent set_mode must return None");
|
assert!(changed.is_none(), "idempotent set_mode must return None");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -58,7 +58,7 @@ fn edge_middle_cutover_clears_timestamp() {
|
|||||||
let rx = runtime.subscribe();
|
let rx = runtime.subscribe();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_some(),
|
runtime.snapshot().direct_since_epoch_secs.is_some(),
|
||||||
"direct startup must expose direct-since timestamp"
|
"direct startup must expose direct-since timestamp"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ fn edge_middle_cutover_clears_timestamp() {
|
|||||||
);
|
);
|
||||||
assert_eq!(observed.mode, RelayRouteMode::Middle);
|
assert_eq!(observed.mode, RelayRouteMode::Middle);
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_none(),
|
observed.direct_since_epoch_secs.is_none(),
|
||||||
"middle cutover must clear direct-since timestamp"
|
"middle cutover must clear direct-since timestamp"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ fn adversarial_blackhat_probe_sequence_observes_consistent_mode_timestamp_pairs(
|
|||||||
let observed_direct = *rx.borrow();
|
let observed_direct = *rx.borrow();
|
||||||
assert_eq!(observed_direct, emitted_direct);
|
assert_eq!(observed_direct, emitted_direct);
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_some(),
|
observed_direct.direct_since_epoch_secs.is_some(),
|
||||||
"direct observation must never expose empty timestamp"
|
"direct observation must never expose empty timestamp"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ fn adversarial_blackhat_probe_sequence_observes_consistent_mode_timestamp_pairs(
|
|||||||
let observed_middle = *rx.borrow();
|
let observed_middle = *rx.borrow();
|
||||||
assert_eq!(observed_middle, emitted_middle);
|
assert_eq!(observed_middle, emitted_middle);
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_none(),
|
observed_middle.direct_since_epoch_secs.is_none(),
|
||||||
"middle observation must never expose direct timestamp"
|
"middle observation must never expose direct timestamp"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,9 +136,9 @@ fn integration_subscriber_and_runtime_gates_stay_coherent_across_cutovers() {
|
|||||||
assert_eq!(snapshot, emitted);
|
assert_eq!(snapshot, emitted);
|
||||||
|
|
||||||
if matches!(mode, RelayRouteMode::Direct) {
|
if matches!(mode, RelayRouteMode::Direct) {
|
||||||
assert!(runtime.direct_since_epoch_secs().is_some());
|
assert!(snapshot.direct_since_epoch_secs.is_some());
|
||||||
} else {
|
} else {
|
||||||
assert!(runtime.direct_since_epoch_secs().is_none());
|
assert!(snapshot.direct_since_epoch_secs.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,12 +176,12 @@ fn light_fuzz_random_mode_plan_preserves_timestamp_and_generation_invariants() {
|
|||||||
|
|
||||||
if matches!(snapshot.mode, RelayRouteMode::Direct) {
|
if matches!(snapshot.mode, RelayRouteMode::Direct) {
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_some(),
|
snapshot.direct_since_epoch_secs.is_some(),
|
||||||
"direct fuzz state must expose timestamp"
|
"direct fuzz state must expose timestamp"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
assert!(
|
assert!(
|
||||||
runtime.direct_since_epoch_secs().is_none(),
|
snapshot.direct_since_epoch_secs.is_none(),
|
||||||
"middle fuzz state must clear timestamp"
|
"middle fuzz state must clear timestamp"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,12 +199,17 @@ fn stress_parallel_subscribers_never_observe_generation_regression() {
|
|||||||
let rx = runtime.subscribe();
|
let rx = runtime.subscribe();
|
||||||
let mut last = rx.borrow().generation;
|
let mut last = rx.borrow().generation;
|
||||||
for _ in 0..10_000usize {
|
for _ in 0..10_000usize {
|
||||||
let current = rx.borrow().generation;
|
let current = *rx.borrow();
|
||||||
assert!(
|
assert!(
|
||||||
current >= last,
|
current.generation >= last,
|
||||||
"watch generation must be monotonic for every subscriber"
|
"watch generation must be monotonic for every subscriber"
|
||||||
);
|
);
|
||||||
last = current;
|
assert_eq!(
|
||||||
|
matches!(current.mode, RelayRouteMode::Direct),
|
||||||
|
current.direct_since_epoch_secs.is_some(),
|
||||||
|
"one route snapshot must contain a coherent mode/timestamp pair"
|
||||||
|
);
|
||||||
|
last = current.generation;
|
||||||
std::thread::yield_now();
|
std::thread::yield_now();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -227,8 +232,8 @@ fn stress_parallel_subscribers_never_observe_generation_regression() {
|
|||||||
|
|
||||||
let final_state = runtime.snapshot();
|
let final_state = runtime.snapshot();
|
||||||
if matches!(final_state.mode, RelayRouteMode::Direct) {
|
if matches!(final_state.mode, RelayRouteMode::Direct) {
|
||||||
assert!(runtime.direct_since_epoch_secs().is_some());
|
assert!(final_state.direct_since_epoch_secs.is_some());
|
||||||
} else {
|
} else {
|
||||||
assert!(runtime.direct_since_epoch_secs().is_none());
|
assert!(final_state.direct_since_epoch_secs.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ fn session_is_not_affected_when_mode_matches_even_if_generation_advanced() {
|
|||||||
let current = RouteCutoverState {
|
let current = RouteCutoverState {
|
||||||
mode: RelayRouteMode::Direct,
|
mode: RelayRouteMode::Direct,
|
||||||
generation: 2,
|
generation: 2,
|
||||||
|
direct_since_epoch_secs: Some(1),
|
||||||
};
|
};
|
||||||
let session_generation = 0;
|
let session_generation = 0;
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ fn cutover_predicate_rejects_equal_generation_even_if_mode_differs() {
|
|||||||
let current = RouteCutoverState {
|
let current = RouteCutoverState {
|
||||||
mode: RelayRouteMode::Middle,
|
mode: RelayRouteMode::Middle,
|
||||||
generation: 77,
|
generation: 77,
|
||||||
|
direct_since_epoch_secs: None,
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
!is_session_affected_by_cutover(current, RelayRouteMode::Direct, 77),
|
!is_session_affected_by_cutover(current, RelayRouteMode::Direct, 77),
|
||||||
@@ -168,13 +170,15 @@ fn adversarial_route_oscillation_only_cuts_over_sessions_with_different_final_mo
|
|||||||
fn light_fuzz_cutover_predicate_matches_reference_oracle() {
|
fn light_fuzz_cutover_predicate_matches_reference_oracle() {
|
||||||
let mut rng = StdRng::seed_from_u64(0xC0DEC0DE5EED);
|
let mut rng = StdRng::seed_from_u64(0xC0DEC0DE5EED);
|
||||||
for _ in 0..20_000 {
|
for _ in 0..20_000 {
|
||||||
let current = RouteCutoverState {
|
let mode = if rng.random::<bool>() {
|
||||||
mode: if rng.random::<bool>() {
|
|
||||||
RelayRouteMode::Direct
|
RelayRouteMode::Direct
|
||||||
} else {
|
} else {
|
||||||
RelayRouteMode::Middle
|
RelayRouteMode::Middle
|
||||||
},
|
};
|
||||||
|
let current = RouteCutoverState {
|
||||||
|
mode,
|
||||||
generation: rng.random_range(0u64..1_000_000),
|
generation: rng.random_range(0u64..1_000_000),
|
||||||
|
direct_since_epoch_secs: matches!(mode, RelayRouteMode::Direct).then_some(1),
|
||||||
};
|
};
|
||||||
let session_mode = if rng.random::<bool>() {
|
let session_mode = if rng.random::<bool>() {
|
||||||
RelayRouteMode::Direct
|
RelayRouteMode::Direct
|
||||||
|
|||||||
+31
-2
@@ -17,8 +17,8 @@ use crate::config::{
|
|||||||
use crate::maestro::generation::test_runtime_generation;
|
use crate::maestro::generation::test_runtime_generation;
|
||||||
use crate::web::frame::{self, FrameType};
|
use crate::web::frame::{self, FrameType};
|
||||||
use crate::web::manager::{
|
use crate::web::manager::{
|
||||||
CloseOperationSelector, ControlError, SessionDetail, SessionFilter, SessionListRequest,
|
CloseOperationSelector, ControlError, ManagerError, SessionDetail, SessionFilter,
|
||||||
SessionRefError, WebProcessRuntime,
|
SessionListRequest, SessionRefError, WebProcessRuntime,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[path = "legacy_tests.rs"]
|
#[path = "legacy_tests.rs"]
|
||||||
@@ -433,6 +433,35 @@ async fn bridge_bootstrap_uses_the_generation_that_selected_its_profile() {
|
|||||||
replacement.stop_background_tasks().await;
|
replacement.stop_background_tasks().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_generation_cannot_publish_bootstrap_after_disabled_cutover() {
|
||||||
|
let capability = [23u8; 32];
|
||||||
|
let initial = test_runtime_generation(1, runtime_config(capability, WebCarrier::Https));
|
||||||
|
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&initial)));
|
||||||
|
let runtime = WebProcessRuntime::start(active_runtime);
|
||||||
|
let profile = initial.config().web.runtime.as_ref().unwrap().profiles[0].clone();
|
||||||
|
let mut disabled_config = runtime_config(capability, WebCarrier::Https);
|
||||||
|
disabled_config.web.enabled = false;
|
||||||
|
let disabled = test_runtime_generation(2, disabled_config);
|
||||||
|
|
||||||
|
runtime.activate_generation(Arc::clone(&disabled));
|
||||||
|
let result = runtime.issue_bootstrap_for_generation(
|
||||||
|
&initial,
|
||||||
|
profile,
|
||||||
|
"192.0.2.10".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(ManagerError::Closed)));
|
||||||
|
let status = serde_json::to_value(runtime.try_status()).unwrap();
|
||||||
|
assert_eq!(status["manager"]["bootstraps"], 0);
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
initial.stop_sessions().await;
|
||||||
|
initial.stop_background_tasks().await;
|
||||||
|
disabled.stop_sessions().await;
|
||||||
|
disabled.stop_background_tasks().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unused_bootstrap_is_rejected_after_profile_identity_change() {
|
async fn unused_bootstrap_is_rejected_after_profile_identity_change() {
|
||||||
let capability = [11u8; 32];
|
let capability = [11u8; 32];
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl WebProcessRuntime {
|
|||||||
self.record_limit_hit();
|
self.record_limit_hit();
|
||||||
self.telemetry
|
self.telemetry
|
||||||
.record_rejection(WebRejectionReason::RuntimeClosed);
|
.record_rejection(WebRejectionReason::RuntimeClosed);
|
||||||
return Err(ManagerError::Limit);
|
return Err(ManagerError::Closed);
|
||||||
}
|
}
|
||||||
if state
|
if state
|
||||||
.bootstraps_per_ip
|
.bootstraps_per_ip
|
||||||
|
|||||||
Reference in New Issue
Block a user