Files

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())