Skip to content

Commit 3912486

Browse files
authored
Merge pull request #1805 from JoshuaWatt/netns
Network Namespace Implementation
2 parents d8caf89 + b07ef1c commit 3912486

14 files changed

Lines changed: 1142 additions & 11 deletions

File tree

.github/workflows/reusable-unit-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ jobs:
4343
cat ~/.ssh/id_ed25519.local.pub >> ~/.ssh/authorized_keys
4444
echo -e "Host localhost ip6-localhost\n Hostname 127.0.0.1\n IdentityFile ~/.ssh/id_ed25519.local\n UserKnownHostsFile ~/.ssh/known_hosts.local" >> ~/.ssh/config
4545
ssh -o StrictHostKeyChecking=no localhost echo OK
46+
- name: Enable unprivileged user namespaces
47+
run: |
48+
sudo sysctl kernel.unprivileged_userns_clone=1
49+
unshare -Un true
4650
- name: Install python dependencies
4751
run: |
4852
python -m pip install --upgrade pip virtualenv

helpers/labgrid-raw-interface

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,20 @@ import argparse
1111
import os
1212
import string
1313
import sys
14+
import subprocess
15+
import json
16+
import struct
17+
import fcntl
1418

1519
import yaml
1620

1721

22+
def get_sudo_uid() -> int:
23+
uid = os.environ["SUDO_UID"]
24+
if uid is not None:
25+
return int(uid)
26+
27+
1828
def get_denylist():
1929
denylist_file = "/etc/labgrid/helpers.yaml"
2030
try:
@@ -32,6 +42,66 @@ def get_denylist():
3242

3343
return denylist
3444

45+
def open_tap(name, device="/dev/net/tun"):
46+
TUNSETIFF = 0x400454CA
47+
IFF_NO_PI = 0x1000
48+
O_RDWR = 0x2
49+
IFF_TAP = 0x0002
50+
51+
flags = IFF_TAP | IFF_NO_PI
52+
name = name.encode()
53+
ifr_name = name + b"\x00" * (16 - len(name))
54+
ifr = struct.pack("16sH22s", ifr_name, flags, b"\x00" * 22)
55+
56+
fd = os.open(device, O_RDWR)
57+
fcntl.ioctl(fd, TUNSETIFF, ifr)
58+
return fd
59+
60+
61+
def handle_ns_macvtap(options):
62+
# Use macvtap in bridge mode. This is more consistent than using hairpin
63+
# mode and relying on a switch to relay packets back into the interface.
64+
output = subprocess.check_output(
65+
["ip", "-j", "-echo", "link", "add", "link", options.ifname, "type", "macvtap", "mode", "bridge"]
66+
)
67+
output = json.loads(output)
68+
assert len(output) == 1
69+
ifindex_new = output[0]["ifindex"]
70+
ifname_new = output[0]["ifname"]
71+
assert ifname_new.startswith("macvtap")
72+
try:
73+
# Set the flag that tells the kernel to allow multicast to flow through
74+
# the macvtap. This is required to be done on the exporter side for the
75+
# client to receive multicast groups that the exporter is not listening
76+
# to. Note that the client still needs to do multicast group membership
77+
# to tell intermediate routers about the groups, this just prevents the
78+
# kernel from filtering them out before they are sent to the tap.
79+
subprocess.check_call(["ip", "link", "set", "allmulticast", "on", "dev", ifname_new])
80+
81+
if options.mac_address:
82+
subprocess.check_call(
83+
["ip", "link", "set", "address", options.mac_address, "dev", ifname_new])
84+
subprocess.check_call(
85+
["ip", "link", "set", "dev", ifname_new, "name", "macvtap0", "up", "netns", str(options.pid), "index", str(ifindex_new)]
86+
)
87+
except:
88+
subprocess.check_call(
89+
["ip", "link", "del", ifname_new]
90+
)
91+
raise
92+
tap_name = f"/dev/tap{ifindex_new}"
93+
uid = get_sudo_uid()
94+
os.chown(
95+
path=tap_name,
96+
uid=uid,
97+
gid=-1,
98+
follow_symlinks=False,
99+
)
100+
101+
with os.fdopen(open_tap(ifname_new, device=tap_name)) as macvtap_fd:
102+
os.set_inheritable(macvtap_fd.fileno(), True)
103+
os.execlp("labgrid-tap-fwd", "labgrid-tap-fwd", str(macvtap_fd.fileno()))
104+
35105

36106
def main(program, options):
37107
if not options.ifname:
@@ -46,7 +116,7 @@ def main(program, options):
46116
if options.ifname in denylist:
47117
raise ValueError(f"Interface name '{options.ifname}' is denied in denylist.")
48118

49-
programs = ["tcpreplay", "tcpdump", "ip", "ethtool"]
119+
programs = ["tcpreplay", "tcpdump", "ip", "ethtool", "ns-macvtap"]
50120
if program not in programs:
51121
raise ValueError(f"Invalid program {program} called with wrapper, valid programs are: {programs}")
52122

@@ -112,6 +182,10 @@ def main(program, options):
112182
args.append(options.ifname)
113183
args.extend(options.ethtool_pause_args)
114184

185+
elif program == "ns-macvtap":
186+
handle_ns_macvtap(options)
187+
return
188+
115189
try:
116190
os.execvp(args[0], args)
117191
except FileNotFoundError as e:
@@ -165,6 +239,12 @@ if __name__ == "__main__":
165239
"ethtool_pause_args", metavar="ARG", nargs=argparse.REMAINDER, help="ethtool --pause args"
166240
)
167241

242+
# ns-macvtap
243+
ns_mactap_parser = subparsers.add_parser("ns-macvtap")
244+
ns_mactap_parser.add_argument("ifname", type=str, help="interface name")
245+
ns_mactap_parser.add_argument("pid", type=int, help="pid of namespace agent")
246+
ns_mactap_parser.add_argument("--mac-address", type=str, metavar="ADDRESS", help="Set interface MAC address to ADDRESS")
247+
168248
args = parser.parse_args()
169249
try:
170250
main(args.program, args)

labgrid/driver/rawnetworkinterfacedriver.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
import json
44
import subprocess
55
import time
6+
import os
67

78
import attr
89

910
from .common import Driver
11+
from .exception import ExecutionError
1012
from ..factory import target_factory
1113
from ..step import step
14+
from ..util.agentwrapper import AgentWrapper
1215
from ..util.helper import processwrapper
1316
from ..util.managedfile import ManagedFile
1417
from ..util.timeout import Timeout
18+
from ..util.netns import NetNamespace
1519
from ..resource.common import NetworkResource
1620

1721

@@ -354,3 +358,114 @@ def get_address(self):
354358
Returns the MAC address of the bound network interface resource.
355359
"""
356360
return self.get_statistics()["address"]
361+
362+
@Driver.check_active
363+
@contextlib.contextmanager
364+
def _netns(self, host):
365+
with AgentWrapper(host) as wrapper:
366+
ns = wrapper.load("netns")
367+
ns.unshare()
368+
yield ns
369+
370+
@Driver.check_active
371+
@step()
372+
@contextlib.contextmanager
373+
def setup_netns(self, mac_address=None):
374+
with contextlib.ExitStack() as ctx:
375+
remote_ns = ctx.enter_context(self._netns(self.iface.host))
376+
local_ns = ctx.enter_context(self._netns(None))
377+
378+
cmd = [
379+
"-d",
380+
"ns-macvtap",
381+
self.iface.ifname,
382+
str(remote_ns.get_pid()),
383+
]
384+
if mac_address:
385+
cmd.append("--mac-address")
386+
cmd.append(mac_address)
387+
388+
# Start tap forward in remote namespace
389+
remote_fwd = ctx.enter_context(
390+
subprocess.Popen(
391+
self._wrap_command(cmd),
392+
stdout=subprocess.PIPE,
393+
stdin=subprocess.PIPE,
394+
)
395+
)
396+
ctx.callback(lambda: remote_fwd.terminate())
397+
self.logger.debug("Started remote tap forward '%s'", " ".join(remote_fwd.args))
398+
399+
r_macaddr = None
400+
to = Timeout(30.0)
401+
while True:
402+
if to.expired:
403+
raise TimeoutError("Timeout waiting for remote macvtap to be established")
404+
405+
links = remote_ns.get_links()
406+
for link in links:
407+
if link["ifname"] == "macvtap0":
408+
r_macaddr = link["address"]
409+
break
410+
411+
if r_macaddr is not None:
412+
break
413+
414+
if remote_fwd.poll() is not None:
415+
raise ExecutionError(
416+
f"Remote tap forward {remote_fwd.pid} died with {remote_fwd.returncode} during setup"
417+
)
418+
419+
time.sleep(0.1)
420+
421+
self.logger.debug("Remote macvtap MAC address is %s", r_macaddr)
422+
423+
_, fd = local_ns.create_tun(address=r_macaddr)
424+
tun_fd = ctx.enter_context(os.fdopen(fd))
425+
426+
links = local_ns.get_links()
427+
link_names = [link["ifname"] for link in links]
428+
assert "tap0" in link_names
429+
430+
local_fwd = ctx.enter_context(
431+
subprocess.Popen(
432+
local_ns.get_prefix() + ["labgrid-tap-fwd", str(tun_fd.fileno())],
433+
stdin=remote_fwd.stdout,
434+
stdout=remote_fwd.stdin,
435+
pass_fds=(tun_fd.fileno(),),
436+
)
437+
)
438+
ctx.callback(lambda: local_fwd.terminate())
439+
self.logger.debug("Started local tap forward '%s'", " ".join(local_fwd.args))
440+
441+
# Close local pipes for the remote forward, now that the local forward is running
442+
remote_fwd.stdin.close()
443+
remote_fwd.stdout.close()
444+
445+
ns = NetNamespace(local_ns)
446+
447+
# Wait for IPv6 address negotiation to finish
448+
to = Timeout(30.0)
449+
while True:
450+
if to.expired:
451+
raise TimeoutError("Timeout waiting for IPv6 address negotiation to finish")
452+
453+
data = json.loads(
454+
ns.run(["ip", "-j", "addr", "show", "dev", ns.intf], check=True, stdout=subprocess.PIPE).stdout
455+
)
456+
for addr in data[0].get("addr_info", []):
457+
if addr.get("tentative", False):
458+
break
459+
else:
460+
# No tentative addresses
461+
break
462+
463+
if remote_fwd.poll() is not None:
464+
raise ExecutionError(f"Remote tap forward {remote_fwd.pid} died with {remote_fwd.returncode}")
465+
466+
if local_fwd.poll() is not None:
467+
raise ExecutionError(f"Local tap forward {local_fwd.pid} died with {local_fwd.returncode}")
468+
469+
time.sleep(0.1)
470+
471+
yield ns

labgrid/remote/client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import shutil
1818
import json
1919
import itertools
20+
import ipaddress
2021
from textwrap import indent
2122
from socket import gethostname
2223
from getpass import getuser
@@ -1410,6 +1411,38 @@ def audio(self):
14101411
if res:
14111412
raise InteractiveCommandError("gst-launch-1.0 error", res)
14121413

1414+
def netns(self):
1415+
place = self.get_acquired_place()
1416+
with self._get_target(place) as target:
1417+
name = self.args.name
1418+
drv = self._get_driver_or_new(target, "RawNetworkInterfaceDriver", name=name)
1419+
with drv.setup_netns(self.args.mac_address) as ns:
1420+
for a in self.args.address:
1421+
ns.run(["ip", "addr", "add", str(a), "dev", ns.intf], check=True)
1422+
1423+
if self.args.nsenter:
1424+
with open(self.args.nsenter, "w") as wrapper:
1425+
wrapper.write(
1426+
"\n".join(
1427+
[
1428+
"#!/usr/bin/sh",
1429+
" ".join(ns.prefix) + ' "$@"',
1430+
"",
1431+
]
1432+
)
1433+
)
1434+
os.fchmod(wrapper.fileno(), 0o755)
1435+
print(f"Use {self.args.nsenter} to enter the namespace")
1436+
1437+
cmd = self.args.cmd
1438+
if not cmd:
1439+
# Same behavior as nsenter with no command
1440+
cmd = os.environ.get("SHELL", "/bin/sh")
1441+
1442+
p = ns.run(cmd)
1443+
if p.returncode != 0:
1444+
raise InteractiveCommandError("netns command error", p.returncode)
1445+
14131446
def _get_tmc(self):
14141447
place = self.get_acquired_place()
14151448
target = self._get_target(place)
@@ -2070,6 +2103,30 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg
20702103
subparser.add_argument("--name", "-n", help="optional resource name")
20712104
subparser.set_defaults(func=ClientSession.audio)
20722105

2106+
subparser = subparsers.add_parser("netns", help="start a network namespace with access to NetworkInterface")
2107+
subparser.add_argument("--name", "-n", help="optional resource name")
2108+
subparser.add_argument(
2109+
"--address",
2110+
"-a",
2111+
type=ipaddress.ip_interface,
2112+
action="append",
2113+
default=[],
2114+
help="assign address to interface (CIDR notation)",
2115+
)
2116+
subparser.add_argument("--mac-address", help="assign MAC address to interface")
2117+
subparser.add_argument(
2118+
"--nsenter",
2119+
"-e",
2120+
nargs="?",
2121+
default=False,
2122+
const="labgrid-nsenter",
2123+
help="create nsenter wrapper script",
2124+
)
2125+
subparser.add_argument(
2126+
"cmd", nargs=argparse.REMAINDER, help="command to execute in the namespace. If unspecified, $SHELL is invoked"
2127+
)
2128+
subparser.set_defaults(func=ClientSession.netns)
2129+
20732130
tmc_parser = subparsers.add_parser("tmc", help="control a USB TMC device")
20742131
tmc_parser.add_argument("--name", "-n", help="optional resource name")
20752132
tmc_parser.set_defaults(func=lambda _: tmc_parser.print_help(file=sys.stderr))

0 commit comments

Comments
 (0)