mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 05:12:02 +03:00
Rustfmt
This commit is contained in:
@@ -80,3 +80,6 @@ mod conveyor_tests;
|
||||
// Host groups are validated and published as one complete immutable snapshot.
|
||||
#[cfg(test)]
|
||||
mod host_list_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod quota_period_tests;
|
||||
|
||||
@@ -84,6 +84,8 @@ pub struct HotFields {
|
||||
pub user_max_tcp_conns_global_each: usize,
|
||||
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
|
||||
pub user_data_quota: std::collections::HashMap<String, u64>,
|
||||
/// Recurring UTC calendars accepted by the active runtime source.
|
||||
pub user_quota_periods: std::collections::HashMap<String, crate::config::UserQuotaPeriod>,
|
||||
pub user_rate_limits: std::collections::HashMap<String, crate::config::RateLimitBps>,
|
||||
pub cidr_rate_limits: std::collections::HashMap<CidrRateLimitKey, crate::config::RateLimitBps>,
|
||||
pub user_max_unique_ips: std::collections::HashMap<String, usize>,
|
||||
@@ -215,6 +217,7 @@ impl HotFields {
|
||||
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
|
||||
user_expirations: cfg.access.user_expirations.clone(),
|
||||
user_data_quota: cfg.access.user_data_quota.clone(),
|
||||
user_quota_periods: cfg.access.user_quota_periods.clone(),
|
||||
user_rate_limits: cfg.access.user_rate_limits.clone(),
|
||||
cidr_rate_limits: cfg.access.cidr_rate_limits.clone(),
|
||||
user_max_unique_ips: cfg.access.user_max_unique_ips.clone(),
|
||||
@@ -228,6 +231,7 @@ impl HotFields {
|
||||
|
||||
pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
||||
let mut cfg = old.clone();
|
||||
cfg.quota_source_read_at = new.quota_source_read_at;
|
||||
|
||||
cfg.general.log_level = new.general.log_level.clone();
|
||||
cfg.general.ad_tag = new.general.ad_tag.clone();
|
||||
@@ -336,6 +340,7 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC
|
||||
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
|
||||
cfg.access.user_expirations = new.access.user_expirations.clone();
|
||||
cfg.access.user_data_quota = new.access.user_data_quota.clone();
|
||||
cfg.access.user_quota_periods = new.access.user_quota_periods.clone();
|
||||
cfg.access.user_rate_limits = new.access.user_rate_limits.clone();
|
||||
cfg.access.cidr_rate_limits = new.access.cidr_rate_limits.clone();
|
||||
cfg.access.user_max_unique_ips = new.access.user_max_unique_ips.clone();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quota_period_reload_is_hot_and_invalid_candidates_preserve_the_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let source = "[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n[access.user_data_quota]\nalice = 1024\n";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
|
||||
let (config_tx, _config_rx) = watch::channel(initial.clone());
|
||||
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
|
||||
let mut state = ReloadState::new(None);
|
||||
for (period, valid) in [
|
||||
(Some("daily"), true),
|
||||
(Some("monthly"), true),
|
||||
(Some("yearly"), false),
|
||||
(None, true),
|
||||
] {
|
||||
let suffix = period
|
||||
.map(|p| format!("[access.user_quota_periods]\nalice = '{p}'\n"))
|
||||
.unwrap_or_default();
|
||||
std::fs::write(&path, format!("{source}{suffix}")).unwrap();
|
||||
let before = config_tx.borrow().clone();
|
||||
reload_config(&path, &config_tx, &log_tx, None, None, &mut state);
|
||||
let after = config_tx.borrow().clone();
|
||||
if valid {
|
||||
let json = serde_json::to_value(after.access.user_quota_periods.get("alice")).unwrap();
|
||||
assert_eq!(json, serde_json::to_value(period).unwrap());
|
||||
} else {
|
||||
assert!(Arc::ptr_eq(&before, &after));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,6 +463,12 @@ pub(super) fn log_changes(
|
||||
new_hot.user_expirations.len()
|
||||
);
|
||||
}
|
||||
if old_hot.user_quota_periods != new_hot.user_quota_periods {
|
||||
info!(
|
||||
"config reload: user_quota_periods updated ({} entries)",
|
||||
new_hot.user_quota_periods.len()
|
||||
);
|
||||
}
|
||||
if old_hot.user_data_quota != new_hot.user_data_quota {
|
||||
info!(
|
||||
"config reload: user_data_quota updated ({} entries)",
|
||||
|
||||
@@ -372,6 +372,8 @@ async fn candidate_watcher_waits_for_activation_and_reconciles_disk() {
|
||||
cancellation.clone(),
|
||||
None,
|
||||
Some(activation_rx),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let watcher = tokio::spawn(watcher);
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ fn reload_config_with_resolver(
|
||||
detected_ip_v6: Option<IpAddr>,
|
||||
reload_state: &mut ReloadState,
|
||||
dns_resolver: Option<&crate::network::dns_overrides::GenerationDnsResolver>,
|
||||
refresh_quota_source: bool,
|
||||
) -> Option<WatchManifest> {
|
||||
let (loaded, applied_cfg) = prepared;
|
||||
let LoadedConfig {
|
||||
@@ -178,6 +179,9 @@ fn reload_config_with_resolver(
|
||||
}
|
||||
|
||||
if !hot_changed {
|
||||
if refresh_quota_source {
|
||||
config_tx.send(Arc::new(applied_cfg)).ok();
|
||||
}
|
||||
reload_state.mark_applied(rendered_hash);
|
||||
return Some(next_manifest);
|
||||
}
|
||||
@@ -228,6 +232,7 @@ pub(super) fn reload_config(
|
||||
detected_ip_v6,
|
||||
reload_state,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,6 +253,8 @@ pub fn spawn_config_watcher(
|
||||
cancellation: tokio_util::sync::CancellationToken,
|
||||
dns_resolver: Option<Arc<crate::network::dns_overrides::GenerationDnsResolver>>,
|
||||
mut activation: Option<watch::Receiver<bool>>,
|
||||
quota_mutation: Option<Arc<tokio::sync::Mutex<()>>>,
|
||||
quota_source_refresh: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
|
||||
) -> (
|
||||
watch::Receiver<Arc<ProxyConfig>>,
|
||||
watch::Receiver<LogLevel>,
|
||||
@@ -365,7 +372,12 @@ pub fn spawn_config_watcher(
|
||||
if poll_watcher.is_some() {
|
||||
info!("config watcher: poll watcher active (Docker/NFS safe)");
|
||||
}
|
||||
if initial_loaded.is_some() && !initial_matches_disk {
|
||||
if initial_loaded.is_some()
|
||||
&& (!initial_matches_disk
|
||||
|| quota_source_refresh
|
||||
.as_ref()
|
||||
.is_some_and(|refresh| refresh()))
|
||||
{
|
||||
let _ = notify_tx.try_send(());
|
||||
}
|
||||
|
||||
@@ -409,6 +421,8 @@ pub fn spawn_config_watcher(
|
||||
&mut reload_state,
|
||||
dns_resolver.clone(),
|
||||
force,
|
||||
quota_mutation.as_ref(),
|
||||
quota_source_refresh.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if next_manifest.is_none() {
|
||||
@@ -423,6 +437,8 @@ pub fn spawn_config_watcher(
|
||||
&mut reload_state,
|
||||
dns_resolver.clone(),
|
||||
force,
|
||||
quota_mutation.as_ref(),
|
||||
quota_source_refresh.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -461,3 +477,6 @@ mod path_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod decoy_dns_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod quota_period_tests;
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
|
||||
None,
|
||||
&mut state,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(config_rx.has_changed().unwrap());
|
||||
@@ -62,13 +63,14 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
|
||||
None,
|
||||
&mut state,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!config_rx.has_changed().unwrap());
|
||||
assert!(Arc::ptr_eq(&published, &config_rx.borrow()));
|
||||
|
||||
preparation::reload(
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, false, None, None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -77,7 +79,7 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
|
||||
|
||||
// Explicit reload bypasses the unchanged source hash and replaces the injected endpoint.
|
||||
preparation::reload(
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, true,
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, true, None, None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -87,7 +89,7 @@ async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
|
||||
let current = config_rx.borrow_and_update().clone();
|
||||
std::fs::write(&path, SOURCE.replace("localhost:18081", "localhost:18080")).unwrap();
|
||||
preparation::reload(
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, false, None, None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -111,7 +113,7 @@ async fn decoy_dns_unchanged_watcher_source_skips_resolution() {
|
||||
let mut state = ReloadState::new(Some(source.rendered_hash));
|
||||
assert!(preparation::source_matches_active(&initial, &source));
|
||||
preparation::reload(
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
|
||||
&path, &config_tx, &log_tx, None, None, &mut state, None, false, None, None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -34,9 +34,30 @@ pub(super) async fn reload(
|
||||
reload_state: &mut ReloadState,
|
||||
dns_resolver: Option<Arc<crate::network::dns_overrides::GenerationDnsResolver>>,
|
||||
force: bool,
|
||||
quota_mutation: Option<&Arc<tokio::sync::Mutex<()>>>,
|
||||
quota_source_refresh: Option<&(dyn Fn() -> bool + Send + Sync)>,
|
||||
) -> Option<WatchManifest> {
|
||||
// Serialize source acquisition with API lifecycle commits, then release before DNS.
|
||||
// The source timestamp fences any later mutation during asynchronous preparation.
|
||||
let guard = match quota_mutation {
|
||||
Some(gate) => Some(gate.lock().await),
|
||||
None => None,
|
||||
};
|
||||
let source = read_source(path).await?;
|
||||
let manifest = WatchManifest::from_source_files(&source.source_files);
|
||||
// A new file edit can restore the exact pre-creation bytes. Refresh only source
|
||||
// evidence for fenced accounts, retaining prepared DNS and immutable WEB state.
|
||||
if !force
|
||||
&& quota_source_refresh.is_some_and(|refresh| refresh())
|
||||
&& source_matches_active(&config_tx.borrow(), &source)
|
||||
{
|
||||
let mut refreshed = (**config_tx.borrow()).clone();
|
||||
refreshed.quota_source_read_at = source.config.quota_source_read_at;
|
||||
config_tx.send(Arc::new(refreshed)).ok();
|
||||
reload_state.mark_applied(source.rendered_hash);
|
||||
return Some(manifest);
|
||||
}
|
||||
drop(guard);
|
||||
if !force && reload_state.is_applied(source.rendered_hash) {
|
||||
return Some(manifest);
|
||||
}
|
||||
@@ -66,6 +87,7 @@ pub(super) async fn reload(
|
||||
detected_ip_v6,
|
||||
reload_state,
|
||||
dns_resolver.as_deref(),
|
||||
quota_source_refresh.is_some_and(|refresh| refresh()),
|
||||
),
|
||||
Ok(Err(error)) => {
|
||||
error!(
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_period_unchanged_source_refreshes_only_fenced_accounts_without_dns() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n",
|
||||
)
|
||||
.unwrap();
|
||||
let source = ProxyConfig::parse_source(&path).unwrap();
|
||||
let initial = Arc::new(source.config);
|
||||
let (tx, mut rx) = watch::channel(initial.clone());
|
||||
let (log, _) = watch::channel(initial.general.log_level.clone());
|
||||
let mut state = ReloadState::new(Some(source.rendered_hash));
|
||||
let gate = Arc::new(tokio::sync::Mutex::new(()));
|
||||
preparation::reload(
|
||||
&path,
|
||||
&tx,
|
||||
&log,
|
||||
None,
|
||||
None,
|
||||
&mut state,
|
||||
None,
|
||||
false,
|
||||
Some(&gate),
|
||||
Some(&|| true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(rx.has_changed().unwrap());
|
||||
let refreshed = rx.borrow_and_update().clone();
|
||||
assert!(config_equal(&initial, &refreshed));
|
||||
assert!(refreshed.quota_source_read_at > initial.quota_source_read_at);
|
||||
preparation::reload(
|
||||
&path,
|
||||
&tx,
|
||||
&log,
|
||||
None,
|
||||
None,
|
||||
&mut state,
|
||||
None,
|
||||
false,
|
||||
Some(&gate),
|
||||
Some(&|| false),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!rx.has_changed().unwrap());
|
||||
assert!(Arc::ptr_eq(&refreshed, &rx.borrow()));
|
||||
preparation::reload(
|
||||
&path,
|
||||
&tx,
|
||||
&log,
|
||||
None,
|
||||
None,
|
||||
&mut state,
|
||||
None,
|
||||
true,
|
||||
Some(&gate),
|
||||
Some(&|| true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(rx.has_changed().unwrap());
|
||||
assert!(rx.borrow().quota_source_read_at > refreshed.quota_source_read_at);
|
||||
assert!(config_equal(&refreshed, &rx.borrow()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_period_watcher_initialization_refreshes_pending_identical_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n",
|
||||
)
|
||||
.unwrap();
|
||||
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
let (mut rx, _, task) = spawn_config_watcher(
|
||||
path,
|
||||
initial.clone(),
|
||||
None,
|
||||
None,
|
||||
cancel.clone(),
|
||||
None,
|
||||
None,
|
||||
Some(Arc::new(tokio::sync::Mutex::new(()))),
|
||||
Some(Arc::new(|| true)),
|
||||
);
|
||||
let task = tokio::spawn(task);
|
||||
tokio::time::timeout(Duration::from_secs(5), rx.changed())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(config_equal(&initial, &rx.borrow()));
|
||||
assert!(rx.borrow().quota_source_read_at > initial.quota_source_read_at);
|
||||
cancel.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(5), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
+14
-2
@@ -156,6 +156,10 @@ pub struct ProxyConfig {
|
||||
#[serde(default)]
|
||||
pub default_dc: Option<u8>,
|
||||
|
||||
/// Monotonic start of source acquisition, used only for cold quota policy fencing.
|
||||
#[serde(skip)]
|
||||
pub(crate) quota_source_read_at: Option<std::time::Instant>,
|
||||
|
||||
/// Precomputed authentication snapshot for handshake hot paths.
|
||||
#[serde(skip)]
|
||||
pub(crate) runtime_user_auth: Option<Arc<UserAuthSnapshot>>,
|
||||
@@ -177,8 +181,11 @@ impl ProxyConfig {
|
||||
path: P,
|
||||
source_overrides: &BTreeMap<PathBuf, String>,
|
||||
) -> Result<LoadedConfig> {
|
||||
let read_at = std::time::Instant::now();
|
||||
let graph = Self::read_source_graph_with_overrides(path, source_overrides)?;
|
||||
Self::load_source_graph(graph)
|
||||
let mut loaded = Self::load_source_graph(graph)?;
|
||||
loaded.config.quota_source_read_at = Some(read_at);
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
/// Captures the raw include graph without requiring typed config validity.
|
||||
@@ -259,7 +266,11 @@ impl ProxyConfig {
|
||||
path: P,
|
||||
overrides: &BTreeMap<PathBuf, String>,
|
||||
) -> Result<ParsedConfigSource> {
|
||||
pipeline::parse_source_graph(Self::read_source_graph_with_overrides(path, overrides)?)
|
||||
let read_at = std::time::Instant::now();
|
||||
let mut parsed =
|
||||
pipeline::parse_source_graph(Self::read_source_graph_with_overrides(path, overrides)?)?;
|
||||
parsed.config.quota_source_read_at = Some(read_at);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Loads and prepares a complete runtime candidate without blocking async workers.
|
||||
@@ -318,6 +329,7 @@ impl ProxyConfig {
|
||||
|
||||
/// Validates cross-field configuration invariants after deserialization.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.access.validate_quota_periods()?;
|
||||
if self.access.users.is_empty() {
|
||||
return Err(ProxyError::Config("No users configured".to_string()));
|
||||
}
|
||||
|
||||
@@ -455,6 +455,7 @@ const ACCESS_CONFIG_KEYS: &[&str] = &[
|
||||
"user_max_tcp_conns_global_each",
|
||||
"user_expirations",
|
||||
"user_data_quota",
|
||||
"user_quota_periods",
|
||||
"user_rate_limits",
|
||||
"cidr_rate_limits",
|
||||
"user_max_unique_ips",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||
config.access.validate_quota_periods()?;
|
||||
if config.general.me_writer_cmd_channel_capacity == 0 {
|
||||
return Err(ProxyError::Config(
|
||||
"general.me_writer_cmd_channel_capacity must be > 0".to_string(),
|
||||
|
||||
@@ -46,6 +46,8 @@ mod legacy_policy_tests;
|
||||
mod me_route_tests;
|
||||
#[path = "load_basic_tests/me_startup_tests.rs"]
|
||||
mod me_startup_tests;
|
||||
#[path = "load_basic_tests/quota_period_tests.rs"]
|
||||
mod quota_period_tests;
|
||||
#[path = "load_basic_tests/source_security_tests.rs"]
|
||||
mod source_security_tests;
|
||||
#[path = "load_basic_tests/synlimit_mss_tests.rs"]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quota_period_configuration_survives_round_trip() {
|
||||
let cfg: ProxyConfig = toml::from_str(
|
||||
r#"
|
||||
[access.users]
|
||||
alice = "0123456789abcdef0123456789abcdef"
|
||||
[access.user_data_quota]
|
||||
alice = 1024
|
||||
[access.user_quota_periods]
|
||||
alice = "monthly"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let encoded = serde_json::to_value(&cfg).unwrap();
|
||||
assert_eq!(encoded["access"]["user_quota_periods"]["alice"], "monthly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_period_requires_a_configured_positive_quota_in_both_validation_paths() {
|
||||
for tail in [
|
||||
"[access.user_quota_periods]\nghost = 'daily'\n",
|
||||
"[access.user_quota_periods]\nalice = 'daily'\n",
|
||||
"[access.user_data_quota]\nalice = 0\n[access.user_quota_periods]\nalice = 'daily'\n",
|
||||
] {
|
||||
let source = format!("[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n{tail}");
|
||||
let cfg: ProxyConfig = toml::from_str(&source).unwrap();
|
||||
assert!(
|
||||
cfg.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("user_quota_periods")
|
||||
);
|
||||
assert!(load_config_error_from_temp_toml(&source).contains("user_quota_periods"));
|
||||
}
|
||||
for period in ["daily", "weekly", "monthly"] {
|
||||
let source = format!(
|
||||
"[general]\nconfig_strict = true\n[access.users]\nalice = '0123456789abcdef0123456789abcdef'\n[access.user_data_quota]\nalice = 1024\n[access.user_quota_periods]\nalice = '{period}'\n"
|
||||
);
|
||||
assert_eq!(
|
||||
load_config_from_temp_toml(&source)
|
||||
.access
|
||||
.user_quota_periods
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_period_tokens_are_strict_and_inventory_is_bounded() {
|
||||
for token in ["'Daily'", "'yearly'", "42", "true"] {
|
||||
assert!(
|
||||
toml::from_str::<ProxyConfig>(&format!("[access.user_quota_periods]\nalice = {token}"))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut cfg = ProxyConfig::default();
|
||||
assert!(cfg.access.user_quota_periods.is_empty());
|
||||
cfg.access.users = (0..65_537)
|
||||
.map(|n| (format!("u{n}"), "0123456789abcdef0123456789abcdef".into()))
|
||||
.collect();
|
||||
cfg.access.user_data_quota.insert("u0".into(), 1);
|
||||
cfg.access
|
||||
.user_quota_periods
|
||||
.insert("u0".into(), crate::config::UserQuotaPeriod::Daily);
|
||||
assert!(cfg.validate().unwrap_err().to_string().contains("65536"));
|
||||
}
|
||||
@@ -22,6 +22,8 @@ mod links;
|
||||
mod logging;
|
||||
mod network;
|
||||
mod policies;
|
||||
// UTC quota calendar tokens have no process-local timezone dependency.
|
||||
mod quota_period;
|
||||
mod server;
|
||||
mod web;
|
||||
// WEB carrier tokens and fixed-slot policy helpers remain independent from bulky config types.
|
||||
@@ -44,6 +46,7 @@ pub use policies::{
|
||||
MeBindStaleMode, MeFloorMode, MeRouteNoWriterMode, MeSocksKdfPolicy, MeTelemetryLevel,
|
||||
MeWriterPickMode, RstOnCloseMode, TelemetryConfig, UserMaxUniqueIpsMode,
|
||||
};
|
||||
pub use quota_period::UserQuotaPeriod;
|
||||
#[allow(unused_imports)]
|
||||
pub use server::{
|
||||
CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU,
|
||||
|
||||
@@ -30,6 +30,10 @@ pub struct AccessConfig {
|
||||
#[serde(default)]
|
||||
pub user_data_quota: HashMap<String, u64>,
|
||||
|
||||
/// Optional UTC calendar for users with a positive data quota.
|
||||
#[serde(default)]
|
||||
pub user_quota_periods: HashMap<String, UserQuotaPeriod>,
|
||||
|
||||
/// Per-user transport rate limits in bits-per-second.
|
||||
///
|
||||
/// Each entry supports independent upload (`up_bps`) and download
|
||||
@@ -90,6 +94,7 @@ impl Default for AccessConfig {
|
||||
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
|
||||
user_expirations: HashMap::new(),
|
||||
user_data_quota: HashMap::new(),
|
||||
user_quota_periods: HashMap::new(),
|
||||
user_rate_limits: HashMap::new(),
|
||||
cidr_rate_limits: HashMap::new(),
|
||||
user_source_deny: HashMap::new(),
|
||||
@@ -105,6 +110,28 @@ impl Default for AccessConfig {
|
||||
}
|
||||
|
||||
impl AccessConfig {
|
||||
/// Validates the opt-in calendar against the bounded durable quota inventory.
|
||||
pub(crate) fn validate_quota_periods(&self) -> crate::error::Result<()> {
|
||||
if !self.user_quota_periods.is_empty() && self.users.len() > 65_536 {
|
||||
return Err(crate::error::ProxyError::Config(
|
||||
"quota periods support at most 65536 configured users".to_string(),
|
||||
));
|
||||
}
|
||||
for user in self.user_quota_periods.keys() {
|
||||
if !self.users.contains_key(user)
|
||||
|| !self
|
||||
.user_data_quota
|
||||
.get(user)
|
||||
.is_some_and(|quota| *quota > 0)
|
||||
{
|
||||
return Err(crate::error::ProxyError::Config(format!(
|
||||
"access.user_quota_periods.{user} requires an existing user with a positive data quota"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_user_enabled(&self, username: &str) -> bool {
|
||||
self.user_enabled.get(username).copied().unwrap_or(true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Calendar used for recurring quota resets; all boundaries are UTC.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UserQuotaPeriod {
|
||||
/// Each UTC midnight.
|
||||
Daily,
|
||||
/// Monday at UTC midnight.
|
||||
Weekly,
|
||||
/// The first day of each UTC month.
|
||||
Monthly,
|
||||
}
|
||||
|
||||
impl UserQuotaPeriod {
|
||||
/// Returns the UTC boundary at or before the supplied Unix timestamp.
|
||||
pub(crate) fn start(self, epoch: u64) -> std::io::Result<u64> {
|
||||
use chrono::Datelike;
|
||||
let timestamp = i64::try_from(epoch).map_err(|_| invalid_time())?;
|
||||
let date = chrono::DateTime::from_timestamp(timestamp, 0)
|
||||
.ok_or_else(invalid_time)?
|
||||
.date_naive();
|
||||
let start = match self {
|
||||
Self::Daily => Some(date),
|
||||
Self::Weekly => date.checked_sub_days(chrono::Days::new(u64::from(
|
||||
date.weekday().num_days_from_monday(),
|
||||
))),
|
||||
Self::Monthly => date.with_day(1),
|
||||
}
|
||||
.ok_or_else(invalid_time)?;
|
||||
u64::try_from(
|
||||
start
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(invalid_time)?
|
||||
.and_utc()
|
||||
.timestamp(),
|
||||
)
|
||||
.map_err(|_| invalid_time())
|
||||
}
|
||||
|
||||
/// Returns the first UTC calendar boundary strictly after the cursor.
|
||||
pub(crate) fn next(self, cursor: u64) -> std::io::Result<u64> {
|
||||
let start = self.start(cursor)?;
|
||||
let date = chrono::DateTime::from_timestamp(start as i64, 0)
|
||||
.ok_or_else(invalid_time)?
|
||||
.date_naive();
|
||||
let next = match self {
|
||||
Self::Daily => date.checked_add_days(chrono::Days::new(1)),
|
||||
Self::Weekly => date.checked_add_days(chrono::Days::new(7)),
|
||||
Self::Monthly => date.checked_add_months(chrono::Months::new(1)),
|
||||
}
|
||||
.ok_or_else(invalid_time)?;
|
||||
u64::try_from(
|
||||
next.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(invalid_time)?
|
||||
.and_utc()
|
||||
.timestamp(),
|
||||
)
|
||||
.map_err(|_| invalid_time())
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_time() -> std::io::Error {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"quota calendar timestamp is outside the supported UTC range",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn epoch(value: &str) -> u64 {
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.unwrap()
|
||||
.timestamp() as u64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_calendar_boundaries_are_strict_and_handle_leap_years() {
|
||||
for (period, now, start, next) in [
|
||||
(
|
||||
UserQuotaPeriod::Daily,
|
||||
"2024-02-29T23:59:59Z",
|
||||
"2024-02-29T00:00:00Z",
|
||||
"2024-03-01T00:00:00Z",
|
||||
),
|
||||
(
|
||||
UserQuotaPeriod::Weekly,
|
||||
"2024-12-31T12:00:00Z",
|
||||
"2024-12-30T00:00:00Z",
|
||||
"2025-01-06T00:00:00Z",
|
||||
),
|
||||
(
|
||||
UserQuotaPeriod::Monthly,
|
||||
"2024-02-15T12:00:00Z",
|
||||
"2024-02-01T00:00:00Z",
|
||||
"2024-03-01T00:00:00Z",
|
||||
),
|
||||
(
|
||||
UserQuotaPeriod::Monthly,
|
||||
"2023-12-31T23:59:59Z",
|
||||
"2023-12-01T00:00:00Z",
|
||||
"2024-01-01T00:00:00Z",
|
||||
),
|
||||
] {
|
||||
assert_eq!(period.start(epoch(now)).unwrap(), epoch(start));
|
||||
assert_eq!(period.next(epoch(now)).unwrap(), epoch(next));
|
||||
assert_eq!(period.next(epoch(start)).unwrap(), epoch(next));
|
||||
}
|
||||
assert!(UserQuotaPeriod::Daily.next(u64::MAX).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user