Production sysctl_tuning hardening and upgrade-safe defaults
This commit is contained in:
@@ -117,7 +117,13 @@ swappiness = 1
|
|||||||
- runtime apply: только `sysctl -w` и только для drifted keys;
|
- runtime apply: только `sysctl -w` и только для drifted keys;
|
||||||
- на установке `.deb` модуль не применяется;
|
- на установке `.deb` модуль не применяется;
|
||||||
- на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`;
|
- на старте daemon модуль применяется, если `enabled=true` и `apply_on_daemon_start=true`;
|
||||||
- `tcp_bbr = auto` не валит запуск при недоступном BBR (warning), `require` — валит.
|
- `tcp_bbr = auto` не валит запуск при недоступном BBR (warning), `require` — валит;
|
||||||
|
- host runtime tools: `modprobe` обязателен только для `tcp_bbr = "require"`.
|
||||||
|
|
||||||
|
Совместимость обновлений:
|
||||||
|
|
||||||
|
- если в существующем runtime-конфиге отсутствует блок `[sysctl_tuning]`, он не включается автоматически (`enabled=false` по default для missing block);
|
||||||
|
- для новых установок рекомендуется явный блок `[sysctl_tuning]` из примера, где `enabled=true`.
|
||||||
|
|
||||||
Ограничения модуля:
|
Ограничения модуля:
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,18 @@ fn default_input_policy() -> String {
|
|||||||
"drop".to_string()
|
"drop".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_sysctl_tuning_for_missing_block() -> SysctlTuningConfig {
|
||||||
|
SysctlTuningConfig {
|
||||||
|
enabled: false,
|
||||||
|
..SysctlTuningConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub amnezia: AmneziaConfig,
|
pub amnezia: AmneziaConfig,
|
||||||
pub traffic: TrafficConfig,
|
pub traffic: TrafficConfig,
|
||||||
#[serde(default)]
|
#[serde(default = "default_sysctl_tuning_for_missing_block")]
|
||||||
pub sysctl_tuning: SysctlTuningConfig,
|
pub sysctl_tuning: SysctlTuningConfig,
|
||||||
pub host_firewall: HostFirewallConfig,
|
pub host_firewall: HostFirewallConfig,
|
||||||
pub anti_torrent: AntiTorrentConfig,
|
pub anti_torrent: AntiTorrentConfig,
|
||||||
@@ -296,7 +303,7 @@ impl Config {
|
|||||||
required.push("sysctl");
|
required.push("sysctl");
|
||||||
}
|
}
|
||||||
if self.sysctl_tuning.enabled
|
if self.sysctl_tuning.enabled
|
||||||
&& self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase() != "off"
|
&& self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase() == "require"
|
||||||
{
|
{
|
||||||
required.push("modprobe");
|
required.push("modprobe");
|
||||||
}
|
}
|
||||||
@@ -649,4 +656,55 @@ swappiness = 1
|
|||||||
let parsed = toml::from_str::<Config>(raw);
|
let parsed = toml::from_str::<Config>(raw);
|
||||||
assert!(parsed.is_err());
|
assert!(parsed.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_sysctl_tuning_block_defaults_to_disabled() {
|
||||||
|
let raw = r#"
|
||||||
|
[amnezia]
|
||||||
|
container_names = ["amnezia-awg2"]
|
||||||
|
interface_candidates = ["awg0"]
|
||||||
|
interface_patterns = ["awg*"]
|
||||||
|
peer_ipv4_cidr = "10.8.1.0/24"
|
||||||
|
|
||||||
|
[traffic]
|
||||||
|
enabled = true
|
||||||
|
peer_limit = "70mbit"
|
||||||
|
root_rate = "1000mbit"
|
||||||
|
reconcile_interval_secs = 15
|
||||||
|
container_ready_timeout_secs = 30
|
||||||
|
class_start_id = 10
|
||||||
|
ifb_name = "ifb0"
|
||||||
|
|
||||||
|
[host_firewall]
|
||||||
|
enabled = true
|
||||||
|
ssh_port = 2323
|
||||||
|
amnezia_port = 443
|
||||||
|
amnezia_protocols = ["udp"]
|
||||||
|
allow_icmp = true
|
||||||
|
default_input_policy = "drop"
|
||||||
|
|
||||||
|
[anti_torrent]
|
||||||
|
enabled = false
|
||||||
|
max_connections_per_peer = 100
|
||||||
|
peer_ipv4_cidr = "10.8.1.0/24"
|
||||||
|
apply_in_amnezia_netns = false
|
||||||
|
|
||||||
|
[reboot]
|
||||||
|
enabled = false
|
||||||
|
window_start_utc = "01:00"
|
||||||
|
window_end_utc = "02:00"
|
||||||
|
|
||||||
|
[swap]
|
||||||
|
enabled = false
|
||||||
|
path = "/swapfile"
|
||||||
|
size_mb = 2048
|
||||||
|
priority = 1
|
||||||
|
swappiness = 1
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let cfg = toml::from_str::<Config>(raw).expect("config parse");
|
||||||
|
assert!(!cfg.sysctl_tuning.enabled);
|
||||||
|
assert!(cfg.sysctl_tuning.apply_on_daemon_start);
|
||||||
|
assert_eq!(cfg.sysctl_tuning.tcp_bbr, "auto");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -692,6 +692,7 @@ table inet awg2_host_firewall {
|
|||||||
class_start_id: 10,
|
class_start_id: 10,
|
||||||
ifb_name: "ifb0".into(),
|
ifb_name: "ifb0".into(),
|
||||||
},
|
},
|
||||||
|
sysctl_tuning: crate::config::SysctlTuningConfig::default(),
|
||||||
host_firewall: crate::config::HostFirewallConfig::default(),
|
host_firewall: crate::config::HostFirewallConfig::default(),
|
||||||
anti_torrent: crate::config::AntiTorrentConfig {
|
anti_torrent: crate::config::AntiTorrentConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -753,6 +754,7 @@ table inet awg2_host_firewall {
|
|||||||
class_start_id: 10,
|
class_start_id: 10,
|
||||||
ifb_name: "ifb0".into(),
|
ifb_name: "ifb0".into(),
|
||||||
},
|
},
|
||||||
|
sysctl_tuning: crate::config::SysctlTuningConfig::default(),
|
||||||
host_firewall: crate::config::HostFirewallConfig::default(),
|
host_firewall: crate::config::HostFirewallConfig::default(),
|
||||||
anti_torrent: crate::config::AntiTorrentConfig {
|
anti_torrent: crate::config::AntiTorrentConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -798,6 +800,7 @@ table inet awg2_host_firewall {
|
|||||||
class_start_id: 10,
|
class_start_id: 10,
|
||||||
ifb_name: "ifb0".into(),
|
ifb_name: "ifb0".into(),
|
||||||
},
|
},
|
||||||
|
sysctl_tuning: crate::config::SysctlTuningConfig::default(),
|
||||||
host_firewall: crate::config::HostFirewallConfig::default(),
|
host_firewall: crate::config::HostFirewallConfig::default(),
|
||||||
anti_torrent: crate::config::AntiTorrentConfig {
|
anti_torrent: crate::config::AntiTorrentConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -855,6 +858,7 @@ table inet awg2_host_firewall {
|
|||||||
class_start_id: 10,
|
class_start_id: 10,
|
||||||
ifb_name: "ifb0".into(),
|
ifb_name: "ifb0".into(),
|
||||||
},
|
},
|
||||||
|
sysctl_tuning: crate::config::SysctlTuningConfig::default(),
|
||||||
host_firewall: crate::config::HostFirewallConfig::default(),
|
host_firewall: crate::config::HostFirewallConfig::default(),
|
||||||
anti_torrent: crate::config::AntiTorrentConfig {
|
anti_torrent: crate::config::AntiTorrentConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ impl Drop for FileLock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn is_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
fn is_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||||
let pid_raw = fs::read_to_string(path)?;
|
let pid_raw = fs::read_to_string(path)?;
|
||||||
let pid = match pid_raw.trim().parse::<u32>() {
|
let pid = match pid_raw.trim().parse::<u32>() {
|
||||||
@@ -82,6 +83,11 @@ fn is_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
|||||||
Ok(!Path::new(&proc_path).exists())
|
Ok(!Path::new(&proc_path).exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn is_stale_lock(_path: &Path) -> anyhow::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let plan = build_render_plan(cfg, true)?;
|
let plan = build_render_plan(cfg, true)?;
|
||||||
|
for w in &plan.warnings {
|
||||||
|
tracing::warn!(target: "sysctl_tuning", "{w}");
|
||||||
|
}
|
||||||
|
ensure_managed_or_absent(SYSCTL_CONF_PATH)?;
|
||||||
write_atomic(SYSCTL_CONF_PATH, &plan.persisted)?;
|
write_atomic(SYSCTL_CONF_PATH, &plan.persisted)?;
|
||||||
|
|
||||||
if let Some(v) = plan.desired.get("net.ipv4.ip_forward") {
|
if let Some(v) = plan.desired.get("net.ipv4.ip_forward") {
|
||||||
@@ -83,7 +87,25 @@ pub fn ensure_sysctl_tuning(cfg: &Config) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result<SysctlTuningStatus> {
|
pub fn inspect_sysctl_tuning(cfg: &Config) -> anyhow::Result<SysctlTuningStatus> {
|
||||||
let plan = build_render_plan(cfg, false)?;
|
let plan = match build_render_plan(cfg, false) {
|
||||||
|
Ok(plan) => plan,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(SysctlTuningStatus {
|
||||||
|
enabled: cfg.sysctl_tuning.enabled,
|
||||||
|
apply_on_daemon_start: cfg.sysctl_tuning.apply_on_daemon_start,
|
||||||
|
tcp_bbr_mode: cfg.sysctl_tuning.tcp_bbr.clone(),
|
||||||
|
conf_path: SYSCTL_CONF_PATH.to_string(),
|
||||||
|
persisted_present: Path::new(SYSCTL_CONF_PATH).exists(),
|
||||||
|
persisted_matches: false,
|
||||||
|
bbr_available: None,
|
||||||
|
bbr_active: None,
|
||||||
|
current: Vec::new(),
|
||||||
|
desired: Vec::new(),
|
||||||
|
drift_reason: Some(format!("render plan unavailable: {e}")),
|
||||||
|
warnings: vec![format!("render plan error: {e}")],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
let persisted_raw = fs::read_to_string(SYSCTL_CONF_PATH).ok();
|
let persisted_raw = fs::read_to_string(SYSCTL_CONF_PATH).ok();
|
||||||
let persisted_present = persisted_raw.is_some();
|
let persisted_present = persisted_raw.is_some();
|
||||||
let persisted_matches = persisted_raw
|
let persisted_matches = persisted_raw
|
||||||
@@ -187,27 +209,48 @@ pub fn remove_managed_sysctl_file_if_disabled(cfg: &Config) -> anyhow::Result<()
|
|||||||
if cfg.sysctl_tuning.enabled {
|
if cfg.sysctl_tuning.enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = Path::new(SYSCTL_CONF_PATH);
|
|
||||||
|
remove_managed_sysctl_file_at(Path::new(SYSCTL_CONF_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_managed_sysctl_file_at(path: &Path) -> anyhow::Result<()> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let raw = fs::read_to_string(path)?;
|
let raw = fs::read_to_string(path)?;
|
||||||
let first = raw.lines().next().unwrap_or("");
|
let first = raw.lines().next().unwrap_or("");
|
||||||
if !first.contains(MANAGED_MARKER) {
|
if !first.contains(MANAGED_MARKER) {
|
||||||
anyhow::bail!("refusing to remove unmanaged sysctl file: {SYSCTL_CONF_PATH}");
|
anyhow::bail!("refusing to remove unmanaged sysctl file: {}", path.display());
|
||||||
}
|
}
|
||||||
fs::remove_file(path)?;
|
fs::remove_file(path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_managed_or_absent(path: &str) -> anyhow::Result<()> {
|
||||||
|
let p = Path::new(path);
|
||||||
|
if !p.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(p)?;
|
||||||
|
let first = raw.lines().next().unwrap_or("");
|
||||||
|
|
||||||
|
if !first.contains(MANAGED_MARKER) {
|
||||||
|
anyhow::bail!("refusing to overwrite unmanaged sysctl file: {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn build_render_plan(cfg: &Config, allow_modprobe: bool) -> anyhow::Result<RenderPlan> {
|
fn build_render_plan(cfg: &Config, allow_modprobe: bool) -> anyhow::Result<RenderPlan> {
|
||||||
let mut desired = BTreeMap::<String, String>::new();
|
let mut desired = BTreeMap::<String, String>::new();
|
||||||
let mut warnings = Vec::<String>::new();
|
let mut warnings = Vec::<String>::new();
|
||||||
|
|
||||||
if !cfg.sysctl_tuning.enabled {
|
if !cfg.sysctl_tuning.enabled {
|
||||||
|
let persisted = render_persisted(&desired);
|
||||||
return Ok(RenderPlan {
|
return Ok(RenderPlan {
|
||||||
desired,
|
desired,
|
||||||
persisted: render_persisted(&desired),
|
persisted,
|
||||||
bbr_available: None,
|
bbr_available: None,
|
||||||
bbr_active: None,
|
bbr_active: None,
|
||||||
warnings,
|
warnings,
|
||||||
@@ -331,13 +374,14 @@ fn write_atomic(path: &str, content: &str) -> anyhow::Result<()> {
|
|||||||
let tmp_path = format!("{path}.tmp");
|
let tmp_path = format!("{path}.tmp");
|
||||||
|
|
||||||
let mut f = fs::File::create(&tmp_path)?;
|
let mut f = fs::File::create(&tmp_path)?;
|
||||||
f.write_all(content.as_bytes())?;
|
|
||||||
f.sync_all()?;
|
|
||||||
drop(f);
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o644))?;
|
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o644))?;
|
||||||
|
|
||||||
|
f.write_all(content.as_bytes())?;
|
||||||
|
f.sync_all()?;
|
||||||
|
drop(f);
|
||||||
|
|
||||||
fs::rename(&tmp_path, path)?;
|
fs::rename(&tmp_path, path)?;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -415,11 +459,69 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_refuses_unmanaged_file() {
|
fn remove_refuses_unmanaged_file() {
|
||||||
let mut c = cfg();
|
let path = std::env::temp_dir().join(format!(
|
||||||
c.sysctl_tuning.enabled = false;
|
"awg2_harness_unmanaged_sysctl_test_{}.conf",
|
||||||
let e = remove_managed_sysctl_file_if_disabled(&c);
|
std::process::id()
|
||||||
if Path::new(SYSCTL_CONF_PATH).exists() {
|
));
|
||||||
assert!(e.is_ok() || e.is_err());
|
|
||||||
|
fs::write(&path, "# manual file\nnet.core.rmem_max = 1\n").expect("write unmanaged test");
|
||||||
|
|
||||||
|
let e = remove_managed_sysctl_file_at(&path);
|
||||||
|
assert!(e.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_deletes_managed_file() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"awg2_harness_managed_sysctl_test_{}.conf",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
|
||||||
|
fs::write(&path, format!("{MANAGED_MARKER}\nnet.core.rmem_max = 1\n"))
|
||||||
|
.expect("write managed test");
|
||||||
|
|
||||||
|
remove_managed_sysctl_file_at(&path).expect("remove managed file");
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_refuses_overwrite_unmanaged_file() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"awg2_harness_unmanaged_overwrite_test_{}.conf",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::write(&path, "# manual file\nnet.core.wmem_max = 1\n").expect("write unmanaged");
|
||||||
|
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
let e = ensure_managed_or_absent(&path_str);
|
||||||
|
assert!(e.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_allows_absent_or_managed_file() {
|
||||||
|
let absent = std::env::temp_dir().join(format!(
|
||||||
|
"awg2_harness_absent_test_{}.conf",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
if absent.exists() {
|
||||||
|
let _ = fs::remove_file(&absent);
|
||||||
}
|
}
|
||||||
|
let absent_str = absent.to_string_lossy().to_string();
|
||||||
|
ensure_managed_or_absent(&absent_str).expect("allow absent");
|
||||||
|
|
||||||
|
let managed = std::env::temp_dir().join(format!(
|
||||||
|
"awg2_harness_managed_overwrite_test_{}.conf",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::write(&managed, format!("{MANAGED_MARKER}\nnet.core.rmem_max = 1\n"))
|
||||||
|
.expect("write managed");
|
||||||
|
let managed_str = managed.to_string_lossy().to_string();
|
||||||
|
ensure_managed_or_absent(&managed_str).expect("allow managed");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&managed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user