Files
Kurt Himebauchanduubooster-team 6a5722c6d1 fix(preload): answer NETLINK_ROUTE queries inside a session (#2141)
* fix(preload): answer NETLINK_ROUTE queries inside a session

ip(8) could not run in a session: the app's SELinux domain has no
netlink_route_socket bind permission, so iproute2 failed at bind and never saw
the link that getifaddrs() and /proc/net already expose.

Serve NETLINK_ROUTE from the same etc/droiddeck-net snapshot: an AF_UNIX
socketpair backs the netlink fd, link/addr/route dumps and route get are
synthesised, writes return EPERM, and multicast subscriptions keep failing
with EACCES so consumers that wait for link events keep their fallback.

Replies carry the port id reported by getsockname() and recvmsg() rewrites
the source address to AF_NETLINK/pid 0. iproute2's dump loop requires both
before it accepts a message or NLMSG_DONE; when either is missing every
message is skipped silently and the process blocks in recvmsg().

* fix(preload): keep socket() and send() semantics for emulated netlink

* fix(preload): deliver netlink replies with the caller's blocking mode

* fix(preload): preserve NETLINK_ROUTE protocol and fd semantics

- Send ACKs for successful queries and honor NETLINK_CAP_ACK
- Reject unsupported subscriptions and apply socket timeouts
- Track socket identity and shared references across dup and close
- Support kernel connect and rewrite recvfrom source addresses
- Resolve local and connected routes before default gateways
- Add netlink and iproute2 regression tests

* fix(preload): handle netlink request limits and socket lifecycle

- Collect large sendmsg requests without falling back to UNIX sockets
- Reject oversized replies explicitly instead of returning partial results
- Allocate unique port IDs and reject duplicate bindings
- Honor socket flags and restore inherited sockets across exec
- Validate socket types and report netlink socket identity
- Replace the fixed socket table with a dynamic registry
- Add regression tests for batching, port conflicts and exec inheritance

* fix(preload): optimize netlink tracking and support link name queries

- Add lock-free fd tracking to bypass netlink locks on ordinary sockets
- Move descriptor restoration to initialization and explicit discovery paths
- Support RTM_GETLINK lookups via IFLA_IFNAME with correct error codes
- Add regression coverage for hot-path overhead and name-based queries

* fix(preload): accept unterminated netlink interface names

- Copy IFLA_IFNAME into a bounded, NUL-terminated buffer before lookup
- Match kernel string length validation and ERANGE errors
- Add regression coverage for unterminated names and boundary cases

* fix(preload): pin peers for netlink replies

* fix(preload): make autobind idempotent

* fix(preload): isolate forked netlink cleanup

* fix(preload): avoid interposed fd scans

* perf(preload): skip PID checks for untracked fds

* fix(preload): verify peer before replay

---------

Co-authored-by: uubooster-team <uu.booster@service.netease.com>
2026-11-05 18:27:08 -05:00

121 lines
5.7 KiB
Python

import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import unittest
SOURCE = Path(__file__).resolve().parents[1] / "linuxfs/preload/net.c"
@unittest.skipUnless(sys.platform.startswith("linux") and shutil.which("cc"), "needs Linux and a C compiler")
class CdpGuardTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.build = tempfile.TemporaryDirectory()
cls.library = Path(cls.build.name) / "net.so"
subprocess.run(["cc", "-shared", "-fPIC", "-O2", str(SOURCE), str(SOURCE.parent / "binfmt.c"), str(SOURCE.parent / "webhelper.c"),
str(Path(__file__).with_name("netlink_stubs.c")), "-x", "c", "-", "-ldl",
"-o", str(cls.library)],
input=b"int bl_udevmon_stand_in(int fd) { return 0; }", check=True, capture_output=True)
@classmethod
def tearDownClass(cls):
cls.build.cleanup()
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.registry = Path(self.temp.name) / "net"
self.registry.mkdir()
def port(self, ipv6=False):
with socket.socket(socket.AF_INET6 if ipv6 else socket.AF_INET) as s:
s.bind(("::1" if ipv6 else "127.0.0.1", 0))
return s.getsockname()[1]
def env(self, port):
return dict(os.environ, LD_PRELOAD=str(self.library), BL_CDP_GUARD=str(port), BL_SYSV_DIR=self.temp.name)
def test_guarded_ipv4_bind_is_loopback_only(self):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); print(s.getsockname()[0])" % port
result = subprocess.run([sys.executable, "-c", program], env=self.env(port), check=True, capture_output=True, text=True)
self.assertEqual("127.0.0.1", result.stdout.strip())
def test_guarded_ipv6_bind_is_loopback_only(self):
if not socket.has_ipv6:
self.skipTest("needs IPv6")
port = self.port(True)
program = "import socket; s=socket.socket(socket.AF_INET6); s.bind(('::', %d)); print(s.getsockname()[0])" % port
result = subprocess.run([sys.executable, "-c", program], env=self.env(port), check=True, capture_output=True, text=True)
self.assertEqual("::1", result.stdout.strip())
def test_other_listeners_are_unchanged(self):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); print(s.getsockname()[0])" % port
result = subprocess.run([sys.executable, "-c", program], env=self.env(0), check=True, capture_output=True, text=True)
self.assertEqual("0.0.0.0", result.stdout.strip())
def connect(self, address, registered, stale=False, wrong_destination=False):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); s.listen(); print('ready', flush=True); c,_=s.accept(); c.sendall(b'accepted')" % port
server = subprocess.Popen([sys.executable, "-u", "-c", program], env=self.env(port), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
self.assertEqual(b"ready\n", server.stdout.readline())
with socket.socket() as client:
client.settimeout(3)
client.bind((address, 0))
if registered:
peer = client.getsockname()[1]
inode = os.fstat(client.fileno()).st_ino + (1 if stale else 0)
destination = port + (1 if wrong_destination else 0)
(self.registry / ("p%d" % peer)).write_text("%d 1 %d %d %d %d %d\n" %
(os.getpid(), os.getuid(), destination, client.fileno(), inode, socket.AF_INET))
client.connect(("127.0.0.1", port))
try:
return client.recv(8)
except ConnectionResetError:
return b""
finally:
server.kill()
server.wait()
server.stdout.close()
server.stderr.close()
def test_registered_guest_loopback_peer_is_accepted(self):
self.assertEqual(b"accepted", self.connect("127.0.0.1", True))
def test_unregistered_local_peer_is_refused(self):
self.assertEqual(b"", self.connect("127.0.0.1", False))
def test_matching_port_record_cannot_authorize_another_address(self):
self.assertEqual(b"", self.connect("127.0.0.2", True))
def test_stale_socket_record_does_not_authorize_a_reused_port(self):
self.assertEqual(b"", self.connect("127.0.0.1", True, stale=True))
def test_record_for_another_destination_does_not_authorize_cdp(self):
self.assertEqual(b"", self.connect("127.0.0.1", True, wrong_destination=True))
def test_preloaded_guest_connect_still_works(self):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); s.listen(); print('ready', flush=True); c,_=s.accept(); c.sendall(b'accepted')" % port
server = subprocess.Popen([sys.executable, "-u", "-c", program], env=self.env(port), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
self.assertEqual(b"ready\n", server.stdout.readline())
client = "import socket; s=socket.create_connection(('127.0.0.1', %d), timeout=3); print(s.recv(8).decode())" % port
result = subprocess.run([sys.executable, "-c", client], env=self.env(port), check=True, capture_output=True, text=True, timeout=5)
self.assertEqual("accepted", result.stdout.strip())
finally:
server.kill()
server.wait()
server.stdout.close()
server.stderr.close()
if __name__ == "__main__":
unittest.main()