Skip to content

Commit c5d1d76

Browse files
authored
refactor(sandbox): replace iptables with nftables for network policy enforcement (#1401)
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. Sandbox bypass enforcement (openshell-sandbox): - Replace iptables chain of individual rule insertions with a single atomic nftables ruleset load via nft -f - New nft_ruleset module with pure functions for ruleset generation and unit tests - Combine log and reject rules in one inet family table (handles both IPv4 and IPv6 in a single ruleset) - 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 - New nft_ruleset module for TAP network rule generation with unit tests - Atomic table-per-TAP-device lifecycle (create/destroy) - Host-side rules provide NAT infrastructure and defense-in-depth isolation (input chain restricts VM to gateway port only, forward chain blocks unsolicited inbound); primary security enforcement happens inside the VM guest via the sandbox supervisor's own rules VM init script: - Load nft kernel modules at sandbox init - Enable nf_log_all_netns sysctl for bypass detection logging OCSF / docs: - Update firewall rule engine references from iptables to nftables - Document host firewall interaction model and two-layer enforcement architecture in VM driver README and compute drivers reference Closes #1335 Signed-off-by: Russell Bryant <rbryant@redhat.com>
1 parent 702bb56 commit c5d1d76

19 files changed

Lines changed: 464 additions & 505 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
|
@@ -397,7 +397,7 @@ start and bind-mounted into sandbox containers by the Podman driver.
397397
| Port publishing | Not needed for relay | Ephemeral host port remains in the container spec for compatibility and debug paths. |
398398
| TLS | mTLS via Kubernetes secrets | mTLS via mounted client files, RPM defaults, or explicit configuration. |
399399
| DNS | Kubernetes CoreDNS | Podman bridge DNS through aardvark-dns when DNS is enabled. |
400-
| Network policy | Kubernetes network policy for pod ingress plus supervisor policy | iptables inside inner sandbox netns plus supervisor policy. |
400+
| Network policy | Kubernetes network policy for pod ingress plus supervisor policy | nftables inside inner sandbox netns plus supervisor policy. |
401401
| Supervisor delivery | Kubernetes driver managed pod image or template | OCI image volume mount. |
402402
| Secrets | Kubernetes Secret volume and env vars | Mounted TLS client materials from a Podman secret. |
403403

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/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,25 @@ RUST_LOG=openshell_server=debug,openshell_driver_vm=debug \
207207
208208
The VM guest's serial console is appended to `<state-dir>/<sandbox-id>/console.log`. Sandbox IDs must match `[A-Za-z0-9._-]{1,128}` before the driver uses them in host paths. The gateway-owned compute-driver socket lives at `<state-dir>/run/compute-driver.sock`; OpenShell creates `run/` with owner-only permissions, removes same-owner stale sockets, and the gateway removes the socket on clean shutdown via `ManagedDriverProcess::drop`. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit `--allow-same-uid-peer` development flag. TCP mode is disabled by default because it is unauthenticated; use `--allow-unauthenticated-tcp --bind-address 127.0.0.1:50061` only for local development.
209209
210+
## Host-side nftables rules
211+
212+
The VM driver creates a per-VM nftables table on the host (`openshell_vm_vmtap_<id>`) with three chains. These rules serve two purposes: NAT infrastructure (required for VM connectivity) and defense-in-depth host isolation. Primary security enforcement — proxy-only egress and bypass detection — is handled by the sandbox supervisor's own nftables rules inside the VM guest.
213+
214+
**`postrouting` (NAT):** Masquerades outbound VM traffic so it can be routed from the VM's private subnet to the external network. This chain handles forwarded traffic (VM → internet), not traffic destined for the host.
215+
216+
**`forward` (defense-in-depth):** Accepts all outbound traffic from the VM (security enforcement happens guest-side) and accepts established/related response traffic back to the VM. Drops unsolicited inbound connections to the VM from the broader network. This chain handles forwarded traffic only — packets transiting the host between the TAP interface and other interfaces.
217+
218+
**`input` (defense-in-depth):** Accepts traffic from the VM to the gateway port on the host. Drops all other traffic from the VM destined for the host itself. This limits what a compromised guest can reach on the host to the gateway service only.
219+
220+
The `input` and `postrouting` chains handle different traffic paths: `input` covers packets addressed to the host (VM → host), while `postrouting` covers packets the host is forwarding on behalf of the VM (VM → internet). A packet from the VM goes through one path or the other, never both.
221+
222+
All chains use `policy accept`, so non-TAP traffic is unaffected. Because nftables evaluates multiple base chains on the same hook independently, host firewalls interact with these rules as follows:
223+
224+
- **Open host (no other firewall):** Our chains are the only filter. The defense-in-depth drop rules block unsolicited inbound and non-gateway host access. Non-TAP traffic passes through.
225+
- **Restrictive host firewall (e.g. firewalld):** The host firewall's chains may additionally drop TAP traffic that our chains accept. A `drop` verdict from any chain is final — our `accept` cannot override it. If VM connectivity fails, verify that the host firewall allows forwarding and input for `vmtap-*` interfaces.
226+
227+
Each table is created atomically via `nft -f` on VM start and torn down atomically via `nft delete table` when the VM is destroyed.
228+
210229
## Prerequisites
211230
212231
- macOS on Apple Silicon, or Linux on aarch64/x86_64 with KVM

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,13 @@ run_post_overlay_setup() {
595595
mount -t cgroup2 cgroup2 "$(root_path /sys/fs/cgroup)" 2>/dev/null &
596596
wait
597597

598+
# Allow nftables LOG rules to work in non-init network namespaces.
599+
# Without this, the kernel's nf_log_syslog silently suppresses output
600+
# from the sandbox's network namespace.
601+
if [ -f /proc/sys/net/netfilter/nf_log_all_netns ]; then
602+
echo 1 > /proc/sys/net/netfilter/nf_log_all_netns 2>/dev/null || true
603+
fi
604+
598605
setup_sandbox_workdir
599606

600607
configure_hostname

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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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, " oifname \"{tap_device}\" drop").unwrap();
44+
writeln!(ruleset, " }}").unwrap();
45+
writeln!(ruleset, " chain input {{").unwrap();
46+
writeln!(
47+
ruleset,
48+
" type filter hook input priority 0; policy accept;"
49+
)
50+
.unwrap();
51+
writeln!(
52+
ruleset,
53+
" iifname \"{tap_device}\" tcp dport {gateway_port} accept"
54+
)
55+
.unwrap();
56+
writeln!(ruleset, " iifname \"{tap_device}\" drop").unwrap();
57+
writeln!(ruleset, " }}").unwrap();
58+
writeln!(ruleset, "}}").unwrap();
59+
60+
ruleset
61+
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::*;
66+
67+
#[test]
68+
fn generates_tap_setup_ruleset() {
69+
let ruleset = generate_tap_ruleset("vmtap-abcd", "10.0.128.0/30", 8080);
70+
assert!(ruleset.contains("table ip openshell_vm_vmtap_abcd {"));
71+
assert!(ruleset.contains("type nat hook postrouting priority 100; policy accept;"));
72+
assert!(ruleset.contains("ip saddr 10.0.128.0/30 masquerade"));
73+
assert!(ruleset.contains("type filter hook forward priority 0; policy accept;"));
74+
assert!(ruleset.contains("iifname \"vmtap-abcd\" accept"));
75+
assert!(ruleset.contains("oifname \"vmtap-abcd\" ct state related,established accept"));
76+
assert!(ruleset.contains("oifname \"vmtap-abcd\" drop"));
77+
assert!(ruleset.contains("type filter hook input priority 0; policy accept;"));
78+
assert!(ruleset.contains("iifname \"vmtap-abcd\" tcp dport 8080 accept"));
79+
}
80+
81+
#[test]
82+
fn table_name_sanitizes_device_name() {
83+
let ruleset = generate_tap_ruleset("vmtap-abc-123", "10.0.128.0/30", 8080);
84+
assert!(ruleset.contains("table ip openshell_vm_vmtap_abc_123 {"));
85+
}
86+
87+
#[test]
88+
fn teardown_command_targets_correct_table() {
89+
let cmd = teardown_table_name("vmtap-abcd");
90+
assert_eq!(cmd, "openshell_vm_vmtap_abcd");
91+
}
92+
}

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, rootfs};
13+
use crate::{embedded_runtime, ffi, nft_ruleset, procguard, rootfs};
1414

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

@@ -413,6 +413,12 @@ fn setup_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) -> R
413413
enable_ip_forwarding()?;
414414

415415
let subnet = tap_subnet_from_host_ip(host_ip);
416+
let table_name = nft_ruleset::teardown_table_name(tap_device);
417+
418+
// Delete any stale nftables table from a previous driver run.
419+
let _ = run_cmd("nft", &["delete", "table", "ip", &table_name]);
420+
421+
// Clean up legacy iptables rules from older driver versions.
416422
let _ = run_cmd(
417423
"iptables",
418424
&[
@@ -426,27 +432,10 @@ fn setup_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) -> R
426432
"MASQUERADE",
427433
],
428434
);
429-
run_cmd(
430-
"iptables",
431-
&[
432-
"-t",
433-
"nat",
434-
"-A",
435-
"POSTROUTING",
436-
"-s",
437-
&subnet,
438-
"-j",
439-
"MASQUERADE",
440-
],
441-
)?;
442435
let _ = run_cmd(
443436
"iptables",
444437
&["-D", "FORWARD", "-i", tap_device, "-j", "ACCEPT"],
445438
);
446-
run_cmd(
447-
"iptables",
448-
&["-A", "FORWARD", "-i", tap_device, "-j", "ACCEPT"],
449-
)?;
450439
let _ = run_cmd(
451440
"iptables",
452441
&[
@@ -462,43 +451,31 @@ fn setup_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) -> R
462451
"ACCEPT",
463452
],
464453
);
465-
run_cmd(
466-
"iptables",
467-
&[
468-
"-A",
469-
"FORWARD",
470-
"-o",
471-
tap_device,
472-
"-m",
473-
"state",
474-
"--state",
475-
"RELATED,ESTABLISHED",
476-
"-j",
477-
"ACCEPT",
478-
],
479-
)?;
480-
// Allow guest → host traffic only to the gateway gRPC port.
481-
// Previous versions accepted ALL inbound traffic from the TAP
482-
// interface; scope to the specific port so the guest cannot reach
483-
// other host services.
484454
let port_str = gateway_port.to_string();
485455
let _ = run_cmd(
486456
"iptables",
487457
&[
488458
"-D", "INPUT", "-i", tap_device, "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
489459
],
490460
);
491-
run_cmd(
461+
let _ = run_cmd(
492462
"iptables",
493-
&[
494-
"-A", "INPUT", "-i", tap_device, "-p", "tcp", "--dport", &port_str, "-j", "ACCEPT",
495-
],
496-
)?;
463+
&["-D", "INPUT", "-i", tap_device, "-j", "ACCEPT"],
464+
);
465+
466+
// Load nftables ruleset atomically.
467+
let ruleset = nft_ruleset::generate_tap_ruleset(tap_device, &subnet, gateway_port);
468+
run_nft_stdin(&ruleset)?;
497469

498470
Ok(())
499471
}
500472

501473
fn teardown_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) {
474+
// Delete the entire nftables table — single atomic operation.
475+
let table_name = nft_ruleset::teardown_table_name(tap_device);
476+
let _ = run_cmd("nft", &["delete", "table", "ip", &table_name]);
477+
478+
// Clean up legacy iptables rules from older driver versions.
502479
let subnet = tap_subnet_from_host_ip(host_ip);
503480
let _ = run_cmd(
504481
"iptables",
@@ -519,8 +496,6 @@ fn teardown_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) {
519496
"iptables",
520497
&["-D", "FORWARD", "-i", tap_device, "-j", "ACCEPT"],
521498
);
522-
// Remove the port-scoped INPUT rule. Also try the legacy blanket
523-
// rule so stale rules from older driver versions are cleaned up.
524499
if gateway_port > 0 {
525500
let port_str = gateway_port.to_string();
526501
let _ = run_cmd(
@@ -547,6 +522,7 @@ fn teardown_tap_networking(tap_device: &str, host_ip: &str, gateway_port: u16) {
547522
"MASQUERADE",
548523
],
549524
);
525+
550526
let _ = run_cmd("ip", &["link", "set", tap_device, "down"]);
551527
let _ = run_cmd("ip", &["tuntap", "del", "dev", tap_device, "mode", "tap"]);
552528
}
@@ -583,6 +559,35 @@ fn run_cmd(cmd: &str, args: &[&str]) -> Result<(), String> {
583559
}
584560
}
585561

562+
fn run_nft_stdin(ruleset: &str) -> Result<(), String> {
563+
use std::io::Write;
564+
565+
let mut child = StdCommand::new("nft")
566+
.args(["-f", "-"])
567+
.stdin(Stdio::piped())
568+
.stdout(Stdio::piped())
569+
.stderr(Stdio::piped())
570+
.spawn()
571+
.map_err(|e| format!("failed to run nft: {e}"))?;
572+
573+
if let Some(mut stdin) = child.stdin.take() {
574+
stdin
575+
.write_all(ruleset.as_bytes())
576+
.map_err(|e| format!("failed to write nft ruleset: {e}"))?;
577+
}
578+
579+
let output = child
580+
.wait_with_output()
581+
.map_err(|e| format!("failed to wait for nft: {e}"))?;
582+
583+
if output.status.success() {
584+
Ok(())
585+
} else {
586+
let stderr = String::from_utf8_lossy(&output.stderr);
587+
Err(format!("nft -f - failed: {stderr}"))
588+
}
589+
}
590+
586591
/// RAII guard that tears down TAP networking on drop.
587592
struct TapGuard {
588593
tap_device: String,
@@ -715,7 +720,7 @@ fn run_libkrun_vm(config: &VmLaunchConfig) -> Result<(), String> {
715720
// on its own service ports (DNS:53, DHCP, HTTP API:80).
716721
//
717722
// That network plane is also what the sandbox supervisor's
718-
// per-sandbox netns (veth pair + iptables, see
723+
// per-sandbox netns (veth pair + nftables, see
719724
// `openshell-sandbox/src/sandbox/linux/netns.rs`) branches off of;
720725
// libkrun's built-in TSI socket impersonation would not satisfy
721726
// those kernel-level primitives.
@@ -1481,4 +1486,17 @@ mod tests {
14811486

14821487
assert_ne!(first, second);
14831488
}
1489+
1490+
#[test]
1491+
fn tap_subnet_from_host_ip_calculates_slash30_base() {
1492+
assert_eq!(tap_subnet_from_host_ip("10.0.128.1"), "10.0.128.0/30");
1493+
assert_eq!(tap_subnet_from_host_ip("10.0.128.2"), "10.0.128.0/30");
1494+
assert_eq!(tap_subnet_from_host_ip("10.0.128.5"), "10.0.128.4/30");
1495+
}
1496+
1497+
#[test]
1498+
fn tap_subnet_from_host_ip_handles_invalid_ip() {
1499+
let result = tap_subnet_from_host_ip("not-an-ip");
1500+
assert_eq!(result, "not-an-ip/30");
1501+
}
14841502
}

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

0 commit comments

Comments
 (0)