Direct-since-epoch

This commit is contained in:
Alexey
2026-09-15 20:37:41 +03:00
parent 5ec9f85530
commit 55f3d19ee0
9 changed files with 483 additions and 73 deletions
+160 -7
View File
@@ -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, &current) {
(Some(expected), Some(current)) => {
same_target(&expected.metadata, &current.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();
+1 -1
View File
@@ -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
View File
@@ -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();
+126 -5
View File
@@ -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(&current) != 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(&regular, b"preserve").unwrap();
symlink(&regular, &link).unwrap();
assert!(remove_stale_unix_socket(&regular).is_err());
assert!(remove_stale_unix_socket(&link).is_err());
assert_eq!(std::fs::read(&regular).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
View File
@@ -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());
} }
} }
+9 -5
View File
@@ -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 mode = if rng.random::<bool>() {
RelayRouteMode::Direct
} else {
RelayRouteMode::Middle
};
let current = RouteCutoverState { let current = RouteCutoverState {
mode: if rng.random::<bool>() { mode,
RelayRouteMode::Direct
} else {
RelayRouteMode::Middle
},
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
View File
@@ -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];
+1 -1
View File
@@ -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