Skip to content

Commit 7ed0df7

Browse files
committed
refactor(sandbox): replace iptables with nftables for network policy enforcement
Migrate all sandbox and VM driver network policy enforcement from iptables to nftables. nftables provides atomic ruleset loading, a cleaner rule syntax, and is the standard netfilter interface in modern kernels. Key changes: Sandbox bypass enforcement (openshell-sandbox): - Replace iptables chain of individual rule insertions with a single atomic nftables ruleset load via `nft -f` - Combine log and reject rules in one ruleset to ensure correct evaluation order (log before reject) - Fall back to reject-only ruleset when kernel lacks NFT_LOG support - Enable net.netfilter.nf_log_all_netns so log rules work from non-init network namespaces - Use temp file for nft ruleset loading instead of stdin for compatibility with minimal VM guest environments VM TAP networking (openshell-driver-vm): - Replace iptables NAT/forwarding rules with nftables equivalents - Add nft_ruleset module for TAP network rule generation with tests - Atomic table-per-TAP-device lifecycle (create/destroy) Closes #1335
1 parent 44e843e commit 7ed0df7

17 files changed

Lines changed: 436 additions & 506 deletions

File tree

crates/openshell-driver-podman/NETWORKING.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Namespace 2: Rootless Podman network namespace, managed by pasta
178178
Namespace 3: Inner sandbox netns, created by supervisor
179179
|
180180
veth pair, such as 10.200.0.1 <-> 10.200.0.2
181-
iptables forces ordinary traffic through proxy
181+
nftables forces ordinary traffic through proxy
182182
user workload runs here
183183
```
184184

@@ -270,7 +270,7 @@ Container on the Podman bridge
270270
|
271271
user code runs here
272272
|
273-
iptables rules:
273+
nftables rules:
274274
ACCEPT -> proxy TCP
275275
ACCEPT -> loopback
276276
ACCEPT -> established/related
@@ -337,7 +337,7 @@ User code in inner netns
337337
HTTP_PROXY points at the local sandbox proxy
338338
|
339339
2. TCP connect to proxy
340-
allowed by iptables as the only ordinary egress destination
340+
allowed by nftables as the only ordinary egress destination
341341
|
342342
3. HTTP CONNECT api.example.com:443
343343
|
@@ -398,7 +398,7 @@ bind-mounted into sandbox containers by the Podman driver.
398398
| Port publishing | Not needed for relay | Ephemeral host port remains in the container spec for compatibility and debug paths. |
399399
| TLS | mTLS via Kubernetes secrets | mTLS via mounted client files, RPM defaults, or explicit configuration. |
400400
| DNS | Kubernetes CoreDNS | Podman bridge DNS through aardvark-dns when DNS is enabled. |
401-
| Network policy | Kubernetes network policy for pod ingress plus supervisor policy | iptables inside inner sandbox netns plus supervisor policy. |
401+
| Network policy | Kubernetes network policy for pod ingress plus supervisor policy | nftables inside inner sandbox netns plus supervisor policy. |
402402
| Supervisor delivery | Kubernetes driver managed pod image or template | OCI image volume mount. |
403403
| Secrets | Kubernetes Secret volume and env vars | Mounted TLS client materials from a Podman secret. |
404404

crates/openshell-driver-podman/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ The restricted agent child does not retain these supervisor privileges.
5555
| Capability | Purpose |
5656
|---|---|
5757
| `SYS_ADMIN` | seccomp filter installation, namespace creation, and Landlock setup. |
58-
| `NET_ADMIN` | Network namespace veth setup, IP address assignment, routes, and iptables. |
58+
| `NET_ADMIN` | Network namespace veth setup, IP address assignment, routes, and nftables. |
5959
| `SYS_PTRACE` | Reading `/proc/<pid>/exe` and walking process ancestry for binary identity. |
6060
| `SYSLOG` | Reading `/dev/kmsg` for bypass-detection diagnostics. |
6161
| `DAC_READ_SEARCH` | Reading `/proc/<pid>/fd/` across UIDs so the proxy can resolve the binary responsible for a connection. |

crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@ wait
289289
hostname openshell-sandbox-vm 2>/dev/null || true
290290
ip link set lo up 2>/dev/null || true
291291

292+
# Allow nftables LOG rules to work in non-init network namespaces.
293+
# Without this, the kernel's nf_log_syslog silently suppresses output
294+
# from the sandbox's network namespace.
295+
if [ -f /proc/sys/net/netfilter/nf_log_all_netns ]; then
296+
echo 1 > /proc/sys/net/netfilter/nf_log_all_netns 2>/dev/null || true
297+
fi
298+
292299
# GPU initialization (before networking so nvidia-smi output is visible early)
293300
if [ "${GPU_ENABLED}" = "true" ]; then
294301
setup_gpu || ts "WARNING: GPU init failed; continuing without GPU"

crates/openshell-driver-vm/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod driver;
55
mod embedded_runtime;
66
mod ffi;
77
pub mod gpu;
8+
mod nft_ruleset;
89
pub mod procguard;
910
mod rootfs;
1011
mod runtime;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use std::fmt::Write;
5+
6+
/// Sanitize a TAP device name for use as an nftables table name suffix.
7+
/// Assumes device names match `vmtap-[a-f0-9]+` (driver-controlled).
8+
fn sanitize_table_name(device: &str) -> String {
9+
device.replace('-', "_")
10+
}
11+
12+
/// Return the nftables table name for a TAP device.
13+
pub fn teardown_table_name(device: &str) -> String {
14+
format!("openshell_vm_{}", sanitize_table_name(device))
15+
}
16+
17+
/// Generate the nftables ruleset for VM TAP networking.
18+
pub fn generate_tap_ruleset(tap_device: &str, subnet: &str, gateway_port: u16) -> String {
19+
let table_name = teardown_table_name(tap_device);
20+
let mut ruleset = String::with_capacity(512);
21+
22+
writeln!(ruleset, "table ip {table_name} {{").unwrap();
23+
writeln!(ruleset, " chain postrouting {{").unwrap();
24+
writeln!(
25+
ruleset,
26+
" type nat hook postrouting priority 100; policy accept;"
27+
)
28+
.unwrap();
29+
writeln!(ruleset, " ip saddr {subnet} masquerade").unwrap();
30+
writeln!(ruleset, " }}").unwrap();
31+
writeln!(ruleset, " chain forward {{").unwrap();
32+
writeln!(
33+
ruleset,
34+
" type filter hook forward priority 0; policy accept;"
35+
)
36+
.unwrap();
37+
writeln!(ruleset, " iifname \"{tap_device}\" accept").unwrap();
38+
writeln!(
39+
ruleset,
40+
" oifname \"{tap_device}\" ct state related,established accept"
41+
)
42+
.unwrap();
43+
writeln!(ruleset, " }}").unwrap();
44+
writeln!(ruleset, " chain input {{").unwrap();
45+
writeln!(
46+
ruleset,
47+
" type filter hook input priority 0; policy accept;"
48+
)
49+
.unwrap();
50+
writeln!(
51+
ruleset,
52+
" iifname \"{tap_device}\" tcp dport {gateway_port} accept"
53+
)
54+
.unwrap();
55+
writeln!(ruleset, " }}").unwrap();
56+
writeln!(ruleset, "}}").unwrap();
57+
58+
ruleset
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
65+
#[test]
66+
fn generates_tap_setup_ruleset() {
67+
let ruleset = generate_tap_ruleset("vmtap-abcd", "10.0.128.0/30", 8080);
68+
assert!(ruleset.contains("table ip openshell_vm_vmtap_abcd {"));
69+
assert!(ruleset.contains("type nat hook postrouting priority 100; policy accept;"));
70+
assert!(ruleset.contains("ip saddr 10.0.128.0/30 masquerade"));
71+
assert!(ruleset.contains("type filter hook forward priority 0; policy accept;"));
72+
assert!(ruleset.contains("iifname \"vmtap-abcd\" accept"));
73+
assert!(ruleset.contains("oifname \"vmtap-abcd\" ct state related,established accept"));
74+
assert!(ruleset.contains("type filter hook input priority 0; policy accept;"));
75+
assert!(ruleset.contains("iifname \"vmtap-abcd\" tcp dport 8080 accept"));
76+
}
77+
78+
#[test]
79+
fn table_name_sanitizes_device_name() {
80+
let ruleset = generate_tap_ruleset("vmtap-abc-123", "10.0.128.0/30", 8080);
81+
assert!(ruleset.contains("table ip openshell_vm_vmtap_abc_123 {"));
82+
}
83+
84+
#[test]
85+
fn teardown_command_targets_correct_table() {
86+
let cmd = teardown_table_name("vmtap-abcd");
87+
assert_eq!(cmd, "openshell_vm_vmtap_abcd");
88+
}
89+
}

crates/openshell-driver-vm/src/runtime.rs

Lines changed: 63 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::ptr;
1010
use std::sync::atomic::{AtomicI32, Ordering};
1111
use std::time::{Duration, Instant};
1212

13-
use crate::{embedded_runtime, ffi, procguard};
13+
use crate::{embedded_runtime, ffi, nft_ruleset, procguard};
1414

1515
pub const VM_RUNTIME_DIR_ENV: &str = "OPENSHELL_VM_RUNTIME_DIR";
1616

@@ -444,6 +444,12 @@ fn setup_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) -> R
444444
enable_ip_forwarding()?;
445445

446446
let subnet = tap_subnet_from_host_ip(host_ip);
447+
let table_name = nft_ruleset::teardown_table_name(tap_device);
448+
449+
// Delete any stale nftables table from a previous driver run.
450+
let _ = run_cmd("nft", &["delete", "table", "ip", &table_name]);
451+
452+
// Clean up legacy iptables rules from older driver versions.
447453
let _ = run_cmd(
448454
"iptables",
449455
&[
@@ -457,27 +463,10 @@ fn setup_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) -> R
457463
"MASQUERADE",
458464
],
459465
);
460-
run_cmd(
461-
"iptables",
462-
&[
463-
"-t",
464-
"nat",
465-
"-A",
466-
"POSTROUTING",
467-
"-s",
468-
&subnet,
469-
"-j",
470-
"MASQUERADE",
471-
],
472-
)?;
473466
let _ = run_cmd(
474467
"iptables",
475468
&["-D", "FORWARD", "-i", tap_device, "-j", "ACCEPT"],
476469
);
477-
run_cmd(
478-
"iptables",
479-
&["-A", "FORWARD", "-i", tap_device, "-j", "ACCEPT"],
480-
)?;
481470
let _ = run_cmd(
482471
"iptables",
483472
&[
@@ -493,43 +482,31 @@ fn setup_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) -> R
493482
"ACCEPT",
494483
],
495484
);
496-
run_cmd(
497-
"iptables",
498-
&[
499-
"-A",
500-
"FORWARD",
501-
"-o",
502-
tap_device,
503-
"-m",
504-
"state",
505-
"--state",
506-
"RELATED,ESTABLISHED",
507-
"-j",
508-
"ACCEPT",
509-
],
510-
)?;
511-
// Allow guest → host traffic only to the gateway gRPC port.
512-
// Previous versions accepted ALL inbound traffic from the TAP
513-
// interface; scope to the specific port so the guest cannot reach
514-
// other host services.
515485
let port_str = gateway_port.to_string();
516486
let _ = run_cmd(
517487
"iptables",
518488
&[
519489
"-D", "INPUT", "-i", tap_device, "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
520490
],
521491
);
522-
run_cmd(
492+
let _ = run_cmd(
523493
"iptables",
524-
&[
525-
"-A", "INPUT", "-i", tap_device, "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
526-
],
527-
)?;
494+
&["-D", "INPUT", "-i", tap_device, "-j", "ACCEPT"],
495+
);
496+
497+
// Load nftables ruleset atomically.
498+
let ruleset = nft_ruleset::generate_tap_ruleset(tap_device, &subnet, gateway_port);
499+
run_nft_stdin(&ruleset)?;
528500

529501
Ok(())
530502
}
531503

532504
fn teardown_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) {
505+
// Delete the entire nftables table — single atomic operation.
506+
let table_name = nft_ruleset::teardown_table_name(tap_device);
507+
let _ = run_cmd("nft", &["delete", "table", "ip", &table_name]);
508+
509+
// Clean up legacy iptables rules from older driver versions.
533510
let subnet = tap_subnet_from_host_ip(host_ip);
534511
let _ = run_cmd(
535512
"iptables",
@@ -550,8 +527,6 @@ fn teardown_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) {
550527
"iptables",
551528
&["-D", "FORWARD", "-i", tap_device, "-j", "ACCEPT"],
552529
);
553-
// Remove the port-scoped INPUT rule. Also try the legacy blanket
554-
// rule so stale rules from older driver versions are cleaned up.
555530
if gateway_port > 0 {
556531
let port_str = gateway_port.to_string();
557532
let _ = run_cmd(
@@ -578,6 +553,7 @@ fn teardown_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) {
578553
"MASQUERADE",
579554
],
580555
);
556+
581557
let _ = run_cmd("ip", &["link", "set", tap_device, "down"]);
582558
let _ = run_cmd("ip", &["tuntap", "del", "dev", tap_device, "mode", "tap"]);
583559
}
@@ -614,6 +590,35 @@ fn run_cmd(cmd: &str, args: &[&str]) -> Result<(), String> {
614590
}
615591
}
616592

593+
fn run_nft_stdin(ruleset: &str) -> Result<(), String> {
594+
use std::io::Write;
595+
596+
let mut child = StdCommand::new("nft")
597+
.args(["-f", "-"])
598+
.stdin(Stdio::piped())
599+
.stdout(Stdio::piped())
600+
.stderr(Stdio::piped())
601+
.spawn()
602+
.map_err(|e| format!("failed to run nft: {e}"))?;
603+
604+
if let Some(mut stdin) = child.stdin.take() {
605+
stdin
606+
.write_all(ruleset.as_bytes())
607+
.map_err(|e| format!("failed to write nft ruleset: {e}"))?;
608+
}
609+
610+
let output = child
611+
.wait_with_output()
612+
.map_err(|e| format!("failed to wait for nft: {e}"))?;
613+
614+
if output.status.success() {
615+
Ok(())
616+
} else {
617+
let stderr = String::from_utf8_lossy(&output.stderr);
618+
Err(format!("nft -f - failed: {stderr}"))
619+
}
620+
}
621+
617622
/// RAII guard that tears down TAP networking on drop.
618623
struct TapGuard {
619624
tap_device: String,
@@ -731,7 +736,7 @@ fn run_libkrun_vm(config: &VmLaunchConfig) -> Result<(), String> {
731736
// on its own service ports (DNS:53, DHCP, HTTP API:80).
732737
//
733738
// That network plane is also what the sandbox supervisor's
734-
// per-sandbox netns (veth pair + iptables, see
739+
// per-sandbox netns (veth pair + nftables, see
735740
// `openshell-sandbox/src/sandbox/linux/netns.rs`) branches off of;
736741
// libkrun's built-in TSI socket impersonation would not satisfy
737742
// those kernel-level primitives.
@@ -1384,4 +1389,17 @@ mod tests {
13841389
assert!(!cmdline.contains("VM_NET_DNS="));
13851390
assert!(!cmdline.contains("GPU_ENABLED="));
13861391
}
1392+
1393+
#[test]
1394+
fn tap_subnet_from_host_ip_calculates_slash30_base() {
1395+
assert_eq!(tap_subnet_from_host_ip("10.0.128.1"), "10.0.128.0/30");
1396+
assert_eq!(tap_subnet_from_host_ip("10.0.128.2"), "10.0.128.0/30");
1397+
assert_eq!(tap_subnet_from_host_ip("10.0.128.5"), "10.0.128.4/30");
1398+
}
1399+
1400+
#[test]
1401+
fn tap_subnet_from_host_ip_handles_invalid_ip() {
1402+
let result = tap_subnet_from_host_ip("not-an-ip");
1403+
assert_eq!(result, "not-an-ip/30");
1404+
}
13871405
}

crates/openshell-ocsf/src/events/network_activity.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::objects::{Actor, ConnectionInfo, Endpoint, FirewallRule};
1111

1212
/// OCSF Network Activity Event [4001].
1313
///
14-
/// Proxy CONNECT tunnel events and iptables-level bypass detection.
14+
/// Proxy CONNECT tunnel events and nftables bypass detection.
1515
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
1616
pub struct NetworkActivityEvent {
1717
/// Common base event fields.

crates/openshell-ocsf/src/format/shorthand.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ mod tests {
456456
actor: Some(Actor {
457457
process: Process::new("node", 1234),
458458
}),
459-
firewall_rule: Some(FirewallRule::new("bypass-detect", "iptables")),
459+
firewall_rule: Some(FirewallRule::new("bypass-detect", "nftables")),
460460
connection_info: Some(ConnectionInfo::new("tcp")),
461461
action: Some(ActionId::Denied),
462462
disposition: Some(DispositionId::Blocked),
@@ -467,7 +467,7 @@ mod tests {
467467
let shorthand = event.format_shorthand();
468468
assert_eq!(
469469
shorthand,
470-
"NET:REFUSE [MED] DENIED node(1234) -> 93.184.216.34:443/tcp [policy:bypass-detect engine:iptables]"
470+
"NET:REFUSE [MED] DENIED node(1234) -> 93.184.216.34:443/tcp [policy:bypass-detect engine:nftables]"
471471
);
472472
}
473473

crates/openshell-ocsf/src/objects/firewall_rule.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub struct FirewallRule {
1111
/// Rule name (e.g., "default-egress", "bypass-detect").
1212
pub name: String,
1313

14-
/// Rule type / engine (e.g., "mechanistic", "opa", "iptables").
14+
/// Rule type / engine (e.g., "mechanistic", "opa", "nftables").
1515
///
1616
/// Kept as `String` because this is a project-specific extension field
1717
/// (not OCSF-enumerated) with runtime-dynamic values from the policy engine.

crates/openshell-sandbox/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ libc = "0.2"
8585
[target.'cfg(target_os = "linux")'.dependencies]
8686
landlock = "0.4"
8787
seccompiler = "0.5"
88+
tempfile = "3"
8889
uuid = { version = "1", features = ["v4"] }
8990

9091
[dev-dependencies]
91-
tempfile = "3"
9292
temp-env = "0.3"
9393
tokio-tungstenite = { workspace = true }
9494
futures = { workspace = true }

0 commit comments

Comments
 (0)