net: support for network rules

This commit is contained in:
Robert Swiecki
2026-03-27 21:16:37 +01:00
parent dec538154b
commit 91e692a155
10 changed files with 549 additions and 6 deletions

View File

@@ -149,11 +149,27 @@ UID := $(shell id -u)
.PHONY: test
test: $(BIN)
# --- Basic sanity tests ---
$(call run_test, ./nsjail -q -Mo --chroot / --user 99999 --group 99999 -- /bin/true, 0)
$(call run_test, ./nsjail -q -Mo --chroot / --user 99999 --group 99999 -- /bin/false, 1)
$(call run_test, ./nsjail --config tests/seccomp.cfg -q -t 2 -- /bin/bash -c 'strace -o /dev/null /bin/true || exit 77', 77)
$(call run_test, ./nsjail --config tests/basic.cfg -q -t 2 -- /bin/bash -c 'strace -o /dev/null /bin/true && exit 77', 77)
$(call run_test, ./nsjail --config tests/nat.cfg -q -t 3 -- /bin/bash -c 'sleep 0.2; ping -W 1 -c 1 8.8.8.8 && exit 77', 77)
$(call run_test, ./nsjail --config tests/port-mappings.cfg -q -t 3 -- /bin/bash -c 'sleep 0.2; { netstat -tan | grep LISTEN; } && exit 77', 77)
# --- Traffic rules tests ---
$(call run_test, ./nsjail --config tests/traffic-rules.cfg -q -t 2, 137)
$(call run_test, ./nsjail --config tests/traffic-drop-tcp4.cfg -q -t 2, 137)
$(call run_test, ./nsjail --config tests/traffic-drop-udp6.cfg -q -t 2, 137)
$(call run_test, ./nsjail --config tests/traffic-mixed.cfg -q -t 2, 137)
# --- IPv4-only NAT tests ---
$(call run_test, ./nsjail --config tests/nat-ip4-only.cfg -q -t 3 --cap CAP_NET_RAW -- /bin/bash -c 'sleep 0.2; ping -4 -W 1 -c 1 8.8.8.8 && exit 77', 77)
# --- IPv6-only NAT tests ---
$(call run_test, ./nsjail --config tests/nat-ip6-only.cfg -q -t 3 -- /bin/true, 0)
# --- --experimental_mnt=old ---
$(call run_test, ./nsjail $(OLD_EF) -q -Mo --rw --chroot / --user 99999 --group 99999 -- /bin/bash -c 'touch $(HOME)/nsjail_test && exit 77', 77)
$(call run_test, ./nsjail $(OLD_EF) -q -Mo --chroot / --user 99999 --group 99999 -- /bin/bash -c 'touch $(HOME)/nsjail_test || exit 77', 77)
$(call run_test, rm -f $(HOME)/nsjail_test, 0)
@@ -174,6 +190,7 @@ test: $(BIN)
$(call run_test, ./nsjail $(OLD_EF) --config configs/firefox-with-net-wayland.cfg -q -t 2, 137)
$(call run_test, ./nsjail $(OLD_EF) --config configs/chromium-with-net-wayland.cfg -q -t 2, 137)
# --- --experimental_mnt=new ---
$(call run_test, ./nsjail $(NEW_EF) -q -Mo --rw --chroot / --user 99999 --group 99999 -- /bin/bash -c 'touch $(HOME)/nsjail_test && exit 77', 77)
$(call run_test, ./nsjail $(NEW_EF) -q -Mo --chroot / --user 99999 --group 99999 -- /bin/bash -c 'touch $(HOME)/nsjail_test || exit 77', 77)
$(call run_test, rm -f $(HOME)/nsjail_test, 0)
@@ -194,6 +211,12 @@ test: $(BIN)
$(call run_test, ./nsjail $(NEW_EF) --config configs/firefox-with-net-wayland.cfg -q -t 2, 137)
$(call run_test, ./nsjail $(NEW_EF) --config configs/chromium-with-net-wayland.cfg -q -t 2, 137)
@echo ""
@echo "========================================"
@echo " ✅ All tests passed!"
@echo "========================================"
@echo ""
# Dependencies (Generated by makedepend)
# DO NOT DELETE THIS LINE -- make depend depends on it.

View File

@@ -388,6 +388,7 @@ static bool setupArgv(nsj_t* nsj, int argc, char** argv, int optind) {
*/
if (optind < argc) {
nsj->argv.clear();
nsj->njc.mutable_exec_bin()->set_path(argv[optind]);
for (int i = optind; i < argc; i++) {
nsj->argv.push_back(argv[i]);
}

View File

@@ -273,6 +273,60 @@ message NsJailConfig {
optional string macvlan_vs_ma = 94 [default = ""];
optional string macvlan_vs_mo = 95 [default = "private"];
message TrafficRule {
enum TrafficAction {
UNKNOWN_ACTION = 0;
DROP = 1; /* Block the traffic silently */
REJECT = 2; /* Block and send ICMP unreachable / TCP RST */
ALLOW = 3; /* Explicitly allow */
ENCAP = 4; /* Encapsulate and route (LWT) */
}
enum EncapType {
ENCAP_IP = 0;
ENCAP_IP6 = 1;
ENCAP_MPLS = 2;
ENCAP_ILA = 3;
}
enum IpFamily {
IPV4 = 0;
IPV6 = 1;
}
enum Protocol {
UNKNOWN_PROTO = 0; /* No protocol filter */
TCP = 1;
UDP = 2;
ICMP = 3;
ICMPV6 = 4;
}
/* Match parameters (Selectors) */
optional string src_ip = 1; /* Source IP or CIDR */
optional string dst_ip = 2; /* Dest IP or CIDR */
optional string iif = 3; /* Input interface */
optional string oif = 4; /* Output interface */
optional Protocol proto = 5 [default = UNKNOWN_PROTO];
optional uint32 sport = 6; /* Source port */
optional uint32 dport = 7; /* Dest port */
optional uint32 sport_end = 8; /* For port ranges */
optional uint32 dport_end = 9; /* For port ranges */
/* Action parameters */
optional TrafficAction action = 10 [default = DROP];
/* Encap parameters */
optional EncapType encap_type = 11 [default = ENCAP_IP];
optional string encap_dst = 12; /* Destination IP (for IP/IP6) or MPLS label */
optional uint32 encap_id = 13; /* Tunnel ID (VNI/Key) or MPLS TTL */
optional uint64 encap_ila_locator = 14; /* ILA Locator */
/* Address family: IPV4 (default) or IPV6 */
optional IpFamily ip_family = 15 [default = IPV4];
}
repeated TrafficRule traffic_rule = 100;
message UserNet {
/* Enable User-Mode NAT (via pasta) */
optional bool nat = 1 [default = false];

251
net.cc
View File

@@ -24,11 +24,15 @@
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/fib_rules.h>
#include <net/if.h>
#include <net/route.h>
#include <netinet/in.h>
#include <netinet/ip6.h>
#include <netinet/tcp.h>
#include <netlink/route/nexthop.h>
#include <netlink/route/route.h>
#include <netlink/route/rule.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -45,7 +49,9 @@
#include <sys/wait.h>
#include <unistd.h>
#include <sstream>
#include <string>
#include <vector>
#include "logs.h"
#include "macros.h"
@@ -118,6 +124,8 @@ namespace net {
#define IFACE_NAME "vs"
#include <linux/if_ether.h>
#include <linux/rtnetlink.h>
#include <netlink/route/link.h>
#include <netlink/route/link/macvlan.h>
@@ -417,36 +425,36 @@ bool initParent(nsj_t* nsj, int pid) {
LOG_E("Could not allocate socket with nl_socket_alloc()");
return false;
}
defer {
nl_socket_free(sk);
};
int err;
if ((err = nl_connect(sk, NETLINK_ROUTE)) < 0) {
LOG_E("Unable to connect socket: %s", nl_geterror(err));
nl_socket_free(sk);
return false;
}
struct nl_cache* link_cache;
if ((err = rtnl_link_alloc_cache(sk, AF_UNSPEC, &link_cache)) < 0) {
LOG_E("rtnl_link_alloc_cache(): %s", nl_geterror(err));
nl_socket_free(sk);
return false;
}
defer {
nl_cache_free(link_cache);
};
for (const auto& iface : nsj->njc.iface_own()) {
if (!moveToNs(iface, sk, link_cache, pid)) {
nl_cache_free(link_cache);
nl_socket_free(sk);
return false;
}
}
if (!nsj->njc.macvlan_iface().empty() && !cloneIface(nsj, sk, link_cache, pid)) {
nl_cache_free(link_cache);
nl_socket_free(sk);
return false;
}
nl_cache_free(link_cache);
nl_socket_free(sk);
return true;
}
@@ -727,6 +735,213 @@ static bool ifaceConfig(const std::string& iface, const std::string& ip, const s
return true;
}
static bool parseIp(const std::string& ip_str, struct in_addr* addr, int* mask) {
size_t slash = ip_str.find('/');
std::string ip = ip_str;
*mask = 32;
if (slash != std::string::npos) {
ip = ip_str.substr(0, slash);
*mask = std::stoi(ip_str.substr(slash + 1));
}
return inet_pton(AF_INET, ip.c_str(), addr) == 1;
}
static bool parseIp6(const std::string& ip_str, struct in6_addr* addr, int* mask) {
size_t slash = ip_str.find('/');
std::string ip = ip_str;
*mask = 128;
if (slash != std::string::npos) {
ip = ip_str.substr(0, slash);
*mask = std::stoi(ip_str.substr(slash + 1));
}
return inet_pton(AF_INET6, ip.c_str(), addr) == 1;
}
static bool applyEncapRoute(struct nl_sock* sk, const nsjail::NsJailConfig_TrafficRule& rule,
int family, uint32_t table_id) {
struct rtnl_route* route = rtnl_route_alloc();
if (!route) {
LOG_E("rtnl_route_alloc() failed");
return false;
}
rtnl_route_set_table(route, table_id);
rtnl_route_set_family(route, family);
rtnl_route_set_scope(route, RT_SCOPE_UNIVERSE);
rtnl_route_set_type(route, RTN_UNICAST);
struct nl_addr* dst = nl_addr_alloc(family == AF_INET ? 4 : 16);
nl_addr_set_family(dst, family);
nl_addr_set_prefixlen(dst, 0);
rtnl_route_set_dst(route, dst);
nl_addr_put(dst);
struct rtnl_nexthop* nh = rtnl_route_nh_alloc();
if (!nh) {
rtnl_route_put(route);
return false;
}
unsigned int ifindex =
if_nametoindex(rule.has_oif() && !rule.oif().empty() ? rule.oif().c_str() : "lo");
if (ifindex > 0) rtnl_route_nh_set_ifindex(nh, ifindex);
if (rule.has_encap_type()) {
struct rtnl_nh_encap* encap = rtnl_nh_encap_alloc();
if (!encap) {
rtnl_route_nh_free(nh);
rtnl_route_put(route);
return false;
}
if (rule.encap_type() == nsjail::NsJailConfig_TrafficRule::ENCAP_IP) {
struct nl_addr* encap_dst;
if (rule.has_encap_dst() &&
nl_addr_parse(rule.encap_dst().c_str(), AF_INET, &encap_dst) == 0) {
rtnl_nh_encap_ip(encap, encap_dst);
if (rule.has_encap_id())
rtnl_nh_set_encap_ip_id(encap, rule.encap_id());
rtnl_route_nh_set_encap(nh, encap);
nl_addr_put(encap_dst);
}
} else if (rule.encap_type() == nsjail::NsJailConfig_TrafficRule::ENCAP_IP6) {
struct nl_addr* encap_dst;
if (rule.has_encap_dst() &&
nl_addr_parse(rule.encap_dst().c_str(), AF_INET6, &encap_dst) == 0) {
rtnl_nh_encap_ip6(encap, encap_dst);
if (rule.has_encap_id())
rtnl_nh_set_encap_ip6_id(encap, rule.encap_id());
rtnl_route_nh_set_encap(nh, encap);
nl_addr_put(encap_dst);
}
} else if (rule.encap_type() == nsjail::NsJailConfig_TrafficRule::ENCAP_MPLS) {
struct nl_addr* encap_dst;
if (rule.has_encap_dst() &&
nl_addr_parse(rule.encap_dst().c_str(), AF_MPLS, &encap_dst) == 0) {
uint8_t ttl = rule.has_encap_id() ? rule.encap_id() : 255;
rtnl_nh_encap_mpls(encap, encap_dst, ttl);
rtnl_route_nh_set_encap(nh, encap);
nl_addr_put(encap_dst);
}
} else if (rule.encap_type() == nsjail::NsJailConfig_TrafficRule::ENCAP_ILA) {
if (rule.has_encap_ila_locator()) {
rtnl_nh_encap_ila(encap, rule.encap_ila_locator());
rtnl_route_nh_set_encap(nh, encap);
}
}
}
rtnl_route_add_nexthop(route, nh);
int err = rtnl_route_add(sk, route, NLM_F_CREATE);
if (err < 0) {
LOG_E("rtnl_route_add() failed: %s", nl_geterror(err));
rtnl_route_put(route);
return false;
}
rtnl_route_put(route);
return true;
}
static bool applyTrafficRule(
struct nl_sock* sk, const nsjail::NsJailConfig_TrafficRule& rule, int family) {
struct rtnl_rule* rtnl_rule = rtnl_rule_alloc();
if (!rtnl_rule) {
LOG_E("rtnl_rule_alloc() failed");
return false;
}
rtnl_rule_set_family(rtnl_rule, family);
if (rule.has_src_ip() && !rule.src_ip().empty()) {
struct nl_addr* addr;
if (nl_addr_parse(rule.src_ip().c_str(), family, &addr) < 0) {
LOG_E("nl_addr_parse(src_ip, %s) failed", rule.src_ip().c_str());
rtnl_rule_put(rtnl_rule);
return false;
}
rtnl_rule_set_src(rtnl_rule, addr);
nl_addr_put(addr);
}
if (rule.has_dst_ip() && !rule.dst_ip().empty()) {
struct nl_addr* addr;
if (nl_addr_parse(rule.dst_ip().c_str(), family, &addr) < 0) {
LOG_E("nl_addr_parse(dst_ip, %s) failed", rule.dst_ip().c_str());
rtnl_rule_put(rtnl_rule);
return false;
}
rtnl_rule_set_dst(rtnl_rule, addr);
nl_addr_put(addr);
}
if (rule.has_iif() && !rule.iif().empty()) rtnl_rule_set_iif(rtnl_rule, rule.iif().c_str());
if (rule.has_oif() && !rule.oif().empty()) rtnl_rule_set_oif(rtnl_rule, rule.oif().c_str());
if (rule.has_proto() && rule.proto() != nsjail::NsJailConfig_TrafficRule::UNKNOWN_PROTO) {
switch (rule.proto()) {
case nsjail::NsJailConfig_TrafficRule::TCP:
rtnl_rule_set_ipproto(rtnl_rule, IPPROTO_TCP);
break;
case nsjail::NsJailConfig_TrafficRule::UDP:
rtnl_rule_set_ipproto(rtnl_rule, IPPROTO_UDP);
break;
case nsjail::NsJailConfig_TrafficRule::ICMP:
rtnl_rule_set_ipproto(rtnl_rule, IPPROTO_ICMP);
break;
case nsjail::NsJailConfig_TrafficRule::ICMPV6:
rtnl_rule_set_ipproto(rtnl_rule, IPPROTO_ICMPV6);
break;
default:
break;
}
}
if (rule.has_sport()) {
if (rule.has_sport_end())
rtnl_rule_set_sport_range(rtnl_rule, rule.sport(), rule.sport_end());
else
rtnl_rule_set_sport(rtnl_rule, rule.sport());
}
if (rule.has_dport()) {
if (rule.has_dport_end())
rtnl_rule_set_dport_range(rtnl_rule, rule.dport(), rule.dport_end());
else
rtnl_rule_set_dport(rtnl_rule, rule.dport());
}
if (rule.has_action()) {
if (rule.action() == nsjail::NsJailConfig_TrafficRule::DROP) {
rtnl_rule_set_action(rtnl_rule, FR_ACT_BLACKHOLE);
} else if (rule.action() == nsjail::NsJailConfig_TrafficRule::REJECT) {
rtnl_rule_set_action(rtnl_rule, FR_ACT_UNREACHABLE);
} else if (rule.action() == nsjail::NsJailConfig_TrafficRule::ENCAP) {
static uint32_t current_table = 1000;
uint32_t table_id = current_table++;
rtnl_rule_set_action(rtnl_rule, FR_ACT_TO_TBL);
rtnl_rule_set_table(rtnl_rule, table_id);
if (!applyEncapRoute(sk, rule, family, table_id)) {
rtnl_rule_put(rtnl_rule);
return false;
}
} else if (rule.action() == nsjail::NsJailConfig_TrafficRule::ALLOW) {
rtnl_rule_set_action(rtnl_rule, FR_ACT_TO_TBL);
rtnl_rule_set_table(rtnl_rule, RT_TABLE_MAIN); // Just pass to main routing
}
} else {
rtnl_rule_set_action(rtnl_rule, FR_ACT_BLACKHOLE);
}
int err = rtnl_rule_add(sk, rtnl_rule, NLM_F_CREATE);
if (err < 0) {
LOG_E("rtnl_rule_add() failed: %s", nl_geterror(err));
rtnl_rule_put(rtnl_rule);
return false;
}
rtnl_rule_put(rtnl_rule);
return true;
}
bool initNsFromChild(nsj_t* nsj) {
if (!nsj->njc.clone_newnet()) {
return true;
@@ -739,6 +954,30 @@ bool initNsFromChild(nsj_t* nsj) {
nsj->njc.macvlan_vs_gw())) {
return false;
}
if (nsj->njc.traffic_rule_size() > 0) {
struct nl_sock* sk = nl_socket_alloc();
if (!sk) {
LOG_E("nl_socket_alloc() failed");
return false;
}
defer {
nl_socket_free(sk);
};
if (nl_connect(sk, NETLINK_ROUTE) < 0) {
LOG_E("nl_connect() failed");
return false;
}
for (const auto& rule : nsj->njc.traffic_rule()) {
int family = (rule.ip_family() == nsjail::NsJailConfig_TrafficRule::IPV6)
? AF_INET6
: AF_INET;
if (!applyTrafficRule(sk, rule, family)) {
return false;
}
}
}
return true;
}

32
tests/nat-ip4-only.cfg Normal file
View File

@@ -0,0 +1,32 @@
# Test config: NAT with IPv4 only (ip6_enabled=false)
name: "nat-ip4-only"
description: "Tests NAT mode with ip6_enabled set to false,"
description: "verifying that IPv4-only operation works correctly."
mode: ONCE
hostname: "NAT-IP4"
cwd: "/tmp"
time_limit: 100
skip_setsid: true
user_net {
nat: true
ip6_enabled: false
}
mount {
src: "/"
dst: "/"
is_bind: true
rw: false
}
exec_bin {
path: "/bin/bash"
arg0: "sh"
arg: "-i"
}

32
tests/nat-ip6-only.cfg Normal file
View File

@@ -0,0 +1,32 @@
# Test config: NAT with IPv6 only (ip4_enabled=false)
name: "nat-ip6-only"
description: "Tests NAT mode with ip4_enabled set to false,"
description: "verifying that IPv6-only operation works correctly."
mode: ONCE
hostname: "NAT-IP6"
cwd: "/tmp"
time_limit: 100
skip_setsid: true
user_net {
nat: true
ip4_enabled: false
}
mount {
src: "/"
dst: "/"
is_bind: true
rw: false
}
exec_bin {
path: "/bin/bash"
arg0: "sh"
arg: "-i"
}

View File

@@ -0,0 +1,32 @@
# Test: IPv4 traffic rule that drops all outgoing TCP traffic
name: "traffic-drop-tcp4"
description: "Tests that an IPv4 DROP rule on TCP port 80 blocks traffic."
mode: ONCE
skip_setsid: true
mount_proc: true
mount {
src: "/"
dst: "/"
is_bind: true
}
user_net {
nat: true
}
traffic_rule {
proto: TCP
dport: 80
action: DROP
}
exec_bin {
path: "/bin/bash"
arg0: "sh"
arg: "-i"
}

View File

@@ -0,0 +1,32 @@
# Test: IPv6 traffic rule that drops all outgoing UDP traffic on port 53
name: "traffic-drop-udp6"
description: "Tests that an IPv6 DROP rule on UDP port 53 is applied correctly."
mode: ONCE
skip_setsid: true
mount_proc: true
mount {
src: "/"
dst: "/"
is_bind: true
}
user_net {
nat: true
}
traffic_rule {
ip_family: IPV6
proto: UDP
action: DROP
}
exec_bin {
path: "/bin/bash"
arg0: "sh"
arg: "-i"
}

49
tests/traffic-mixed.cfg Normal file
View File

@@ -0,0 +1,49 @@
# Test: Mixed IPv4 and IPv6 traffic rules using the unified traffic_rule field
name: "traffic-mixed"
description: "Tests a combination of IPv4 and IPv6 traffic rules in a single config,"
description: "verifying the unified ip_family field works for both address families."
mode: ONCE
skip_setsid: true
mount_proc: true
mount {
src: "/"
dst: "/"
is_bind: true
}
user_net {
nat: true
}
# IPv4: reject TCP port 443
traffic_rule {
proto: TCP
dport: 443
action: REJECT
}
# IPv6: drop UDP port 53
traffic_rule {
ip_family: IPV6
proto: UDP
dport: 53
action: DROP
}
# IPv4: allow TCP port 80
traffic_rule {
proto: TCP
dport: 80
action: ALLOW
}
exec_bin {
path: "/bin/bash"
arg0: "sh"
arg: "-i"
}

49
tests/traffic-rules.cfg Normal file
View File

@@ -0,0 +1,49 @@
name: "traffic_rule"
description: "Demonstrates the highly structured TrafficRule protobuf API for firewalling and NAT."
mode: ONCE
skip_setsid: true
mount_proc: true
mount {
src: "/"
dst: "/"
is_bind: true
}
user_net {
nat: true
}
traffic_rule {
proto: TCP
dport: 443
action: DROP
}
traffic_rule {
proto: TCP
dport: 80
dport_end: 85
action: ENCAP
encap_type: ENCAP_IP
encap_dst: "127.0.0.100"
encap_id: 100
}
traffic_rule {
ip_family: IPV6
proto: UDP
dport: 53
action: ENCAP
encap_type: ENCAP_MPLS
encap_dst: "200"
encap_id: 255
}
exec_bin {
path: "/bin/bash"
arg0: "sh"
arg: "-i"
}