Conntrack: Firewall reconciliation process-owned&transactional

This commit is contained in:
Alexey
2026-09-25 19:35:31 +03:00
parent 26a574780e
commit c12c5c73c1
26 changed files with 2520 additions and 439 deletions
+4
View File
@@ -124,6 +124,10 @@ pub(super) struct ZeroCoreData {
pub(super) conntrack_pressure_active: bool,
pub(super) conntrack_event_queue_depth: u64,
pub(super) conntrack_rule_apply_ok: bool,
pub(super) conntrack_rule_reconcile_success_total: u64,
pub(super) conntrack_rule_reconcile_error_total: u64,
pub(super) conntrack_rule_rollback_success_total: u64,
pub(super) conntrack_rule_rollback_error_total: u64,
pub(super) conntrack_delete_attempt_total: u64,
pub(super) conntrack_delete_success_total: u64,
pub(super) conntrack_delete_not_found_total: u64,
+8
View File
@@ -61,6 +61,14 @@ pub(super) fn build_zero_all_data(stats: &Stats, configured_users: usize) -> Zer
conntrack_pressure_active: stats.get_conntrack_pressure_active(),
conntrack_event_queue_depth: stats.get_conntrack_event_queue_depth(),
conntrack_rule_apply_ok: stats.get_conntrack_rule_apply_ok(),
conntrack_rule_reconcile_success_total: stats
.get_conntrack_rule_reconcile_success_total(),
conntrack_rule_reconcile_error_total: stats
.get_conntrack_rule_reconcile_error_total(),
conntrack_rule_rollback_success_total: stats
.get_conntrack_rule_rollback_success_total(),
conntrack_rule_rollback_error_total: stats
.get_conntrack_rule_rollback_error_total(),
conntrack_delete_attempt_total: stats.get_conntrack_delete_attempt_total(),
conntrack_delete_success_total: stats.get_conntrack_delete_success_total(),
conntrack_delete_not_found_total: stats.get_conntrack_delete_not_found_total(),
+1 -3
View File
@@ -15,8 +15,8 @@ mod firewall;
use firewall::{
DeleteOutcome, delete_conntrack_entry, effective_conntrack_enabled, probe_runtime_support,
reconcile_rules,
};
pub(crate) use firewall::FirewallAuthority;
const CONNTRACK_EVENT_QUEUE_CAPACITY: usize = 32_768;
const PRESSURE_RELEASE_TICKS: u8 = 3;
@@ -115,7 +115,6 @@ async fn run_conntrack_controller_worker(
runtime_support,
false,
);
reconcile_rules(&cfg, runtime_support, stats.as_ref()).await;
loop {
tokio::select! {
@@ -129,7 +128,6 @@ async fn run_conntrack_controller_worker(
effective_enabled = effective_conntrack_enabled(&cfg, runtime_support);
delete_budget_tokens = cfg.server.conntrack_control.delete_budget_per_sec;
apply_runtime_state(stats.as_ref(), shared.as_ref(), &cfg, runtime_support, pressure_state.active);
reconcile_rules(&cfg, runtime_support, stats.as_ref()).await;
}
event = close_rx.recv() => {
let Some(event) = event else {
+15 -431
View File
@@ -1,434 +1,18 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::time::Duration;
// Process-owned firewall reconciliation and privileged conntrack helpers.
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tracing::{debug, warn};
mod actor;
mod command;
mod iptables;
mod model;
mod nftables;
mod runtime;
mod transaction;
use crate::config::{ConntrackBackend, ConntrackMode, ProxyConfig};
use crate::proxy::shared_state::ConntrackCloseEvent;
use crate::stats::Stats;
#[cfg(unix)]
use crate::util::trusted_command::resolve_trusted_helper;
pub(crate) use actor::FirewallAuthority;
pub(super) use runtime::{
DeleteOutcome, delete_conntrack_entry, effective_conntrack_enabled, probe_runtime_support,
};
use super::{ConntrackRuntimeSupport, NetfilterBackend};
/// Reconciles kernel NOTRACK rules with the active listener policy.
pub(super) async fn reconcile_rules(
cfg: &ProxyConfig,
runtime_support: ConntrackRuntimeSupport,
stats: &Stats,
) {
if !cfg.server.conntrack_control.inline_conntrack_control {
clear_notrack_rules_all_backends().await;
stats.set_conntrack_rule_apply_ok(true);
return;
}
if !effective_conntrack_enabled(cfg, runtime_support) {
clear_notrack_rules_all_backends().await;
stats.set_conntrack_rule_apply_ok(false);
return;
}
let backend = runtime_support
.netfilter_backend
.expect("netfilter backend must be available for effective conntrack control");
let apply_result = match backend {
NetfilterBackend::Nftables => apply_nft_rules(cfg).await,
NetfilterBackend::Iptables => apply_iptables_rules(cfg).await,
};
if let Err(error) = apply_result {
warn!(error = %error, "Failed to reconcile conntrack/notrack rules");
stats.set_conntrack_rule_apply_ok(false);
} else {
stats.set_conntrack_rule_apply_ok(true);
}
}
/// Probes the effective firewall backend and conntrack deletion capability.
pub(super) fn probe_runtime_support(
configured_backend: ConntrackBackend,
) -> ConntrackRuntimeSupport {
ConntrackRuntimeSupport {
netfilter_backend: pick_backend(configured_backend),
has_cap_net_admin: has_cap_net_admin(),
has_conntrack_binary: command_exists("conntrack"),
}
}
/// Resolves whether conntrack close publication is usable for this runtime.
pub(super) fn effective_conntrack_enabled(
cfg: &ProxyConfig,
runtime_support: ConntrackRuntimeSupport,
) -> bool {
cfg.server.conntrack_control.inline_conntrack_control
&& runtime_support.has_cap_net_admin
&& runtime_support.netfilter_backend.is_some()
&& runtime_support.has_conntrack_binary
}
fn pick_backend(configured: ConntrackBackend) -> Option<NetfilterBackend> {
match configured {
ConntrackBackend::Auto => {
if command_exists("nft") {
Some(NetfilterBackend::Nftables)
} else if command_exists("iptables") {
Some(NetfilterBackend::Iptables)
} else {
None
}
}
ConntrackBackend::Nftables => command_exists("nft").then_some(NetfilterBackend::Nftables),
ConntrackBackend::Iptables => {
command_exists("iptables").then_some(NetfilterBackend::Iptables)
}
}
}
fn command_exists(binary: &str) -> bool {
#[cfg(unix)]
{
resolve_trusted_helper(binary).is_some()
}
#[cfg(not(unix))]
{
let _ = binary;
false
}
}
fn listener_port_set(cfg: &ProxyConfig) -> Vec<u16> {
let mut ports: BTreeSet<u16> = BTreeSet::new();
if cfg.server.listeners.is_empty() {
ports.insert(cfg.server.port);
} else {
for listener in &cfg.server.listeners {
ports.insert(listener.port.unwrap_or(cfg.server.port));
}
}
ports.into_iter().collect()
}
fn notrack_targets(cfg: &ProxyConfig) -> (Vec<(Option<IpAddr>, u16)>, Vec<(Option<IpAddr>, u16)>) {
let mode = cfg.server.conntrack_control.mode;
let mut v4_targets: BTreeSet<(Option<IpAddr>, u16)> = BTreeSet::new();
let mut v6_targets: BTreeSet<(Option<IpAddr>, u16)> = BTreeSet::new();
match mode {
ConntrackMode::Tracked => {}
ConntrackMode::Notrack => {
if cfg.server.listeners.is_empty() {
let port = cfg.server.port;
if let Some(ipv4) = cfg
.server
.listen_addr_ipv4
.as_ref()
.and_then(|value| value.parse::<IpAddr>().ok())
{
if ipv4.is_unspecified() {
v4_targets.insert((None, port));
} else {
v4_targets.insert((Some(ipv4), port));
}
}
if let Some(ipv6) = cfg
.server
.listen_addr_ipv6
.as_ref()
.and_then(|value| value.parse::<IpAddr>().ok())
{
if ipv6.is_unspecified() {
v6_targets.insert((None, port));
} else {
v6_targets.insert((Some(ipv6), port));
}
}
} else {
for listener in &cfg.server.listeners {
let port = listener.port.unwrap_or(cfg.server.port);
if listener.ip.is_ipv4() {
if listener.ip.is_unspecified() {
v4_targets.insert((None, port));
} else {
v4_targets.insert((Some(listener.ip), port));
}
} else if listener.ip.is_unspecified() {
v6_targets.insert((None, port));
} else {
v6_targets.insert((Some(listener.ip), port));
}
}
}
}
ConntrackMode::Hybrid => {
let ports = listener_port_set(cfg);
for ip in &cfg.server.conntrack_control.hybrid_listener_ips {
if ip.is_ipv4() {
for port in &ports {
v4_targets.insert((Some(*ip), *port));
}
} else {
for port in &ports {
v6_targets.insert((Some(*ip), *port));
}
}
}
}
}
(
v4_targets.into_iter().collect(),
v6_targets.into_iter().collect(),
)
}
async fn apply_nft_rules(cfg: &ProxyConfig) -> Result<(), String> {
let _ = run_command(
"nft",
&["delete", "table", "inet", "telemt_conntrack"],
None,
)
.await;
if matches!(cfg.server.conntrack_control.mode, ConntrackMode::Tracked) {
return Ok(());
}
let (v4_targets, v6_targets) = notrack_targets(cfg);
let mut rules = Vec::new();
for (ip, port) in v4_targets {
let rule = if let Some(ip) = ip {
format!("tcp dport {} ip daddr {} notrack", port, ip)
} else {
format!("tcp dport {} notrack", port)
};
rules.push(rule);
}
for (ip, port) in v6_targets {
let rule = if let Some(ip) = ip {
format!("tcp dport {} ip6 daddr {} notrack", port, ip)
} else {
format!("tcp dport {} notrack", port)
};
rules.push(rule);
}
let rule_blob = if rules.is_empty() {
String::new()
} else {
format!(" {}\n", rules.join("\n "))
};
let script = format!(
"table inet telemt_conntrack {{\n chain preraw {{\n type filter hook prerouting priority raw; policy accept;\n{rule_blob} }}\n}}\n"
);
run_command("nft", &["-f", "-"], Some(script)).await
}
async fn apply_iptables_rules(cfg: &ProxyConfig) -> Result<(), String> {
apply_iptables_rules_for_binary("iptables", cfg, true).await?;
apply_iptables_rules_for_binary("ip6tables", cfg, false).await?;
Ok(())
}
async fn apply_iptables_rules_for_binary(
binary: &str,
cfg: &ProxyConfig,
ipv4: bool,
) -> Result<(), String> {
if !command_exists(binary) {
return Ok(());
}
let chain = "TELEMT_NOTRACK";
let _ = run_command(
binary,
&["-t", "raw", "-D", "PREROUTING", "-j", chain],
None,
)
.await;
let _ = run_command(binary, &["-t", "raw", "-F", chain], None).await;
let _ = run_command(binary, &["-t", "raw", "-X", chain], None).await;
if matches!(cfg.server.conntrack_control.mode, ConntrackMode::Tracked) {
return Ok(());
}
run_command(binary, &["-t", "raw", "-N", chain], None).await?;
run_command(binary, &["-t", "raw", "-F", chain], None).await?;
if run_command(
binary,
&["-t", "raw", "-C", "PREROUTING", "-j", chain],
None,
)
.await
.is_err()
{
run_command(
binary,
&["-t", "raw", "-I", "PREROUTING", "1", "-j", chain],
None,
)
.await?;
}
let (v4_targets, v6_targets) = notrack_targets(cfg);
let selected = if ipv4 { v4_targets } else { v6_targets };
for (ip, port) in selected {
let mut args = vec![
"-t".to_string(),
"raw".to_string(),
"-A".to_string(),
chain.to_string(),
"-p".to_string(),
"tcp".to_string(),
"--dport".to_string(),
port.to_string(),
];
if let Some(ip) = ip {
args.push("-d".to_string());
args.push(ip.to_string());
}
args.push("-j".to_string());
args.push("CT".to_string());
args.push("--notrack".to_string());
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
run_command(binary, &arg_refs, None).await?;
}
Ok(())
}
async fn clear_notrack_rules_all_backends() {
let _ = run_command(
"nft",
&["delete", "table", "inet", "telemt_conntrack"],
None,
)
.await;
let _ = run_command(
"iptables",
&["-t", "raw", "-D", "PREROUTING", "-j", "TELEMT_NOTRACK"],
None,
)
.await;
let _ = run_command("iptables", &["-t", "raw", "-F", "TELEMT_NOTRACK"], None).await;
let _ = run_command("iptables", &["-t", "raw", "-X", "TELEMT_NOTRACK"], None).await;
let _ = run_command(
"ip6tables",
&["-t", "raw", "-D", "PREROUTING", "-j", "TELEMT_NOTRACK"],
None,
)
.await;
let _ = run_command("ip6tables", &["-t", "raw", "-F", "TELEMT_NOTRACK"], None).await;
let _ = run_command("ip6tables", &["-t", "raw", "-X", "TELEMT_NOTRACK"], None).await;
}
/// Result of one best-effort kernel conntrack deletion.
pub(super) enum DeleteOutcome {
/// The kernel reported successful deletion.
Deleted,
/// No matching conntrack entry existed.
NotFound,
/// The helper was unavailable or returned an unexpected failure.
Error,
}
/// Deletes the exact TCP tuple represented by one close event.
pub(super) async fn delete_conntrack_entry(event: ConntrackCloseEvent) -> DeleteOutcome {
if !command_exists("conntrack") {
return DeleteOutcome::Error;
}
let args = vec![
"-D".to_string(),
"-p".to_string(),
"tcp".to_string(),
"-s".to_string(),
event.src.ip().to_string(),
"--sport".to_string(),
event.src.port().to_string(),
"-d".to_string(),
event.dst.ip().to_string(),
"--dport".to_string(),
event.dst.port().to_string(),
];
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
match run_command("conntrack", &arg_refs, None).await {
Ok(()) => DeleteOutcome::Deleted,
Err(error) => {
if error.contains("0 flow entries have been deleted") {
DeleteOutcome::NotFound
} else {
debug!(error = %error, "conntrack delete failed");
DeleteOutcome::Error
}
}
}
}
async fn run_command(binary: &str, args: &[&str], stdin: Option<String>) -> Result<(), String> {
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(unix)]
let Some(command_path) = resolve_trusted_helper(binary) else {
return Err(format!("{binary} is not available"));
};
#[cfg(not(unix))]
return Err(format!("{binary} is not available"));
#[cfg(unix)]
let mut command = Command::new(command_path);
command.args(args);
if stdin.is_some() {
command.stdin(std::process::Stdio::piped());
}
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::piped());
command.kill_on_drop(true);
let mut child = command
.spawn()
.map_err(|error| format!("spawn {binary} failed: {error}"))?;
let output = tokio::time::timeout(COMMAND_TIMEOUT, async move {
if let Some(blob) = stdin
&& let Some(mut writer) = child.stdin.take()
{
writer
.write_all(blob.as_bytes())
.await
.map_err(|error| format!("stdin write {binary} failed: {error}"))?;
}
child
.wait_with_output()
.await
.map_err(|error| format!("wait {binary} failed: {error}"))
})
.await
.map_err(|_| format!("{binary} timed out after {}s", COMMAND_TIMEOUT.as_secs()))??;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
})
}
fn has_cap_net_admin() -> bool {
#[cfg(target_os = "linux")]
{
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return false;
};
for line in status.lines() {
if let Some(raw) = line.strip_prefix("CapEff:") {
let caps = raw.trim();
if let Ok(bits) = u64::from_str_radix(caps, 16) {
const CAP_NET_ADMIN_BIT: u64 = 12;
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
}
}
}
false
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
#[cfg(test)]
#[path = "firewall/tests.rs"]
mod tests;
+375
View File
@@ -0,0 +1,375 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, watch};
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::config::ProxyConfig;
use crate::maestro::control_plane::ProcessControlPlane;
use crate::stats::Stats;
use super::command::{CommandError, FirewallCommandRunner, SystemCommandRunner};
use super::model::{AppliedPlan, AppliedState, DesiredPolicy, DesiredState};
use super::transaction::{InterruptibleRunner, reconcile_once, recover_to_empty};
const SHUTDOWN_CLEANUP_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_WAIT_TIMEOUT: Duration = Duration::from_secs(35);
const INITIAL_RECONCILE_TIMEOUT: Duration = Duration::from_secs(65);
const RETRY_DELAYS: [Duration; 6] = [
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(16),
Duration::from_secs(30),
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ReconcileOutcome {
Applied,
Failed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct ReconcileStatus {
pub(super) generation: u64,
pub(super) outcome: ReconcileOutcome,
}
/// Process-owned publisher and shutdown owner for conntrack firewall policy.
#[derive(Clone)]
pub(crate) struct FirewallAuthority {
desired_tx: watch::Sender<Option<DesiredState>>,
status_rx: watch::Receiver<Option<ReconcileStatus>>,
terminal: CancellationToken,
closed: Arc<AtomicBool>,
completed_flag: Arc<AtomicBool>,
cleanup_succeeded: Arc<AtomicBool>,
completed: Arc<Notify>,
}
impl FirewallAuthority {
/// Starts the single process-owned firewall reconciler.
pub(crate) fn spawn(control_plane: &ProcessControlPlane) -> Result<Self, String> {
let (desired_tx, desired_rx) = watch::channel(None);
let (status_tx, status_rx) = watch::channel(None);
let terminal = CancellationToken::new();
let closed = Arc::new(AtomicBool::new(false));
let completed_flag = Arc::new(AtomicBool::new(false));
let cleanup_succeeded = Arc::new(AtomicBool::new(false));
let completed = Arc::new(Notify::new());
let actor = FirewallReconciler::new(
SystemCommandRunner,
desired_rx,
status_tx,
terminal.clone(),
Arc::clone(&closed),
Arc::clone(&completed_flag),
Arc::clone(&cleanup_succeeded),
Arc::clone(&completed),
);
control_plane
.spawn_cooperative(move |process_cancellation| async move {
actor.run(process_cancellation).await;
})
.map_err(|_| {
"process control-plane admission closed before conntrack firewall startup"
.to_string()
})?;
Ok(Self {
desired_tx,
status_rx,
terminal,
closed,
completed_flag,
cleanup_succeeded,
completed,
})
}
/// Publishes policy only after its runtime generation becomes active.
pub(crate) fn publish(
&self,
generation: u64,
config: Arc<ProxyConfig>,
stats: Arc<Stats>,
) -> bool {
if self.closed.load(Ordering::Acquire) {
stats.set_conntrack_rule_apply_ok(false);
return false;
}
stats.set_conntrack_rule_apply_ok(false);
self.desired_tx.send_replace(Some(DesiredState {
generation,
policy: DesiredPolicy::from_config(config.as_ref()),
stats,
}));
true
}
/// Publishes startup policy and waits for its first bounded attempt.
pub(crate) async fn publish_initial(
&self,
generation: u64,
config: Arc<ProxyConfig>,
stats: Arc<Stats>,
) -> bool {
let mut status_rx = self.status_rx.clone();
if !self.publish(generation, config, stats) {
return false;
}
tokio::time::timeout(INITIAL_RECONCILE_TIMEOUT, async move {
loop {
if let Some(status) = *status_rx.borrow_and_update()
&& status.generation == generation
{
return status.outcome == ReconcileOutcome::Applied;
}
if status_rx.changed().await.is_err() {
return false;
}
}
})
.await
.unwrap_or(false)
}
/// Stops policy admission and waits for bounded terminal cleanup.
pub(crate) async fn shutdown_and_clear(&self) -> bool {
let completed = self.completed.notified();
tokio::pin!(completed);
completed.as_mut().enable();
if !self.closed.swap(true, Ordering::AcqRel) {
if let Some(desired) = self.desired_tx.borrow().as_ref() {
desired.stats.set_conntrack_rule_apply_ok(false);
}
self.terminal.cancel();
}
let finished = self.completed_flag.load(Ordering::Acquire)
|| tokio::time::timeout(SHUTDOWN_WAIT_TIMEOUT, completed)
.await
.is_ok();
finished && self.cleanup_succeeded.load(Ordering::Acquire)
}
}
struct CompletionGuard {
closed: Arc<AtomicBool>,
completed_flag: Arc<AtomicBool>,
completed: Arc<Notify>,
}
impl Drop for CompletionGuard {
fn drop(&mut self) {
self.closed.store(true, Ordering::Release);
self.completed_flag.store(true, Ordering::Release);
self.completed.notify_waiters();
}
}
pub(super) struct FirewallReconciler<R> {
runner: R,
desired_rx: watch::Receiver<Option<DesiredState>>,
status_tx: watch::Sender<Option<ReconcileStatus>>,
terminal: CancellationToken,
completion: CompletionGuard,
cleanup_succeeded: Arc<AtomicBool>,
applied: AppliedState,
last_generation: u64,
last_policy: Option<DesiredPolicy>,
last_stats: Option<Arc<Stats>>,
}
impl<R> FirewallReconciler<R>
where
R: FirewallCommandRunner + 'static,
{
pub(super) fn new(
runner: R,
desired_rx: watch::Receiver<Option<DesiredState>>,
status_tx: watch::Sender<Option<ReconcileStatus>>,
terminal: CancellationToken,
closed: Arc<AtomicBool>,
completed_flag: Arc<AtomicBool>,
cleanup_succeeded: Arc<AtomicBool>,
completed: Arc<Notify>,
) -> Self {
Self {
runner,
desired_rx,
status_tx,
terminal,
completion: CompletionGuard {
closed,
completed_flag,
completed,
},
cleanup_succeeded,
applied: AppliedState::Unknown,
last_generation: 0,
last_policy: None,
last_stats: None,
}
}
pub(super) async fn run(mut self, process_cancellation: CancellationToken) {
let mut current = None;
let mut retry_index = 0usize;
'run: loop {
if current.is_none() {
let changed = tokio::select! {
biased;
_ = self.terminal.cancelled() => false,
_ = process_cancellation.cancelled() => false,
changed = self.desired_rx.changed() => changed.is_ok(),
};
if !changed {
break;
}
current = self.take_latest_desired();
retry_index = 0;
if current.is_none() {
continue;
}
}
let desired = current.as_ref().expect("desired state is present").clone();
let interruptible = InterruptibleRunner::new(
&self.runner,
&self.terminal,
&process_cancellation,
);
let result = reconcile_once(
&interruptible,
&interruptible,
&mut self.applied,
&desired,
)
.await;
match result {
Ok(()) => {
desired.stats.increment_conntrack_rule_reconcile_success_total();
desired.stats.set_conntrack_rule_apply_ok(true);
self.status_tx.send_replace(Some(ReconcileStatus {
generation: desired.generation,
outcome: ReconcileOutcome::Applied,
}));
if self.terminal.is_cancelled() || process_cancellation.is_cancelled() {
desired.stats.set_conntrack_rule_apply_ok(false);
break;
}
current = None;
retry_index = 0;
}
Err(failure) if failure.cancelled => break,
Err(failure) => {
desired.stats.increment_conntrack_rule_reconcile_error_total();
desired.stats.set_conntrack_rule_apply_ok(false);
if let Some(rollback_succeeded) = failure.rollback_succeeded {
if rollback_succeeded {
desired
.stats
.increment_conntrack_rule_rollback_success_total();
} else {
desired
.stats
.increment_conntrack_rule_rollback_error_total();
}
}
self.status_tx.send_replace(Some(ReconcileStatus {
generation: desired.generation,
outcome: ReconcileOutcome::Failed,
}));
warn!(
generation = desired.generation,
error = %failure.message,
"Failed to reconcile conntrack firewall policy"
);
let delay = RETRY_DELAYS[retry_index.min(RETRY_DELAYS.len() - 1)];
retry_index = retry_index.saturating_add(1);
let retry_deadline = tokio::time::Instant::now() + delay;
loop {
tokio::select! {
biased;
_ = self.terminal.cancelled() => break 'run,
_ = process_cancellation.cancelled() => break 'run,
changed = self.desired_rx.changed() => {
if changed.is_err() {
break 'run;
}
if let Some(next) = self.take_latest_desired() {
current = Some(next);
retry_index = 0;
break;
}
}
_ = tokio::time::sleep_until(retry_deadline) => break,
}
}
}
}
if self.terminal.is_cancelled() || process_cancellation.is_cancelled() {
break;
}
if self.desired_rx.has_changed().unwrap_or(false) {
if let Some(next) = self.take_latest_desired() {
current = Some(next);
retry_index = 0;
}
}
}
if let Some(stats) = &self.last_stats {
stats.set_conntrack_rule_apply_ok(false);
}
if let Err(error) = tokio::time::timeout(
SHUTDOWN_CLEANUP_TIMEOUT,
recover_to_empty(&self.runner),
)
.await
.unwrap_or_else(|_| Err(CommandError::failed("firewall shutdown cleanup timed out")))
{
warn!(error = %error, "Failed to clear conntrack firewall policy during shutdown");
} else {
self.applied = AppliedState::Known(AppliedPlan::Empty);
self.cleanup_succeeded.store(true, Ordering::Release);
}
let _completion = &self.completion;
}
pub(super) fn take_latest_desired(&mut self) -> Option<DesiredState> {
let next = self.desired_rx.borrow_and_update().clone()?;
if next.generation < self.last_generation {
warn!(
generation = next.generation,
active_generation = self.last_generation,
"Ignored stale conntrack firewall policy publication"
);
return None;
}
if next.generation == self.last_generation {
if self.last_policy.as_ref() != Some(&next.policy) {
warn!(
generation = next.generation,
"Ignored conflicting conntrack firewall policy for active generation"
);
return None;
}
self.last_stats = Some(next.stats.clone());
if let AppliedState::Known(applied) = &self.applied
&& applied.matches_policy(&next.policy)
{
next.stats.set_conntrack_rule_apply_ok(true);
}
return Some(next);
}
self.last_generation = next.generation;
self.last_policy = Some(next.policy.clone());
self.last_stats = Some(next.stats.clone());
Some(next)
}
}
+204
View File
@@ -0,0 +1,204 @@
use std::time::Duration;
#[cfg(unix)]
use tokio::io::AsyncWriteExt;
#[cfg(unix)]
use tokio::process::Command;
#[cfg(unix)]
use crate::util::trusted_command::resolve_trusted_helper;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct CommandSpec {
pub(super) binary: &'static str,
pub(super) args: Vec<String>,
pub(super) stdin: Option<String>,
}
impl CommandSpec {
pub(super) fn new(binary: &'static str, args: impl IntoIterator<Item = &'static str>) -> Self {
Self {
binary,
args: args.into_iter().map(str::to_string).collect(),
stdin: None,
}
}
pub(super) fn with_stdin(
binary: &'static str,
args: impl IntoIterator<Item = &'static str>,
stdin: String,
) -> Self {
Self {
binary,
args: args.into_iter().map(str::to_string).collect(),
stdin: Some(stdin),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum CommandErrorKind {
Missing,
NotFound,
Cancelled,
Timeout,
Failed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct CommandError {
pub(super) kind: CommandErrorKind,
pub(super) message: String,
}
impl CommandError {
pub(super) fn cancelled() -> Self {
Self {
kind: CommandErrorKind::Cancelled,
message: "firewall transaction cancelled".to_string(),
}
}
pub(super) fn failed(message: impl Into<String>) -> Self {
Self {
kind: CommandErrorKind::Failed,
message: message.into(),
}
}
}
impl std::fmt::Display for CommandError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
pub(super) trait FirewallCommandRunner: Send + Sync {
fn available(&self, binary: &str) -> bool;
fn has_cap_net_admin(&self) -> bool;
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError>;
}
#[derive(Clone, Copy, Default)]
pub(super) struct SystemCommandRunner;
impl FirewallCommandRunner for SystemCommandRunner {
fn available(&self, binary: &str) -> bool {
#[cfg(unix)]
{
resolve_trusted_helper(binary).is_some()
}
#[cfg(not(unix))]
{
let _ = binary;
false
}
}
fn has_cap_net_admin(&self) -> bool {
#[cfg(target_os = "linux")]
{
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return false;
};
for line in status.lines() {
if let Some(raw) = line.strip_prefix("CapEff:") {
let caps = raw.trim();
if let Ok(bits) = u64::from_str_radix(caps, 16) {
const CAP_NET_ADMIN_BIT: u64 = 12;
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
}
}
}
false
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError> {
#[cfg(not(unix))]
{
Err(CommandError {
kind: CommandErrorKind::Missing,
message: format!("{} is not available", spec.binary),
})
}
#[cfg(unix)]
{
let Some(command_path) = resolve_trusted_helper(spec.binary) else {
return Err(CommandError {
kind: CommandErrorKind::Missing,
message: format!("{} is not available", spec.binary),
});
};
let mut command = Command::new(command_path);
command.args(&spec.args);
command.env("LC_ALL", "C");
if spec.stdin.is_some() {
command.stdin(std::process::Stdio::piped());
}
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::piped());
command.kill_on_drop(true);
let mut child = command.spawn().map_err(|error| CommandError {
kind: CommandErrorKind::Failed,
message: format!("spawn {} failed: {error}", spec.binary),
})?;
let binary = spec.binary;
let output = tokio::time::timeout(COMMAND_TIMEOUT, async move {
if let Some(blob) = spec.stdin
&& let Some(mut writer) = child.stdin.take()
{
writer
.write_all(blob.as_bytes())
.await
.map_err(|error| CommandError {
kind: CommandErrorKind::Failed,
message: format!("stdin write {binary} failed: {error}"),
})?;
}
child
.wait_with_output()
.await
.map_err(|error| CommandError {
kind: CommandErrorKind::Failed,
message: format!("wait {binary} failed: {error}"),
})
})
.await
.map_err(|_| CommandError {
kind: CommandErrorKind::Timeout,
message: format!("{binary} timed out after {}s", COMMAND_TIMEOUT.as_secs()),
})??;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
};
let kind = if is_not_found_error(&message) {
CommandErrorKind::NotFound
} else {
CommandErrorKind::Failed
};
Err(CommandError { kind, message })
}
}
}
pub(super) fn is_not_found_error(message: &str) -> bool {
message.contains("No chain/target/match by that name")
|| message.contains("Bad rule (does a matching rule exist in that chain?)")
|| message.contains("Could not process rule: No such file or directory")
}
+255
View File
@@ -0,0 +1,255 @@
use super::command::{
CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner,
};
use super::model::{NotrackTarget, ShadowSlot};
const DISPATCH_CHAIN: &str = "TELEMT_NOTRACK";
const SHADOW_CHAIN_A: &str = "TELEMT_NT_A";
const SHADOW_CHAIN_B: &str = "TELEMT_NT_B";
const MAX_OWNED_JUMPS: usize = 8;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum IpFamily {
V4,
V6,
}
impl IpFamily {
fn command_binary(self) -> &'static str {
match self {
Self::V4 => "iptables",
Self::V6 => "ip6tables",
}
}
fn restore_binary(self) -> &'static str {
match self {
Self::V4 => "iptables-restore",
Self::V6 => "ip6tables-restore",
}
}
}
pub(super) fn family_available<R: FirewallCommandRunner>(
runner: &R,
family: IpFamily,
) -> bool {
runner.available(family.command_binary()) && runner.available(family.restore_binary())
}
fn shadow_chain(slot: ShadowSlot) -> &'static str {
match slot {
ShadowSlot::A => SHADOW_CHAIN_A,
ShadowSlot::B => SHADOW_CHAIN_B,
}
}
pub(super) async fn stage_family<R: FirewallCommandRunner>(
runner: &R,
family: IpFamily,
slot: ShadowSlot,
targets: &[NotrackTarget],
) -> Result<(), CommandError> {
if targets.is_empty() {
return Ok(());
}
require_family(runner, family)?;
ensure_owned_chains(runner, family).await?;
let script = render_stage_script(slot, targets);
runner
.run(CommandSpec::with_stdin(
family.restore_binary(),
["--noflush"],
script,
))
.await
}
pub(super) async fn activate_family<R: FirewallCommandRunner>(
runner: &R,
family: IpFamily,
slot: Option<ShadowSlot>,
) -> Result<(), CommandError> {
require_family(runner, family)?;
if slot.is_some() {
ensure_prerouting_jump(runner, family).await?;
}
runner
.run(CommandSpec::with_stdin(
family.restore_binary(),
["--noflush"],
render_dispatch_script(slot),
))
.await
}
pub(super) async fn cleanup_all<R: FirewallCommandRunner>(
runner: &R,
) -> Result<(), CommandError> {
let mut errors = Vec::new();
for family in [IpFamily::V4, IpFamily::V6] {
if !runner.available(family.command_binary()) {
continue;
}
if let Err(error) = cleanup_family(runner, family).await {
errors.push(error.message);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(CommandError::failed(errors.join("; ")))
}
}
async fn cleanup_family<R: FirewallCommandRunner>(
runner: &R,
family: IpFamily,
) -> Result<(), CommandError> {
let binary = family.command_binary();
let mut errors = Vec::new();
for _ in 0..MAX_OWNED_JUMPS {
let result = runner
.run(CommandSpec::new(
binary,
["-t", "raw", "-D", "PREROUTING", "-j", DISPATCH_CHAIN],
))
.await;
match result {
Ok(()) => {}
Err(error)
if matches!(error.kind, CommandErrorKind::NotFound | CommandErrorKind::Missing) =>
{
break;
}
Err(error) => {
errors.push(error.message);
break;
}
}
}
for chain in [DISPATCH_CHAIN, SHADOW_CHAIN_A, SHADOW_CHAIN_B] {
for operation in ["-F", "-X"] {
let result = runner
.run(CommandSpec::new(
binary,
["-t", "raw", operation, chain],
))
.await;
if let Err(error) = result
&& !matches!(error.kind, CommandErrorKind::NotFound | CommandErrorKind::Missing)
{
errors.push(error.message);
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(CommandError::failed(errors.join("; ")))
}
}
async fn ensure_prerouting_jump<R: FirewallCommandRunner>(
runner: &R,
family: IpFamily,
) -> Result<(), CommandError> {
let binary = family.command_binary();
match runner
.run(CommandSpec::new(
binary,
["-t", "raw", "-C", "PREROUTING", "-j", DISPATCH_CHAIN],
))
.await
{
Ok(()) => Ok(()),
Err(error) if error.kind == CommandErrorKind::NotFound => {
runner
.run(CommandSpec::new(
binary,
[
"-t",
"raw",
"-I",
"PREROUTING",
"1",
"-j",
DISPATCH_CHAIN,
],
))
.await
}
Err(error) => Err(error),
}
}
async fn ensure_owned_chains<R: FirewallCommandRunner>(
runner: &R,
family: IpFamily,
) -> Result<(), CommandError> {
let binary = family.command_binary();
for chain in [DISPATCH_CHAIN, SHADOW_CHAIN_A, SHADOW_CHAIN_B] {
match runner
.run(CommandSpec::new(binary, ["-t", "raw", "-N", chain]))
.await
{
Ok(()) => {}
Err(error) if is_chain_exists_error(&error.message) => {}
Err(error) => return Err(error),
}
}
Ok(())
}
pub(super) fn is_chain_exists_error(message: &str) -> bool {
message.contains("Chain already exists")
}
fn require_family<R: FirewallCommandRunner>(
runner: &R,
family: IpFamily,
) -> Result<(), CommandError> {
for binary in [family.command_binary(), family.restore_binary()] {
if !runner.available(binary) {
return Err(CommandError {
kind: CommandErrorKind::Missing,
message: format!("{binary} is required for conntrack firewall reconciliation"),
});
}
}
Ok(())
}
pub(super) fn render_stage_script(
slot: ShadowSlot,
targets: &[NotrackTarget],
) -> String {
let chain = shadow_chain(slot);
let mut script = format!("*raw\n-F {chain}\n");
for target in targets {
script.push_str("-A ");
script.push_str(chain);
script.push_str(" -p tcp --dport ");
script.push_str(&target.port.to_string());
if let Some(ip) = target.ip {
script.push_str(" -d ");
script.push_str(&ip.to_string());
}
script.push_str(" -j CT --notrack\n");
}
script.push_str("COMMIT\n");
script
}
pub(super) fn render_dispatch_script(slot: Option<ShadowSlot>) -> String {
let mut script = format!("*raw\n-F {DISPATCH_CHAIN}\n");
if let Some(slot) = slot {
script.push_str("-A ");
script.push_str(DISPATCH_CHAIN);
script.push_str(" -j ");
script.push_str(shadow_chain(slot));
script.push('\n');
}
script.push_str("COMMIT\n");
script
}
+205
View File
@@ -0,0 +1,205 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::sync::Arc;
use crate::config::{ConntrackBackend, ConntrackMode, ProxyConfig};
use crate::stats::Stats;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ShadowSlot {
A,
B,
}
impl ShadowSlot {
pub(super) fn other(self) -> Self {
match self {
Self::A => Self::B,
Self::B => Self::A,
}
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct NotrackTarget {
pub(super) ip: Option<IpAddr>,
pub(super) port: u16,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum DesiredPolicy {
Empty,
Rules {
configured_backend: ConntrackBackend,
v4: Vec<NotrackTarget>,
v6: Vec<NotrackTarget>,
},
}
impl DesiredPolicy {
pub(super) fn from_config(cfg: &ProxyConfig) -> Self {
if !cfg.server.conntrack_control.inline_conntrack_control
|| matches!(cfg.server.conntrack_control.mode, ConntrackMode::Tracked)
{
return Self::Empty;
}
let (v4, v6) = notrack_targets(cfg);
if v4.is_empty() && v6.is_empty() {
Self::Empty
} else {
Self::Rules {
configured_backend: cfg.server.conntrack_control.backend,
v4,
v6,
}
}
}
}
#[derive(Clone)]
pub(super) struct DesiredState {
pub(super) generation: u64,
pub(super) policy: DesiredPolicy,
pub(super) stats: Arc<Stats>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum AppliedPlan {
Empty,
Iptables {
slot: ShadowSlot,
v4: Vec<NotrackTarget>,
v6: Vec<NotrackTarget>,
},
Nftables {
slot: ShadowSlot,
v4: Vec<NotrackTarget>,
v6: Vec<NotrackTarget>,
},
}
impl AppliedPlan {
pub(super) fn slot(&self) -> Option<ShadowSlot> {
match self {
Self::Empty => None,
Self::Iptables { slot, .. } | Self::Nftables { slot, .. } => Some(*slot),
}
}
pub(super) fn matches_policy(&self, policy: &DesiredPolicy) -> bool {
match (self, policy) {
(Self::Empty, DesiredPolicy::Empty) => true,
(
Self::Iptables { v4, v6, .. },
DesiredPolicy::Rules {
configured_backend,
v4: desired_v4,
v6: desired_v6,
},
) => {
matches!(
configured_backend,
ConntrackBackend::Auto | ConntrackBackend::Iptables
) && v4 == desired_v4
&& v6 == desired_v6
}
(
Self::Nftables { v4, v6, .. },
DesiredPolicy::Rules {
configured_backend,
v4: desired_v4,
v6: desired_v6,
},
) => {
matches!(
configured_backend,
ConntrackBackend::Auto | ConntrackBackend::Nftables
) && v4 == desired_v4
&& v6 == desired_v6
}
_ => false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum AppliedState {
Known(AppliedPlan),
Unknown,
}
fn listener_port_set(cfg: &ProxyConfig) -> Vec<u16> {
let mut ports = BTreeSet::new();
if cfg.server.listeners.is_empty() {
ports.insert(cfg.server.port);
} else {
for listener in &cfg.server.listeners {
ports.insert(listener.port.unwrap_or(cfg.server.port));
}
}
ports.into_iter().collect()
}
fn notrack_targets(cfg: &ProxyConfig) -> (Vec<NotrackTarget>, Vec<NotrackTarget>) {
let mut v4_targets = BTreeSet::new();
let mut v6_targets = BTreeSet::new();
match cfg.server.conntrack_control.mode {
ConntrackMode::Tracked => {}
ConntrackMode::Notrack => {
if cfg.server.listeners.is_empty() {
let port = cfg.server.port;
for raw in [
cfg.server.listen_addr_ipv4.as_deref(),
cfg.server.listen_addr_ipv6.as_deref(),
]
.into_iter()
.flatten()
{
if let Ok(ip) = raw.parse::<IpAddr>() {
let target = NotrackTarget {
ip: (!ip.is_unspecified()).then_some(ip),
port,
};
if ip.is_ipv4() {
v4_targets.insert(target);
} else {
v6_targets.insert(target);
}
}
}
} else {
for listener in &cfg.server.listeners {
let target = NotrackTarget {
ip: (!listener.ip.is_unspecified()).then_some(listener.ip),
port: listener.port.unwrap_or(cfg.server.port),
};
if listener.ip.is_ipv4() {
v4_targets.insert(target);
} else {
v6_targets.insert(target);
}
}
}
}
ConntrackMode::Hybrid => {
for ip in &cfg.server.conntrack_control.hybrid_listener_ips {
for port in listener_port_set(cfg) {
let target = NotrackTarget {
ip: Some(*ip),
port,
};
if ip.is_ipv4() {
v4_targets.insert(target);
} else {
v6_targets.insert(target);
}
}
}
}
}
(
v4_targets.into_iter().collect(),
v6_targets.into_iter().collect(),
)
}
+147
View File
@@ -0,0 +1,147 @@
use super::command::{CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner};
use super::model::{NotrackTarget, ShadowSlot};
const LEGACY_TABLE: &str = "telemt_conntrack";
const TABLE_A: &str = "telemt_conntrack_a";
const TABLE_B: &str = "telemt_conntrack_b";
pub(super) fn available<R: FirewallCommandRunner>(runner: &R) -> bool {
runner.available("nft")
}
fn table(slot: ShadowSlot) -> &'static str {
match slot {
ShadowSlot::A => TABLE_A,
ShadowSlot::B => TABLE_B,
}
}
pub(super) async fn stage<R: FirewallCommandRunner>(
runner: &R,
slot: ShadowSlot,
v4: &[NotrackTarget],
v6: &[NotrackTarget],
) -> Result<(), CommandError> {
require_nft(runner)?;
delete_table_if_present(runner, table(slot)).await?;
runner
.run(CommandSpec::with_stdin(
"nft",
["-f", "-"],
render_stage_script(slot, v4, v6),
))
.await
}
pub(super) async fn activate<R: FirewallCommandRunner>(
runner: &R,
slot: ShadowSlot,
) -> Result<(), CommandError> {
require_nft(runner)?;
runner
.run(CommandSpec::with_stdin(
"nft",
["-f", "-"],
render_activate_script(slot),
))
.await
}
pub(super) async fn deactivate<R: FirewallCommandRunner>(
runner: &R,
slot: ShadowSlot,
) -> Result<(), CommandError> {
delete_table_if_present(runner, table(slot)).await
}
pub(super) async fn cleanup_all<R: FirewallCommandRunner>(
runner: &R,
) -> Result<(), CommandError> {
if !runner.available("nft") {
return Ok(());
}
let mut errors = Vec::new();
for table_name in [LEGACY_TABLE, TABLE_A, TABLE_B] {
if let Err(error) = delete_table_if_present(runner, table_name).await {
errors.push(error.message);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(CommandError::failed(errors.join("; ")))
}
}
async fn delete_table_if_present<R: FirewallCommandRunner>(
runner: &R,
table_name: &'static str,
) -> Result<(), CommandError> {
match runner
.run(CommandSpec::new(
"nft",
["delete", "table", "inet", table_name],
))
.await
{
Ok(()) => Ok(()),
Err(error)
if matches!(error.kind, CommandErrorKind::NotFound | CommandErrorKind::Missing) =>
{
Ok(())
}
Err(error) => Err(error),
}
}
fn require_nft<R: FirewallCommandRunner>(runner: &R) -> Result<(), CommandError> {
if runner.available("nft") {
Ok(())
} else {
Err(CommandError {
kind: CommandErrorKind::Missing,
message: "nft is required for conntrack firewall reconciliation".to_string(),
})
}
}
pub(super) fn render_stage_script(
slot: ShadowSlot,
v4: &[NotrackTarget],
v6: &[NotrackTarget],
) -> String {
let table = table(slot);
let mut script = format!(
"add table inet {table}\nadd chain inet {table} rules\n"
);
for target in v4 {
script.push_str("add rule inet ");
script.push_str(table);
script.push_str(" rules tcp dport ");
script.push_str(&target.port.to_string());
if let Some(ip) = target.ip {
script.push_str(" ip daddr ");
script.push_str(&ip.to_string());
}
script.push_str(" notrack\n");
}
for target in v6 {
script.push_str("add rule inet ");
script.push_str(table);
script.push_str(" rules tcp dport ");
script.push_str(&target.port.to_string());
if let Some(ip) = target.ip {
script.push_str(" ip6 daddr ");
script.push_str(&ip.to_string());
}
script.push_str(" notrack\n");
}
script
}
pub(super) fn render_activate_script(slot: ShadowSlot) -> String {
let table = table(slot);
format!(
"add chain inet {table} preraw {{ type filter hook prerouting priority raw; policy accept; }}\nadd rule inet {table} preraw jump rules\n"
)
}
+91
View File
@@ -0,0 +1,91 @@
use tracing::debug;
use crate::config::{ConntrackBackend, ProxyConfig};
use crate::conntrack_control::{ConntrackRuntimeSupport, NetfilterBackend};
use crate::proxy::shared_state::ConntrackCloseEvent;
use super::command::{CommandSpec, FirewallCommandRunner, SystemCommandRunner};
use super::nftables;
/// Probes the effective firewall backend and conntrack deletion capability.
pub(in crate::conntrack_control) fn probe_runtime_support(
configured_backend: ConntrackBackend,
) -> ConntrackRuntimeSupport {
let runner = SystemCommandRunner;
let iptables_available = runner.available("iptables");
let netfilter_backend = match configured_backend {
ConntrackBackend::Auto if nftables::available(&runner) => Some(NetfilterBackend::Nftables),
ConntrackBackend::Auto if iptables_available => Some(NetfilterBackend::Iptables),
ConntrackBackend::Nftables if nftables::available(&runner) => {
Some(NetfilterBackend::Nftables)
}
ConntrackBackend::Iptables if iptables_available => Some(NetfilterBackend::Iptables),
_ => None,
};
ConntrackRuntimeSupport {
netfilter_backend,
has_cap_net_admin: runner.has_cap_net_admin(),
has_conntrack_binary: runner.available("conntrack"),
}
}
/// Resolves whether conntrack close publication is usable for this runtime.
pub(in crate::conntrack_control) fn effective_conntrack_enabled(
cfg: &ProxyConfig,
runtime_support: ConntrackRuntimeSupport,
) -> bool {
cfg.server.conntrack_control.inline_conntrack_control
&& runtime_support.has_cap_net_admin
&& runtime_support.netfilter_backend.is_some()
&& runtime_support.has_conntrack_binary
}
/// Result of one best-effort kernel conntrack deletion.
pub(in crate::conntrack_control) enum DeleteOutcome {
/// The kernel reported successful deletion.
Deleted,
/// No matching conntrack entry existed.
NotFound,
/// The helper was unavailable or returned an unexpected failure.
Error,
}
/// Deletes the exact TCP tuple represented by one close event.
pub(in crate::conntrack_control) async fn delete_conntrack_entry(
event: ConntrackCloseEvent,
) -> DeleteOutcome {
let runner = SystemCommandRunner;
if !runner.available("conntrack") {
return DeleteOutcome::Error;
}
let args = vec![
"-D".to_string(),
"-p".to_string(),
"tcp".to_string(),
"-s".to_string(),
event.src.ip().to_string(),
"--sport".to_string(),
event.src.port().to_string(),
"-d".to_string(),
event.dst.ip().to_string(),
"--dport".to_string(),
event.dst.port().to_string(),
];
match runner
.run(CommandSpec {
binary: "conntrack",
args,
stdin: None,
})
.await
{
Ok(()) => DeleteOutcome::Deleted,
Err(error) if error.message.contains("0 flow entries have been deleted") => {
DeleteOutcome::NotFound
}
Err(error) => {
debug!(error = %error, "conntrack delete failed");
DeleteOutcome::Error
}
}
}
+493
View File
@@ -0,0 +1,493 @@
use std::collections::{BTreeMap, BTreeSet};
use std::future::pending;
use std::sync::{Arc, Mutex};
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::sync::{Notify, watch};
use tokio_util::sync::CancellationToken;
use crate::config::ConntrackBackend;
use crate::stats::Stats;
use super::actor::{FirewallReconciler, ReconcileOutcome};
use super::command::{CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner};
use super::model::{
AppliedPlan, AppliedState, DesiredPolicy, DesiredState, NotrackTarget, ShadowSlot,
};
use super::transaction::{InterruptibleRunner, reconcile_once};
#[path = "tests/model_tests.rs"]
mod model_tests;
#[derive(Clone)]
struct FailureRule {
binary: &'static str,
occurrence: usize,
}
#[derive(Default)]
struct FakeState {
calls: Vec<CommandSpec>,
binary_calls: BTreeMap<&'static str, usize>,
failures: Vec<FailureRule>,
}
#[derive(Clone)]
struct FakeRunner {
available: Arc<BTreeSet<&'static str>>,
has_cap_net_admin: bool,
state: Arc<Mutex<FakeState>>,
}
impl FakeRunner {
fn all_available() -> Self {
Self {
available: Arc::new(BTreeSet::from([
"conntrack",
"ip6tables",
"ip6tables-restore",
"iptables",
"iptables-restore",
"nft",
])),
has_cap_net_admin: true,
state: Arc::new(Mutex::new(FakeState::default())),
}
}
fn with_failure(self, binary: &'static str, occurrence: usize) -> Self {
self.state
.lock()
.unwrap()
.failures
.push(FailureRule { binary, occurrence });
self
}
fn calls(&self) -> Vec<CommandSpec> {
self.state.lock().unwrap().calls.clone()
}
}
impl FirewallCommandRunner for FakeRunner {
fn available(&self, binary: &str) -> bool {
self.available.contains(binary)
}
fn has_cap_net_admin(&self) -> bool {
self.has_cap_net_admin
}
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError> {
let mut state = self.state.lock().unwrap();
let occurrence = {
let count = state.binary_calls.entry(spec.binary).or_default();
*count += 1;
*count
};
state.calls.push(spec.clone());
if state
.failures
.iter()
.any(|failure| failure.binary == spec.binary && failure.occurrence == occurrence)
{
return Err(CommandError::failed(format!(
"injected {} failure at occurrence {}",
spec.binary, occurrence
)));
}
drop(state);
let operation = spec.args.get(2).map(String::as_str);
if (matches!(spec.binary, "iptables" | "ip6tables")
&& matches!(operation, Some("-C" | "-D" | "-F" | "-X")))
|| (spec.binary == "nft"
&& spec.args.first().map(String::as_str) == Some("delete"))
{
return Err(CommandError {
kind: CommandErrorKind::NotFound,
message: "injected object not found".to_string(),
});
}
Ok(())
}
}
struct BlockingRunner {
entered: Arc<Notify>,
}
impl FirewallCommandRunner for BlockingRunner {
fn available(&self, _binary: &str) -> bool {
true
}
fn has_cap_net_admin(&self) -> bool {
true
}
async fn run(&self, _spec: CommandSpec) -> Result<(), CommandError> {
self.entered.notify_one();
pending().await
}
}
fn target(ip: Option<&str>, port: u16) -> NotrackTarget {
NotrackTarget {
ip: ip.map(|value| value.parse().unwrap()),
port,
}
}
fn desired(generation: u64, policy: DesiredPolicy) -> DesiredState {
DesiredState {
generation,
policy,
stats: Arc::new(Stats::new()),
}
}
fn dual_stack_policy(port: u16) -> DesiredPolicy {
DesiredPolicy::Rules {
configured_backend: ConntrackBackend::Iptables,
v4: vec![target(Some("192.0.2.10"), port)],
v6: vec![target(Some("2001:db8::10"), port)],
}
}
fn nft_dual_stack_policy(port: u16) -> DesiredPolicy {
DesiredPolicy::Rules {
configured_backend: ConntrackBackend::Nftables,
v4: vec![target(Some("192.0.2.10"), port)],
v6: vec![target(Some("2001:db8::10"), port)],
}
}
#[tokio::test]
async fn successful_reconcile_flips_between_shadow_slots() {
let runner = FakeRunner::all_available();
let mut applied = AppliedState::Known(AppliedPlan::Empty);
let first = desired(1, dual_stack_policy(443));
reconcile_once(&runner, &runner, &mut applied, &first)
.await
.unwrap();
assert!(matches!(
applied,
AppliedState::Known(AppliedPlan::Iptables {
slot: ShadowSlot::A,
..
})
));
let second = desired(2, dual_stack_policy(8443));
reconcile_once(&runner, &runner, &mut applied, &second)
.await
.unwrap();
assert!(matches!(
applied,
AppliedState::Known(AppliedPlan::Iptables {
slot: ShadowSlot::B,
..
})
));
assert!(runner.calls().iter().any(|call| {
call.stdin
.as_deref()
.is_some_and(|script| script.contains("-A TELEMT_NOTRACK -j TELEMT_NT_B"))
}));
}
#[tokio::test]
async fn identical_policy_is_command_free_after_convergence() {
let runner = FakeRunner::all_available();
let mut applied = AppliedState::Known(AppliedPlan::Empty);
reconcile_once(
&runner,
&runner,
&mut applied,
&desired(1, dual_stack_policy(443)),
)
.await
.unwrap();
let calls_after_convergence = runner.calls().len();
reconcile_once(
&runner,
&runner,
&mut applied,
&desired(2, dual_stack_policy(443)),
)
.await
.unwrap();
assert_eq!(runner.calls().len(), calls_after_convergence);
}
#[tokio::test]
async fn backend_migration_failure_restores_previous_backend() {
let runner = FakeRunner::all_available().with_failure("ip6tables-restore", 3);
let mut applied = AppliedState::Known(AppliedPlan::Empty);
reconcile_once(
&runner,
&runner,
&mut applied,
&desired(1, dual_stack_policy(443)),
)
.await
.unwrap();
let previous = applied.clone();
let failure = reconcile_once(
&runner,
&runner,
&mut applied,
&desired(2, nft_dual_stack_policy(8443)),
)
.await
.unwrap_err();
assert_eq!(failure.rollback_succeeded, Some(true));
assert_eq!(applied, previous);
}
#[tokio::test]
async fn unavailable_target_does_not_clear_confirmed_applied_policy() {
let runner = FakeRunner::all_available();
let mut applied = AppliedState::Known(AppliedPlan::Empty);
reconcile_once(
&runner,
&runner,
&mut applied,
&desired(1, dual_stack_policy(443)),
)
.await
.unwrap();
let previous = applied.clone();
let unavailable = FakeRunner {
available: Arc::new(BTreeSet::new()),
has_cap_net_admin: false,
state: Arc::new(Mutex::new(FakeState::default())),
};
let failure = reconcile_once(
&unavailable,
&unavailable,
&mut applied,
&desired(2, nft_dual_stack_policy(8443)),
)
.await
.unwrap_err();
assert_eq!(failure.rollback_succeeded, None);
assert_eq!(applied, previous);
assert!(unavailable.calls().is_empty());
}
#[tokio::test]
async fn partial_dual_stack_failure_restores_previous_applied_plan() {
let runner = FakeRunner::all_available().with_failure("ip6tables-restore", 4);
let mut applied = AppliedState::Known(AppliedPlan::Empty);
let first = desired(1, dual_stack_policy(443));
reconcile_once(&runner, &runner, &mut applied, &first)
.await
.unwrap();
let previous = applied.clone();
let failure = reconcile_once(
&runner,
&runner,
&mut applied,
&desired(2, dual_stack_policy(8443)),
)
.await
.unwrap_err();
assert_eq!(failure.rollback_succeeded, Some(true));
assert_eq!(applied, previous);
}
#[tokio::test]
async fn rollback_failure_marks_applied_state_unknown() {
let runner = FakeRunner::all_available()
.with_failure("ip6tables-restore", 4)
.with_failure("nft", 2);
let mut applied = AppliedState::Known(AppliedPlan::Empty);
reconcile_once(
&runner,
&runner,
&mut applied,
&desired(1, dual_stack_policy(443)),
)
.await
.unwrap();
let failure = reconcile_once(
&runner,
&runner,
&mut applied,
&desired(2, dual_stack_policy(8443)),
)
.await
.unwrap_err();
assert_eq!(failure.rollback_succeeded, Some(false));
assert_eq!(applied, AppliedState::Unknown);
}
#[tokio::test]
async fn transaction_cancellation_does_not_claim_a_new_applied_plan() {
let entered = Arc::new(Notify::new());
let runner = BlockingRunner {
entered: entered.clone(),
};
let terminal = CancellationToken::new();
let process_cancellation = CancellationToken::new();
let interruptible = InterruptibleRunner::new(
&runner,
&terminal,
&process_cancellation,
);
let mut applied = AppliedState::Known(AppliedPlan::Empty);
let desired = desired(1, dual_stack_policy(443));
let failure = {
let transaction = reconcile_once(&interruptible, &runner, &mut applied, &desired);
tokio::pin!(transaction);
tokio::select! {
_ = entered.notified() => terminal.cancel(),
_ = &mut transaction => panic!("transaction completed before injected cancellation"),
}
transaction.await.unwrap_err()
};
assert!(failure.cancelled);
assert_eq!(applied, AppliedState::Known(AppliedPlan::Empty));
}
#[test]
fn desired_watch_coalesces_and_rejects_stale_or_conflicting_generations() {
let runner = FakeRunner::all_available();
let (desired_tx, desired_rx) = watch::channel(None);
let (status_tx, _status_rx) = watch::channel(None);
let terminal = CancellationToken::new();
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let completed = Arc::new(Notify::new());
let mut reconciler = FirewallReconciler::new(
runner,
desired_rx,
status_tx,
terminal,
closed,
Arc::new(std::sync::atomic::AtomicBool::new(false)),
Arc::new(std::sync::atomic::AtomicBool::new(false)),
completed,
);
desired_tx.send_replace(Some(desired(1, dual_stack_policy(443))));
desired_tx.send_replace(Some(desired(2, dual_stack_policy(8443))));
assert_eq!(reconciler.take_latest_desired().unwrap().generation, 2);
desired_tx.send_replace(Some(desired(1, dual_stack_policy(443))));
assert!(reconciler.take_latest_desired().is_none());
desired_tx.send_replace(Some(desired(2, dual_stack_policy(9443))));
assert!(reconciler.take_latest_desired().is_none());
}
#[tokio::test(start_paused = true)]
async fn actor_retries_with_backoff_and_cleans_owned_rules_on_shutdown() {
let runner = FakeRunner::all_available().with_failure("iptables-restore", 1);
let observed_runner = runner.clone();
let (desired_tx, desired_rx) = watch::channel(None);
let (status_tx, mut status_rx) = watch::channel(None);
let terminal = CancellationToken::new();
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let completed_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let cleanup_succeeded = Arc::new(std::sync::atomic::AtomicBool::new(false));
let completed = Arc::new(Notify::new());
let reconciler = FirewallReconciler::new(
runner,
desired_rx,
status_tx,
terminal.clone(),
closed.clone(),
completed_flag.clone(),
cleanup_succeeded.clone(),
completed,
);
let process_cancellation = CancellationToken::new();
let task = tokio::spawn(reconciler.run(process_cancellation));
let initial_desired = desired(2, dual_stack_policy(443));
let desired_stats = initial_desired.stats.clone();
desired_tx.send_replace(Some(initial_desired));
status_rx.changed().await.unwrap();
assert_eq!(
status_rx.borrow().as_ref().unwrap().outcome,
ReconcileOutcome::Failed
);
let calls_after_failure = observed_runner.calls().len();
desired_tx.send_replace(Some(desired(1, dual_stack_policy(7443))));
tokio::task::yield_now().await;
desired_tx.send_replace(Some(desired(2, dual_stack_policy(9443))));
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_millis(999)).await;
tokio::task::yield_now().await;
assert_eq!(observed_runner.calls().len(), calls_after_failure);
tokio::time::advance(Duration::from_millis(1)).await;
status_rx.changed().await.unwrap();
assert_eq!(
status_rx.borrow().as_ref().unwrap().outcome,
ReconcileOutcome::Applied
);
assert_eq!(status_rx.borrow().as_ref().unwrap().generation, 2);
assert!(observed_runner.calls().iter().all(|call| {
call.stdin
.as_ref()
.is_none_or(|script| !script.contains("7443") && !script.contains("9443"))
}));
let calls_before_shutdown = observed_runner.calls().len();
terminal.cancel();
tokio::time::timeout(Duration::from_secs(1), task)
.await
.unwrap()
.unwrap();
assert!(closed.load(Ordering::Acquire));
assert!(completed_flag.load(Ordering::Acquire));
assert!(cleanup_succeeded.load(Ordering::Acquire));
assert!(!desired_stats.get_conntrack_rule_apply_ok());
assert!(observed_runner.calls().len() > calls_before_shutdown);
}
#[tokio::test]
async fn actor_reports_terminal_cleanup_failure_separately_from_completion() {
let runner = FakeRunner::all_available().with_failure("nft", 1);
let (_desired_tx, desired_rx) = watch::channel(None);
let (status_tx, _status_rx) = watch::channel(None);
let terminal = CancellationToken::new();
terminal.cancel();
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let completed_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let cleanup_succeeded = Arc::new(std::sync::atomic::AtomicBool::new(false));
let reconciler = FirewallReconciler::new(
runner,
desired_rx,
status_tx,
terminal,
closed.clone(),
completed_flag.clone(),
cleanup_succeeded.clone(),
Arc::new(Notify::new()),
);
reconciler.run(CancellationToken::new()).await;
assert!(closed.load(Ordering::Acquire));
assert!(completed_flag.load(Ordering::Acquire));
assert!(!cleanup_succeeded.load(Ordering::Acquire));
}
@@ -0,0 +1,123 @@
use crate::config::{ConntrackBackend, ConntrackMode, ProxyConfig};
use super::super::command::is_not_found_error;
use super::super::iptables::{self, is_chain_exists_error};
use super::super::model::{DesiredPolicy, ShadowSlot};
use super::super::nftables;
use super::target;
#[test]
fn desired_policy_derives_exact_listener_targets() {
let mut config = ProxyConfig::default();
config.server.port = 8443;
config.server.listen_addr_ipv4 = Some("0.0.0.0".to_string());
config.server.listen_addr_ipv6 = Some("2001:db8::10".to_string());
config.server.conntrack_control.inline_conntrack_control = true;
config.server.conntrack_control.mode = ConntrackMode::Notrack;
config.server.conntrack_control.backend = ConntrackBackend::Iptables;
assert_eq!(
DesiredPolicy::from_config(&config),
DesiredPolicy::Rules {
configured_backend: ConntrackBackend::Iptables,
v4: vec![target(None, 8443)],
v6: vec![target(Some("2001:db8::10"), 8443)],
}
);
config.server.conntrack_control.mode = ConntrackMode::Tracked;
assert_eq!(DesiredPolicy::from_config(&config), DesiredPolicy::Empty);
config.server.conntrack_control.mode = ConntrackMode::Notrack;
config.server.conntrack_control.inline_conntrack_control = false;
assert_eq!(DesiredPolicy::from_config(&config), DesiredPolicy::Empty);
}
#[test]
fn hybrid_policy_is_a_sorted_deduplicated_address_port_product() {
let mut config = ProxyConfig::default();
config.server.conntrack_control.inline_conntrack_control = true;
config.server.conntrack_control.mode = ConntrackMode::Hybrid;
config.server.conntrack_control.hybrid_listener_ips = vec![
"2001:db8::10".parse().unwrap(),
"192.0.2.10".parse().unwrap(),
"192.0.2.10".parse().unwrap(),
];
config.server.listeners = vec![
serde_json::from_value(serde_json::json!({
"ip": "0.0.0.0",
"port": 8443
}))
.unwrap(),
serde_json::from_value(serde_json::json!({
"ip": "::",
"port": 443
}))
.unwrap(),
];
assert_eq!(
DesiredPolicy::from_config(&config),
DesiredPolicy::Rules {
configured_backend: ConntrackBackend::Auto,
v4: vec![
target(Some("192.0.2.10"), 443),
target(Some("192.0.2.10"), 8443),
],
v6: vec![
target(Some("2001:db8::10"), 443),
target(Some("2001:db8::10"), 8443),
],
}
);
}
#[test]
fn restore_renderers_keep_staging_detached_from_activation() {
let stage = iptables::render_stage_script(
ShadowSlot::B,
&[target(Some("192.0.2.20"), 443)],
);
assert!(stage.contains("-F TELEMT_NT_B\n"));
assert!(stage.contains(
"-A TELEMT_NT_B -p tcp --dport 443 -d 192.0.2.20 -j CT --notrack\n"
));
assert!(!stage.contains("-A TELEMT_NOTRACK -j TELEMT_NT_B"));
assert!(!stage.contains(":TELEMT_"));
let activation = iptables::render_dispatch_script(Some(ShadowSlot::B));
assert!(activation.contains("-F TELEMT_NOTRACK\n"));
assert!(activation.contains("-A TELEMT_NOTRACK -j TELEMT_NT_B\n"));
assert!(!activation.contains(":TELEMT_"));
let nft_stage = nftables::render_stage_script(
ShadowSlot::A,
&[target(None, 443)],
&[target(Some("2001:db8::20"), 8443)],
);
assert!(!nft_stage.contains("hook prerouting"));
assert!(nft_stage.contains("tcp dport 443 notrack\n"));
assert!(nft_stage.contains("tcp dport 8443 ip6 daddr 2001:db8::20 notrack\n"));
assert!(nftables::render_activate_script(ShadowSlot::A).contains("hook prerouting"));
}
#[test]
fn command_error_classification_only_accepts_absent_owned_objects() {
assert!(is_not_found_error(
"iptables: No chain/target/match by that name."
));
assert!(is_not_found_error(
"Bad rule (does a matching rule exist in that chain?)."
));
assert!(is_not_found_error(
"Error: Could not process rule: No such file or directory"
));
assert!(!is_not_found_error("Permission denied"));
assert!(!is_not_found_error(
"can't initialize iptables table `raw': Table does not exist"
));
assert!(!is_not_found_error(
"Another app is currently holding the xtables lock"
));
assert!(is_chain_exists_error("iptables: Chain already exists."));
assert!(!is_chain_exists_error("Permission denied"));
}
@@ -0,0 +1,333 @@
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::config::ConntrackBackend;
use super::command::{
CommandError, CommandErrorKind, CommandSpec, FirewallCommandRunner,
};
use super::iptables::{self, IpFamily};
use super::model::{AppliedPlan, AppliedState, DesiredPolicy, DesiredState, ShadowSlot};
use super::nftables;
const TRANSACTION_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) struct InterruptibleRunner<'a, R> {
inner: &'a R,
terminal: &'a CancellationToken,
process_cancellation: &'a CancellationToken,
}
impl<'a, R> InterruptibleRunner<'a, R> {
pub(super) fn new(
inner: &'a R,
terminal: &'a CancellationToken,
process_cancellation: &'a CancellationToken,
) -> Self {
Self {
inner,
terminal,
process_cancellation,
}
}
}
impl<R: FirewallCommandRunner> FirewallCommandRunner for InterruptibleRunner<'_, R> {
fn available(&self, binary: &str) -> bool {
self.inner.available(binary)
}
fn has_cap_net_admin(&self) -> bool {
self.inner.has_cap_net_admin()
}
async fn run(&self, spec: CommandSpec) -> Result<(), CommandError> {
tokio::select! {
biased;
_ = self.terminal.cancelled() => Err(CommandError::cancelled()),
_ = self.process_cancellation.cancelled() => Err(CommandError::cancelled()),
result = self.inner.run(spec) => result,
}
}
}
#[derive(Debug)]
pub(super) struct ReconcileFailure {
pub(super) message: String,
pub(super) rollback_succeeded: Option<bool>,
pub(super) cancelled: bool,
}
pub(super) async fn reconcile_once<I, R>(
interruptible: &I,
recovery_runner: &R,
applied: &mut AppliedState,
desired: &DesiredState,
) -> Result<(), ReconcileFailure>
where
I: FirewallCommandRunner,
R: FirewallCommandRunner,
{
if let AppliedState::Known(current) = applied
&& current.matches_policy(&desired.policy)
{
return Ok(());
}
if matches!(applied, AppliedState::Unknown) {
match tokio::time::timeout(TRANSACTION_TIMEOUT, recover_to_empty(interruptible)).await {
Ok(Ok(())) => *applied = AppliedState::Known(AppliedPlan::Empty),
Ok(Err(error)) if error.kind == CommandErrorKind::Cancelled => {
return Err(cancelled_failure());
}
Ok(Err(error)) => {
return Err(ReconcileFailure {
message: format!("startup recovery failed: {error}"),
rollback_succeeded: None,
cancelled: false,
});
}
Err(_) => {
return Err(ReconcileFailure {
message: "startup recovery timed out".to_string(),
rollback_succeeded: None,
cancelled: false,
});
}
}
}
let previous = match applied {
AppliedState::Known(plan) => plan.clone(),
AppliedState::Unknown => unreachable!("unknown state was recovered above"),
};
let target = match resolve_target(interruptible, &desired.policy, &previous) {
Ok(target) => target,
Err(error) => {
return Err(ReconcileFailure {
message: error.message,
rollback_succeeded: None,
cancelled: false,
});
}
};
let transition = tokio::time::timeout(
TRANSACTION_TIMEOUT,
transition_plan(interruptible, &previous, &target),
)
.await;
match transition {
Ok(Ok(())) => {
*applied = AppliedState::Known(target);
Ok(())
}
Ok(Err(error)) if error.kind == CommandErrorKind::Cancelled => Err(cancelled_failure()),
Ok(Err(error)) => {
rollback_after_failure(recovery_runner, applied, previous, error.message).await
}
Err(_) => {
rollback_after_failure(
recovery_runner,
applied,
previous,
"firewall apply transaction timed out".to_string(),
)
.await
}
}
}
async fn rollback_after_failure<R: FirewallCommandRunner>(
runner: &R,
applied: &mut AppliedState,
previous: AppliedPlan,
apply_error: String,
) -> Result<(), ReconcileFailure> {
let rollback = tokio::time::timeout(TRANSACTION_TIMEOUT, restore_plan(runner, &previous)).await;
let rollback_result = match rollback {
Ok(result) => result,
Err(_) => Err(CommandError::failed("firewall rollback timed out")),
};
match rollback_result {
Ok(()) => {
*applied = AppliedState::Known(previous);
Err(ReconcileFailure {
message: apply_error,
rollback_succeeded: Some(true),
cancelled: false,
})
}
Err(rollback_error) => {
*applied = AppliedState::Unknown;
if rollback_error.kind == CommandErrorKind::Cancelled {
return Err(cancelled_failure());
}
Err(ReconcileFailure {
message: format!("{apply_error}; rollback failed: {rollback_error}"),
rollback_succeeded: Some(false),
cancelled: false,
})
}
}
}
fn cancelled_failure() -> ReconcileFailure {
ReconcileFailure {
message: "firewall transaction cancelled for process shutdown".to_string(),
rollback_succeeded: None,
cancelled: true,
}
}
pub(super) fn resolve_target<R: FirewallCommandRunner>(
runner: &R,
desired: &DesiredPolicy,
previous: &AppliedPlan,
) -> Result<AppliedPlan, CommandError> {
let DesiredPolicy::Rules {
configured_backend,
v4,
v6,
} = desired
else {
return Ok(AppliedPlan::Empty);
};
if !runner.has_cap_net_admin() {
return Err(CommandError::failed(
"CAP_NET_ADMIN is required for conntrack firewall reconciliation",
));
}
let next_slot = previous.slot().map_or(ShadowSlot::A, ShadowSlot::other);
let iptables_available = (v4.is_empty() || iptables::family_available(runner, IpFamily::V4))
&& (v6.is_empty() || iptables::family_available(runner, IpFamily::V6));
match configured_backend {
ConntrackBackend::Nftables if nftables::available(runner) => Ok(AppliedPlan::Nftables {
slot: next_slot,
v4: v4.clone(),
v6: v6.clone(),
}),
ConntrackBackend::Iptables if iptables_available => Ok(AppliedPlan::Iptables {
slot: next_slot,
v4: v4.clone(),
v6: v6.clone(),
}),
ConntrackBackend::Auto if nftables::available(runner) => Ok(AppliedPlan::Nftables {
slot: next_slot,
v4: v4.clone(),
v6: v6.clone(),
}),
ConntrackBackend::Auto if iptables_available => Ok(AppliedPlan::Iptables {
slot: next_slot,
v4: v4.clone(),
v6: v6.clone(),
}),
backend => Err(CommandError::failed(format!(
"configured conntrack firewall backend {backend:?} is unavailable"
))),
}
}
pub(super) async fn transition_plan<R: FirewallCommandRunner>(
runner: &R,
previous: &AppliedPlan,
target: &AppliedPlan,
) -> Result<(), CommandError> {
install_plan(runner, target).await?;
match (previous, target) {
(
AppliedPlan::Iptables {
v4: previous_v4,
v6: previous_v6,
..
},
AppliedPlan::Iptables { v4, v6, .. },
) => {
if !previous_v4.is_empty() && v4.is_empty() {
iptables::activate_family(runner, IpFamily::V4, None).await?;
}
if !previous_v6.is_empty() && v6.is_empty() {
iptables::activate_family(runner, IpFamily::V6, None).await?;
}
}
(AppliedPlan::Nftables { slot: previous_slot, .. }, AppliedPlan::Nftables { slot, .. })
if previous_slot != slot =>
{
nftables::deactivate(runner, *previous_slot).await?;
}
(_, _) if previous != target => clear_plan(runner, previous).await?,
_ => {}
}
Ok(())
}
async fn install_plan<R: FirewallCommandRunner>(
runner: &R,
plan: &AppliedPlan,
) -> Result<(), CommandError> {
match plan {
AppliedPlan::Empty => Ok(()),
AppliedPlan::Iptables { slot, v4, v6 } => {
if !v4.is_empty() {
iptables::stage_family(runner, IpFamily::V4, *slot, v4).await?;
}
if !v6.is_empty() {
iptables::stage_family(runner, IpFamily::V6, *slot, v6).await?;
}
if !v4.is_empty() {
iptables::activate_family(runner, IpFamily::V4, Some(*slot)).await?;
}
if !v6.is_empty() {
iptables::activate_family(runner, IpFamily::V6, Some(*slot)).await?;
}
Ok(())
}
AppliedPlan::Nftables { slot, v4, v6 } => {
nftables::stage(runner, *slot, v4, v6).await?;
nftables::activate(runner, *slot).await
}
}
}
async fn clear_plan<R: FirewallCommandRunner>(
runner: &R,
plan: &AppliedPlan,
) -> Result<(), CommandError> {
match plan {
AppliedPlan::Empty => Ok(()),
AppliedPlan::Iptables { v4, v6, .. } => {
if !v4.is_empty() {
iptables::activate_family(runner, IpFamily::V4, None).await?;
}
if !v6.is_empty() {
iptables::activate_family(runner, IpFamily::V6, None).await?;
}
Ok(())
}
AppliedPlan::Nftables { slot, .. } => nftables::deactivate(runner, *slot).await,
}
}
async fn restore_plan<R: FirewallCommandRunner>(
runner: &R,
plan: &AppliedPlan,
) -> Result<(), CommandError> {
recover_to_empty(runner).await?;
install_plan(runner, plan).await
}
pub(super) async fn recover_to_empty<R: FirewallCommandRunner>(
runner: &R,
) -> Result<(), CommandError> {
let nft_result = nftables::cleanup_all(runner).await;
let iptables_result = iptables::cleanup_all(runner).await;
match (nft_result, iptables_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(first), Ok(())) | (Ok(()), Err(first)) => Err(first),
(Err(first), Err(second)) => Err(CommandError::failed(format!(
"{}; {}",
first.message, second.message
))),
}
}
+33
View File
@@ -133,6 +133,21 @@ impl ProcessControlPlane {
Ok(())
}
/// Registers a cooperatively cancelled task whose cleanup future must finish.
pub(crate) fn spawn_cooperative<S, F>(&self, spawn: S) -> Result<(), S>
where
S: FnOnce(CancellationToken) -> F,
F: Future<Output = ()> + Send + 'static,
{
let Some(registration) = self.inner.admission.try_register() else {
return Err(spawn);
};
let cancellation = self.inner.cancellation.clone();
self.inner.tasks.spawn(spawn(cancellation));
drop(registration);
Ok(())
}
/// Closes task admission, cancels all owned work, and joins it within the deadline.
pub(crate) async fn shutdown(&self, timeout: Duration) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
@@ -254,4 +269,22 @@ mod tests {
assert!(shutdown.await.unwrap());
assert!(completed.load(Ordering::Acquire));
}
#[tokio::test]
async fn cooperative_task_observes_cancellation_and_finishes_cleanup() {
let scope = ProcessControlPlane::new();
let completed = Arc::new(AtomicBool::new(false));
let completed_task = Arc::clone(&completed);
assert!(
scope
.spawn_cooperative(move |cancellation| async move {
cancellation.cancelled().await;
completed_task.store(true, Ordering::Release);
})
.is_ok()
);
assert!(scope.shutdown(Duration::from_secs(1)).await);
assert!(completed.load(Ordering::Acquire));
}
}
+38 -4
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use tokio::sync::{RwLock, Semaphore, watch};
use tracing::{error, info};
use tracing::{error, info, warn};
use crate::api;
use crate::ip_tracker::UserIpTracker;
@@ -40,7 +40,7 @@ pub(super) async fn run_telemt_core(
process_started_at,
process_started_at_epoch_secs,
startup_tracker,
config,
mut config,
config_path,
has_rust_log,
effective_log_level,
@@ -48,6 +48,13 @@ pub(super) async fn run_telemt_core(
logging_guard: _logging_guard,
} = bootstrap::bootstrap(privilege_drop_requested).await?;
if privilege_drop_requested && config.server.conntrack_control.inline_conntrack_control {
warn!(
"Inline conntrack control is disabled when process privileges are dropped"
);
config.server.conntrack_control.inline_conntrack_control = false;
}
let quota_store = Arc::new(QuotaStore::default());
let connection_authority = Arc::new(UserConnectionAuthority::default());
let stats = Arc::new(Stats::with_process_authorities(
@@ -367,9 +374,26 @@ pub(super) async fn run_telemt_core(
.await
.map_err(std::io::Error::other)?;
#[cfg(target_os = "linux")]
let conntrack_firewall = {
let authority = crate::conntrack_control::FirewallAuthority::spawn(&process_control_plane)
.map_err(std::io::Error::other)?;
if !authority
.publish_initial(1, runtime.config.clone(), stats.clone())
.await
{
warn!(
"Initial conntrack firewall reconciliation failed; background retries remain active"
);
}
Some(authority)
};
#[cfg(not(target_os = "linux"))]
let conntrack_firewall = None::<crate::conntrack_control::FirewallAuthority>;
drop_after_bind();
runtime_tasks::spawn_metrics_if_configured(
if let Err(error) = runtime_tasks::spawn_metrics_if_configured(
&runtime.config,
&startup_tracker,
active_runtime.clone(),
@@ -377,7 +401,15 @@ pub(super) async fn run_telemt_core(
tls_full_cert_budget.clone(),
process_control_plane.clone(),
)
.await?;
.await
{
if let Some(conntrack_firewall) = &conntrack_firewall
&& !conntrack_firewall.shutdown_and_clear().await
{
warn!("Conntrack firewall cleanup failed after metrics startup error");
}
return Err(error.into());
}
runtime_watch_tx.send_replace(Some(active_runtime.load_full().watch_state()));
active_runtime_tx.send_replace(Some(active_runtime.clone()));
@@ -401,6 +433,7 @@ pub(super) async fn run_telemt_core(
runtime_watch_tx,
listener_manager,
web_trace,
conntrack_firewall.clone(),
);
shutdown::spawn_signal_handlers(
@@ -413,6 +446,7 @@ pub(super) async fn run_telemt_core(
active_runtime,
quota_state,
reload_supervisor,
conntrack_firewall,
process_control_plane,
)
.await;
+18
View File
@@ -8,6 +8,7 @@ use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use crate::conntrack_control::FirewallAuthority;
use crate::stats::QuotaStore;
use crate::tls_front::cache::TlsFullCertBudget;
use crate::web::trace::WebTraceStore;
@@ -33,6 +34,7 @@ pub(crate) struct ReloadSupervisor {
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
listener_manager: Arc<Mutex<ListenerManager>>,
web_trace: Arc<WebTraceStore>,
conntrack_firewall: Option<FirewallAuthority>,
}
/// Process-owned handle that quiesces reloads before shutdown snapshots the runtime.
@@ -106,6 +108,7 @@ impl ReloadSupervisor {
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
listener_manager: ListenerManager,
web_trace: Arc<WebTraceStore>,
conntrack_firewall: Option<FirewallAuthority>,
) -> ReloadSupervisorHandle {
let listener_manager = Arc::new(Mutex::new(listener_manager));
let supervisor = Self {
@@ -120,6 +123,7 @@ impl ReloadSupervisor {
runtime_watch_tx,
listener_manager: listener_manager.clone(),
web_trace,
conntrack_firewall,
};
let control = supervisor.control.clone();
let shutdown = CancellationToken::new();
@@ -339,6 +343,14 @@ impl ReloadSupervisor {
old_runtime.stop_accepting_sessions();
listener_manager.activate_runtime_generation(new_runtime.clone())
};
let conntrack_firewall_published = match &self.conntrack_firewall {
Some(conntrack_firewall) => conntrack_firewall.publish(
new_runtime.id,
new_runtime.config(),
new_runtime.stats.clone(),
),
None => true,
};
self.web_trace
.apply_policy(new_runtime.id, &new_runtime.config().web.debug);
config_watcher_activation.send_replace(true);
@@ -353,6 +365,12 @@ impl ReloadSupervisor {
.apply_reload(&new_runtime.config().general.log_level);
self.runtime_watch_tx
.send_replace(Some(new_runtime.watch_state()));
if !conntrack_firewall_published {
let warning = "conntrack firewall reconciler is unavailable after runtime activation"
.to_string();
warn!(reload_id = command.reload_id, warning = %warning);
self.control.add_warning(command.reload_id, warning).await;
}
info!(
reload_id = command.reload_id,
+2
View File
@@ -61,6 +61,7 @@ async fn fixture(request: ReloadRequest) -> ReloadFixture {
runtime_watch_tx,
listener_manager,
web_trace,
conntrack_firewall: None,
});
let command = ReloadCommand {
reload_id: accepted.reload_id,
@@ -275,6 +276,7 @@ async fn quiesce_joins_idle_supervisor_and_rejects_later_submissions() {
runtime.config().web.debug.clone(),
&runtime.config().web.limits,
),
None,
);
tokio::time::timeout(Duration::from_secs(1), handle.quiesce())
+6
View File
@@ -408,6 +408,12 @@ pub(crate) fn resolve_reload_config(
fields.push("server.max_connections".to_string());
effective.server.max_connections = old.server.max_connections;
}
if serde_json::to_value(&old.server.conntrack_control).ok()
!= serde_json::to_value(&desired.server.conntrack_control).ok()
{
fields.push("server.conntrack_control".to_string());
effective.server.conntrack_control = old.server.conntrack_control.clone();
}
if old.general.direct_relay_buffer_budget_max_bytes
!= desired.general.direct_relay_buffer_budget_max_bytes
{
+36
View File
@@ -127,6 +127,42 @@ fn process_wide_connection_and_direct_buffer_envelopes_are_restart_only() {
assert!(!resolved.runtime_changed);
}
#[test]
fn conntrack_control_policy_is_restart_only_as_one_process_owned_unit() {
let old = ProxyConfig::default();
let mut desired = old.clone();
desired.server.conntrack_control.inline_conntrack_control =
!old.server.conntrack_control.inline_conntrack_control;
desired.server.conntrack_control.mode = crate::config::ConntrackMode::Notrack;
desired.server.conntrack_control.backend = crate::config::ConntrackBackend::Iptables;
desired.server.conntrack_control.profile =
crate::config::ConntrackPressureProfile::Aggressive;
desired.server.conntrack_control.hybrid_listener_ips =
vec!["192.0.2.10".parse().unwrap()];
desired
.server
.conntrack_control
.pressure_high_watermark_pct = 90;
desired.server.conntrack_control.pressure_low_watermark_pct = 40;
desired.server.conntrack_control.delete_budget_per_sec = old
.server
.conntrack_control
.delete_budget_per_sec
.saturating_add(1);
let resolved = resolve_reload_config(&old, &desired).unwrap();
assert_eq!(
resolved.deferred_process_fields,
vec!["server.conntrack_control".to_string()]
);
assert_eq!(
serde_json::to_value(&resolved.effective.server.conntrack_control).unwrap(),
serde_json::to_value(&old.server.conntrack_control).unwrap()
);
assert!(!resolved.runtime_changed);
}
#[test]
fn mixed_reload_retains_process_state_and_applies_runtime_state() {
let old = ProxyConfig::default();
+10
View File
@@ -23,6 +23,7 @@ use super::control_plane::ProcessControlPlane;
use super::generation::RuntimeGeneration;
use super::helpers::{format_uptime, unit_label};
use super::reload_supervisor::ReloadSupervisorHandle;
use crate::conntrack_control::FirewallAuthority;
use crate::quota_state::QuotaStateOwner;
use crate::stats::Stats;
use crate::synlimit_control;
@@ -54,6 +55,7 @@ pub(crate) async fn wait_for_shutdown(
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
quota_state: Arc<QuotaStateOwner>,
reload_supervisor: ReloadSupervisorHandle,
conntrack_firewall: Option<FirewallAuthority>,
process_control_plane: ProcessControlPlane,
) {
let signal = wait_for_shutdown_signal().await;
@@ -63,6 +65,7 @@ pub(crate) async fn wait_for_shutdown(
active_runtime,
quota_state,
reload_supervisor,
conntrack_firewall,
process_control_plane,
)
.await;
@@ -95,6 +98,7 @@ async fn perform_shutdown(
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
quota_state: Arc<QuotaStateOwner>,
reload_supervisor: ReloadSupervisorHandle,
conntrack_firewall: Option<FirewallAuthority>,
process_control_plane: ProcessControlPlane,
) {
let shutdown_started_at = Instant::now();
@@ -126,6 +130,12 @@ async fn perform_shutdown(
warn!("ME shutdown: pool lifecycle deadline expired");
}
if let Some(conntrack_firewall) = conntrack_firewall
&& !conntrack_firewall.shutdown_and_clear().await
{
warn!("Conntrack firewall cleanup did not complete successfully");
}
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
}
+47
View File
@@ -266,6 +266,53 @@ pub(super) fn render(
}
);
let _ = writeln!(
out,
"# HELP telemt_conntrack_rule_reconcile_total Conntrack firewall reconciliations by result"
);
let _ = writeln!(out, "# TYPE telemt_conntrack_rule_reconcile_total counter");
let _ = writeln!(
out,
"telemt_conntrack_rule_reconcile_total{{result=\"success\"}} {}",
if core_enabled {
stats.get_conntrack_rule_reconcile_success_total()
} else {
0
}
);
let _ = writeln!(
out,
"telemt_conntrack_rule_reconcile_total{{result=\"error\"}} {}",
if core_enabled {
stats.get_conntrack_rule_reconcile_error_total()
} else {
0
}
);
let _ = writeln!(
out,
"# HELP telemt_conntrack_rule_rollback_total Conntrack firewall rollbacks by result"
);
let _ = writeln!(out, "# TYPE telemt_conntrack_rule_rollback_total counter");
let _ = writeln!(
out,
"telemt_conntrack_rule_rollback_total{{result=\"success\"}} {}",
if core_enabled {
stats.get_conntrack_rule_rollback_success_total()
} else {
0
}
);
let _ = writeln!(
out,
"telemt_conntrack_rule_rollback_total{{result=\"error\"}} {}",
if core_enabled {
stats.get_conntrack_rule_rollback_error_total()
} else {
0
}
);
let _ = writeln!(
out,
"# HELP telemt_conntrack_event_queue_depth Pending close events in conntrack control queue"
+10
View File
@@ -43,6 +43,10 @@ async fn test_render_metrics_format() {
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
stats.increment_handshake_timeouts();
stats.increment_handshake_failure_class("timeout");
stats.increment_conntrack_rule_reconcile_success_total();
stats.increment_conntrack_rule_reconcile_error_total();
stats.increment_conntrack_rule_rollback_success_total();
stats.increment_conntrack_rule_rollback_error_total();
shared_state
.handshake
.auth_expensive_checks_total
@@ -123,6 +127,12 @@ async fn test_render_metrics_format() {
);
assert!(output.contains("telemt_handshake_timeouts_total 1"));
assert!(output.contains("telemt_handshake_failures_by_class_total{class=\"timeout\"} 1"));
assert!(
output.contains("telemt_conntrack_rule_reconcile_total{result=\"success\"} 1")
);
assert!(output.contains("telemt_conntrack_rule_reconcile_total{result=\"error\"} 1"));
assert!(output.contains("telemt_conntrack_rule_rollback_total{result=\"success\"} 1"));
assert!(output.contains("telemt_conntrack_rule_rollback_total{result=\"error\"} 1"));
assert!(output.contains("telemt_auth_expensive_checks_total 9"));
assert!(output.contains("telemt_auth_budget_exhausted_total 2"));
assert!(output.contains("telemt_upstream_connect_attempt_total 2"));
+32
View File
@@ -135,6 +135,38 @@ impl Stats {
.store(ok, Ordering::Relaxed);
}
/// Records one successful process-owned firewall reconciliation.
pub fn increment_conntrack_rule_reconcile_success_total(&self) {
if self.telemetry_core_enabled() {
self.conntrack_rule_reconcile_success_total
.fetch_add(1, Ordering::Relaxed);
}
}
/// Records one failed process-owned firewall reconciliation.
pub fn increment_conntrack_rule_reconcile_error_total(&self) {
if self.telemetry_core_enabled() {
self.conntrack_rule_reconcile_error_total
.fetch_add(1, Ordering::Relaxed);
}
}
/// Records one successful firewall transaction rollback.
pub fn increment_conntrack_rule_rollback_success_total(&self) {
if self.telemetry_core_enabled() {
self.conntrack_rule_rollback_success_total
.fetch_add(1, Ordering::Relaxed);
}
}
/// Records one rollback failure that leaves applied firewall state unknown.
pub fn increment_conntrack_rule_rollback_error_total(&self) {
if self.telemetry_core_enabled() {
self.conntrack_rule_rollback_error_total
.fetch_add(1, Ordering::Relaxed);
}
}
pub fn increment_conntrack_delete_attempt_total(&self) {
if self.telemetry_core_enabled() {
self.conntrack_delete_attempt_total
+20
View File
@@ -84,6 +84,26 @@ impl Stats {
pub fn get_conntrack_rule_apply_ok(&self) -> bool {
self.conntrack_rule_apply_ok_gauge.load(Ordering::Relaxed)
}
/// Returns successful process-owned firewall reconciliations.
pub fn get_conntrack_rule_reconcile_success_total(&self) -> u64 {
self.conntrack_rule_reconcile_success_total
.load(Ordering::Relaxed)
}
/// Returns failed process-owned firewall reconciliations.
pub fn get_conntrack_rule_reconcile_error_total(&self) -> u64 {
self.conntrack_rule_reconcile_error_total
.load(Ordering::Relaxed)
}
/// Returns successful firewall transaction rollbacks.
pub fn get_conntrack_rule_rollback_success_total(&self) -> u64 {
self.conntrack_rule_rollback_success_total
.load(Ordering::Relaxed)
}
/// Returns rollback failures that left applied firewall state unknown.
pub fn get_conntrack_rule_rollback_error_total(&self) -> u64 {
self.conntrack_rule_rollback_error_total
.load(Ordering::Relaxed)
}
pub fn get_conntrack_delete_attempt_total(&self) -> u64 {
self.conntrack_delete_attempt_total.load(Ordering::Relaxed)
}
+4
View File
@@ -150,6 +150,10 @@ pub struct Stats {
conntrack_pressure_active_gauge: AtomicBool,
conntrack_event_queue_depth_gauge: AtomicU64,
conntrack_rule_apply_ok_gauge: AtomicBool,
conntrack_rule_reconcile_success_total: AtomicU64,
conntrack_rule_reconcile_error_total: AtomicU64,
conntrack_rule_rollback_success_total: AtomicU64,
conntrack_rule_rollback_error_total: AtomicU64,
conntrack_delete_attempt_total: AtomicU64,
conntrack_delete_success_total: AtomicU64,
conntrack_delete_not_found_total: AtomicU64,
+10 -1
View File
@@ -2,10 +2,12 @@ use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
const TRUSTED_HELPER_DIRS: [&str; 4] = ["/usr/sbin", "/usr/bin", "/sbin", "/bin"];
const TRUSTED_HELPERS: [&str; 10] = [
const TRUSTED_HELPERS: [&str; 12] = [
"nft",
"iptables",
"iptables-restore",
"ip6tables",
"ip6tables-restore",
"conntrack",
"pfctl",
"systemctl",
@@ -69,6 +71,13 @@ mod tests {
assert!(resolve_trusted_helper("../bin/nft").is_none());
}
#[test]
fn privileged_helper_allowlist_accepts_exact_restore_names() {
assert!(TRUSTED_HELPERS.contains(&"iptables-restore"));
assert!(TRUSTED_HELPERS.contains(&"ip6tables-restore"));
assert!(!TRUSTED_HELPERS.contains(&"iptables-restore-wrapper"));
}
#[test]
fn writable_executable_is_not_trusted() {
let directory = tempfile::tempdir().unwrap();