Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion aifw-api/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,109 @@ fn apply_fail(step: &str, e: impl std::fmt::Display) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}

/// Render and validate every required target resource before the first
/// destructive DELETE. Older restore code discovered malformed rows halfway
/// through apply and silently skipped them, making rollback the normal
/// validation path and allowing partial-success responses.
fn validate_restore_target(
config: &FirewallConfig,
iface_map: &InterfaceMap,
) -> Result<(), StatusCode> {
use aifw_common::{Address, AliasType, CountryCode};

for rule in &config.rules {
if rule
.interface
.as_deref()
.is_some_and(|name| map_iface(name, iface_map).is_none())
{
continue;
}
rule_from_config(rule)
.ok_or_else(|| apply_fail(&format!("validating rule {}", rule.id), "invalid rule"))?;
}
for nat in &config.nat {
if map_iface(&nat.interface, iface_map).is_none() {
continue;
}
nat_from_config(nat).ok_or_else(|| {
apply_fail(
&format!("validating nat rule {}", nat.id),
"invalid NAT rule",
)
})?;
}
for alias in &config.aliases {
AliasType::parse(&alias.alias_type).ok_or_else(|| {
apply_fail(
&format!("validating alias {}", alias.name),
"invalid alias type",
)
})?;
}
for route in &config.static_routes {
crate::routes::validate_route_target(&route.destination).map_err(|_| {
apply_fail(
&format!("validating route {}", route.destination),
"invalid destination",
)
})?;
crate::routes::validate_route_target(&route.gateway).map_err(|_| {
apply_fail(
&format!("validating route {}", route.destination),
"invalid gateway",
)
})?;
}
for geo in &config.geoip {
CountryCode::new(&geo.country)
.map_err(|e| apply_fail(&format!("validating geo-ip {}", geo.id), e))?;
}
for tunnel in &config.vpn.wireguard {
if map_iface(&tunnel.interface, iface_map).is_none() {
continue;
}
Address::parse(&tunnel.address)
.map_err(|e| apply_fail(&format!("validating wireguard {}", tunnel.name), e))?;
for peer in &tunnel.peers {
for address in &peer.allowed_ips {
Address::parse(address).map_err(|e| {
apply_fail(&format!("validating wireguard peer {}", peer.name), e)
})?;
}
}
}
for sa in &config.vpn.ipsec {
Address::parse(&sa.src_addr)
.map_err(|e| apply_fail(&format!("validating ipsec {}", sa.name), e))?;
Address::parse(&sa.dst_addr)
.map_err(|e| apply_fail(&format!("validating ipsec {}", sa.name), e))?;
}
for queue in &config.queues {
if map_iface(&queue.interface, iface_map).is_none() {
continue;
}
queue_from_config(queue).ok_or_else(|| {
apply_fail(
&format!("validating shaping queue {}", queue.name),
"invalid queue",
)
})?;
}
for limit in &config.rate_limits {
if limit
.interface
.as_deref()
.is_some_and(|name| map_iface(name, iface_map).is_none())
{
continue;
}
rate_limit_from_config(limit)
.ok_or_else(|| apply_fail("validating rate limit", "invalid rate limit"))?;
}
Ok(())
}

/// Restore-with-rollback wrapper around [`apply_firewall_config`] (#535).
///
/// Captures the current running config first, applies the target strictly,
Expand All @@ -1529,6 +1632,7 @@ pub(crate) async fn apply_firewall_config_or_rollback(
config: &FirewallConfig,
iface_map: &InterfaceMap,
) -> Result<(), StatusCode> {
validate_restore_target(config, iface_map)?;
let snapshot = build_current_config(state).await?;
let Err(apply_err) = apply_firewall_config(state, config, iface_map).await else {
return Ok(());
Expand Down Expand Up @@ -1580,6 +1684,8 @@ pub(crate) async fn apply_firewall_config(
Address, CountryCode, GeoIpRule, Interface, IpsecSa, VpnStatus, WgPeer, WgTunnel,
};

validate_restore_target(config, iface_map)?;

// Restore preludes — clear existing rows before re-populating from the
// imported config. A failed DELETE (locked DB, FK violation, schema
// drift) used to warn and continue, leaving stale rows in a half-imported
Expand Down Expand Up @@ -1707,7 +1813,8 @@ pub(crate) async fn apply_firewall_config(
iface_after.as_deref(),
rc.fib,
)
.await;
.await
.map_err(|e| apply_fail(&format!("static route {} apply", rc.destination), e))?;
}
}

Expand Down
6 changes: 5 additions & 1 deletion aifw-api/src/opnsense/importer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1504,7 +1504,11 @@ async fn apply_routes(
// Attempt to program the kernel route. Mirrors what
// `routes::create_static_route` does for a single-route
// creation request.
crate::routes::apply_route_to_system(&r.network, gateway, None, 0).await;
if let Err(e) =
crate::routes::apply_route_to_system(&r.network, gateway, None, 0).await
{
tracing::warn!(error = %e, "opnsense import: route apply failed");
}
tracker.static_route_ids.push(id);
summary.static_routes += 1;
}
Expand Down
26 changes: 18 additions & 8 deletions aifw-api/src/routes/static_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,16 @@ pub async fn create_static_route(
.map_err(|_| bad_request())?;

// Apply to system if enabled
if enabled {
apply_route_to_system(
if enabled
&& let Err(e) = apply_route_to_system(
&req.destination,
&req.gateway,
req.interface.as_deref(),
fib,
)
.await;
.await
{
tracing::warn!(error = %e, "route create: system apply failed; route remains disabled until startup retry");
}

let route = StaticRoute {
Expand Down Expand Up @@ -169,14 +171,16 @@ pub async fn update_static_route(
.await
.map_err(|_| internal())?;

if enabled {
apply_route_to_system(
if enabled
&& let Err(e) = apply_route_to_system(
&req.destination,
&req.gateway,
req.interface.as_deref(),
fib,
)
.await;
.await
{
tracing::warn!(error = %e, "route update: system apply failed");
}

let now = chrono::Utc::now().to_rfc3339();
Expand Down Expand Up @@ -227,7 +231,7 @@ pub(crate) async fn apply_route_to_system(
gateway: &str,
interface: Option<&str>,
fib: u32,
) {
) -> Result<(), String> {
let fib_s = fib.to_string();
let mut args: Vec<&str> = Vec::new();
args.push("/sbin/route");
Expand All @@ -249,6 +253,7 @@ pub(crate) async fn apply_route_to_system(
match output {
Ok(o) if o.status.success() => {
tracing::info!(destination, gateway, fib, "route added");
Ok(())
}
Ok(o) => {
let err = String::from_utf8_lossy(&o.stderr);
Expand All @@ -257,12 +262,15 @@ pub(crate) async fn apply_route_to_system(
// "route: writing to routing socket: File exists" on FreeBSD.
if err.contains("File exists") {
tracing::debug!(destination, gateway, fib, "route already present, skipping");
Ok(())
} else {
tracing::warn!(destination, gateway, fib, error = %err, "route add failed");
Err(format!("route add failed: {err}"))
}
}
Err(e) => {
tracing::warn!(destination, gateway, fib, error = %e, "route command failed");
Err(format!("route command failed: {e}"))
}
}
}
Expand Down Expand Up @@ -317,6 +325,8 @@ pub async fn apply_all_routes(pool: &sqlx::SqlitePool) {

tracing::info!(count = routes.len(), "applying static routes on startup");
for (dest, gw, iface, fib) in &routes {
apply_route_to_system(dest, gw, iface.as_deref(), *fib as u32).await;
if let Err(e) = apply_route_to_system(dest, gw, iface.as_deref(), *fib as u32).await {
tracing::warn!(destination = %dest, error = %e, "route startup apply failed");
}
}
}
32 changes: 32 additions & 0 deletions aifw-api/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2296,6 +2296,38 @@ mod tests {
assert!(res.is_err(), "partial apply must not report success");
}

#[tokio::test]
async fn test_restore_preflight_rejects_invalid_target_without_mutation() {
let state = crate::create_app_state_in_memory(plain_auth_settings())
.await
.unwrap();
let mut existing = aifw_common::Rule::new(
aifw_common::Action::Block,
aifw_common::Direction::In,
aifw_common::Protocol::Tcp,
aifw_common::RuleMatch {
src_addr: aifw_common::Address::Any,
src_port: None,
dst_addr: aifw_common::Address::Any,
dst_port: None,
},
);
existing.label = Some("must-survive-preflight".into());
let existing_id = existing.id;
state.rule_engine.add_rule(existing).await.unwrap();

let mut config = crate::backup::build_current_config(&state).await.unwrap();
config.rules[0].src_addr = Some("definitely-not-an-address".into());
let res =
crate::backup::apply_firewall_config_or_rollback(&state, &config, &Default::default())
.await;
assert!(res.is_err());
assert!(
state.rule_engine.get_rule(existing_id).await.is_ok(),
"preflight failure must happen before destructive table clears"
);
}

// --- IPsec tunnels (#530) ---

fn ipsec_tunnel_body() -> Value {
Expand Down