152 lines
8.8 KiB
Python
152 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Real packet checks; reexec in fresh user/network namespace before any mutation."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
import firewall
|
|
|
|
def run(*args, check=True):
|
|
result = subprocess.run(args, capture_output=True, text=True, timeout=15)
|
|
if check and result.returncode:
|
|
raise RuntimeError(f'{args}: {result.stderr}')
|
|
return result
|
|
|
|
def main():
|
|
if len(sys.argv) == 1:
|
|
parent = os.readlink('/proc/self/ns/net')
|
|
return subprocess.call(['unshare', '--user', '--map-root-user', '--net', sys.executable,
|
|
__file__, parent])
|
|
assert os.readlink('/proc/self/ns/net') != sys.argv[1], 'refuse host namespace'
|
|
os.umask(0o022)
|
|
run('ip', 'link', 'set', 'lo', 'up')
|
|
peer = subprocess.Popen(['unshare', '--net', 'sleep', '180'])
|
|
try:
|
|
import time
|
|
for _ in range(100):
|
|
if os.readlink(f'/proc/{peer.pid}/ns/net') != os.readlink('/proc/self/ns/net'): break
|
|
time.sleep(.01)
|
|
else: raise AssertionError('peer namespace not ready')
|
|
run('ip', 'link', 'add', 'test0', 'type', 'veth', 'peer', 'name', 'peer0')
|
|
run('ip', 'link', 'set', 'peer0', 'netns', str(peer.pid))
|
|
ns = ['nsenter', '-t', str(peer.pid), '-n']
|
|
run(*ns, 'ip', 'link', 'set', 'lo', 'up')
|
|
for addr in ('192.0.2.2/24', '2001:db8::2/64'):
|
|
run(*ns, 'ip', 'addr', 'add', addr, 'dev', 'peer0', *(['nodad'] if ':' in addr else []))
|
|
run(*ns, 'ip', 'link', 'set', 'peer0', 'up')
|
|
for addr in ('192.0.2.1/24', '2001:db8::1/64'):
|
|
run('ip', 'addr', 'add', addr, 'dev', 'test0', *(['nodad'] if ':' in addr else []))
|
|
run('ip', 'link', 'set', 'test0', 'up')
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
for name, data in firewall.render(json.loads((HERE/'app-ports.json').read_text())).items():
|
|
dest = root/name
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_bytes(data.replace(b'/etc/fips/', str(root/'etc/fips/').encode()+b'/'))
|
|
firewall.apply(root)
|
|
firewall.apply(root)
|
|
def packet(port, allowed, ipv6=False, udp=False):
|
|
family = socket.AF_INET6 if ipv6 else socket.AF_INET
|
|
address = '2001:db8::1' if ipv6 else '192.0.2.1'
|
|
with socket.socket(family, socket.SOCK_DGRAM if udp else socket.SOCK_STREAM) as server:
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server.bind((address, port)); server.settimeout(.8)
|
|
if not udp: server.listen()
|
|
code = ('import socket; s=socket.socket(%d,%d); s.settimeout(.4); '
|
|
's.%s((%r,%d)%s)' % (family, socket.SOCK_DGRAM if udp else socket.SOCK_STREAM,
|
|
'sendto' if udp else 'connect', address, port, ''))
|
|
if udp:
|
|
code = f'import socket; s=socket.socket({family},socket.SOCK_DGRAM); s.sendto(b"probe",({address!r},{port}))'
|
|
result = run(*ns, sys.executable, '-c', code, check=False)
|
|
if udp:
|
|
try: success = server.recv(16) == b'probe'
|
|
except socket.timeout: success = False
|
|
else: success = result.returncode == 0
|
|
assert success == allowed, (port, ipv6, udp, success, result.stderr)
|
|
for ipv6 in (False, True):
|
|
packet(443, True, ipv6); packet(5679, True, ipv6)
|
|
packet(22, False, ipv6); packet(12345, False, ipv6)
|
|
packet(51820, True, udp=True)
|
|
# Rename the actual receiving interface to exercise FIPS gating.
|
|
run('ip', 'link', 'set', 'test0', 'down')
|
|
run('ip', 'link', 'set', 'test0', 'name', 'fips0')
|
|
run('ip', 'link', 'set', 'fips0', 'up')
|
|
packet(443, True); packet(22, False); packet(51820, True, udp=True)
|
|
# Publishing updates are consumed by an atomic baseline reload.
|
|
dropin = root/'etc/fips/fips.d/70-fixture.nft'
|
|
dropin.write_text('tcp dport 12345 accept\n')
|
|
firewall.apply(root); packet(12345, True)
|
|
dropin.unlink(); firewall.apply(root); packet(12345, False)
|
|
# A malformed replacement retains all prior kernel rules.
|
|
dropin.write_text('this is not nft syntax\n')
|
|
try: firewall.apply(root)
|
|
except ValueError: pass
|
|
else: raise AssertionError('invalid policy accepted')
|
|
packet(443, True); packet(12345, False)
|
|
dropin.unlink()
|
|
foreign = root/'foreign.nft'
|
|
foreign.write_text('table inet operator_fixture { chain input { type filter hook input priority 20; policy accept; tcp dport 443 drop; }\n}\n')
|
|
run('/usr/sbin/nft','-f',str(foreign))
|
|
firewall.apply(root); packet(443, False)
|
|
run('/usr/sbin/nft','list','table','inet','operator_fixture')
|
|
# Route actual connections through a second isolated peer, checking
|
|
# WG/container egress and return traffic without allowing TollGate bridging.
|
|
downstream = subprocess.Popen(['unshare', '--net', 'sleep', '120'])
|
|
try:
|
|
for _ in range(100):
|
|
if os.readlink(f'/proc/{downstream.pid}/ns/net') != os.readlink('/proc/self/ns/net'): break
|
|
time.sleep(.01)
|
|
else: raise AssertionError('downstream namespace not ready')
|
|
dns = ['nsenter', '-t', str(downstream.pid), '-n']
|
|
run('ip','link','add','out0','type','veth','peer','name','dest0')
|
|
run('ip','link','set','dest0','netns',str(downstream.pid))
|
|
run('ip','addr','add','198.51.100.1/24','dev','out0')
|
|
run('ip','link','set','out0','up')
|
|
run(*dns,'ip','addr','add','198.51.100.2/24','dev','dest0')
|
|
run(*dns,'ip','link','set','dest0','up')
|
|
run(*dns,'ip','route','add','192.0.2.0/24','via','198.51.100.1')
|
|
run(*dns,'ip','route','add','10.44.0.0/16','via','198.51.100.1')
|
|
run(*ns,'ip','route','add','198.51.100.0/24','via','192.0.2.1')
|
|
run('ip','addr','add','10.44.0.1/16','dev','fips0')
|
|
run(*ns,'ip','addr','add','10.44.0.2/16','dev','peer0')
|
|
# This proc sysctl belongs to the verified fresh net namespace.
|
|
Path('/proc/sys/net/ipv4/ip_forward').write_text('1')
|
|
listener = subprocess.Popen([*dns,sys.executable,'-u','-c',
|
|
'import socket; s=socket.socket(); s.bind(("198.51.100.2",45678)); s.listen(); print("ready",flush=True); '
|
|
'\nwhile True:\n c,_=s.accept(); c.sendall(b"reply"); c.close()'],stdout=subprocess.PIPE,text=True)
|
|
try:
|
|
assert listener.stdout.readline().strip() == 'ready'
|
|
current='fips0'
|
|
for interface,allowed,source in [('wg0',True,'10.44.0.2'),('br-tollgate',False,'192.0.2.2'),
|
|
('podman1',True,'192.0.2.2'),('cni-podman1',True,'192.0.2.2'),
|
|
('docker0',True,'192.0.2.2'),('br-custom',False,'192.0.2.2')]:
|
|
run('ip','link','set',current,'down')
|
|
run('ip','link','set',current,'name',interface)
|
|
run('ip','link','set',interface,'up'); current=interface
|
|
code=f'import socket; s=socket.socket(); s.settimeout(.5); s.bind(({source!r},0)); s.connect(("198.51.100.2",45678)); assert s.recv(5)==b"reply"'
|
|
result=run(*ns,sys.executable,'-c',code,check=False)
|
|
assert (result.returncode==0)==allowed,(interface,result.stderr)
|
|
finally:
|
|
listener.terminate(); listener.wait(timeout=5)
|
|
finally:
|
|
downstream.terminate(); downstream.wait(timeout=5)
|
|
# Foreign collision with a reserved name is refused, not replaced.
|
|
run('/usr/sbin/nft','delete','table','inet','archipelago_host')
|
|
run('/usr/sbin/nft','add','table','inet','archipelago_host')
|
|
try: firewall.apply(root)
|
|
except ValueError as error: assert 'foreign reserved table' in str(error)
|
|
else: raise AssertionError('foreign table overwritten')
|
|
print('PASS: actual IPv4/IPv6 TCP, UDP/WG, FIPS publish/remove/SSH denial, atomic refusal, foreign deny/table custody, WG/container forwarding and TollGate denial')
|
|
finally:
|
|
peer.terminate(); peer.wait(timeout=5)
|
|
return 0
|
|
|
|
if __name__ == '__main__': sys.exit(main())
|