Files
archy/image-recipe/host-policy/firewall.py
T

173 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Fixed, root-owned image firewall. Never flush a foreign table or ruleset."""
import fcntl
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
MARKER = '# Archipelago image firewall v1\n'
TABLES = {'archipelago_host': 'Archipelago image firewall v1: host',
'fips': 'Archipelago image firewall v1: fips'}
def render(app_ports):
if not app_ports or app_ports != sorted(set(app_ports)) or any(type(p) is not int or not 1 <= p <= 65535 for p in app_ports):
raise ValueError('invalid reviewed app port inventory')
ports = ', '.join(map(str, app_ports))
# A separate FIPS chain preserves the existing publishing/SSH drop-in API.
# All includes are behind the interface check, including old unscoped rules.
fips = MARKER + '''add table inet fips
delete table inet fips
table inet fips {
comment "Archipelago image firewall v1: fips"
chain input {
type filter hook input priority -5; policy accept;
iifname != "fips0" return
ct state invalid drop
ct state established,related accept
meta l4proto ipv6-icmp accept
ip protocol icmp accept
udp dport 51820 accept
include "/etc/fips/fips.d/*.nft"
drop
}
}
'''
host = MARKER + '''# Each replacement is scoped; both tables apply in one nft transaction.
include "/etc/fips/fips.nft"
add table inet archipelago_host
delete table inet archipelago_host
table inet archipelago_host {
comment "Archipelago image firewall v1: host"
chain local_services {
# LAN/private mesh app UIs; wallet/RPC ports are not opened wholesale.
tcp dport { 22, 7777, APP_PORTS } accept
}
chain input {
type filter hook input priority 10; policy drop;
ct state invalid drop
iifname "lo" accept
ct state established,related accept
# fips0 already passed the independently default-denying FIPS chain.
iifname "fips0" accept
# IP errors/PMTU plus the link-local control traffic needed by IPv6.
ip protocol icmp icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
meta l4proto ipv6-icmp icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
meta l4proto ipv6-icmp ip6 hoplimit 255 icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept
meta l4proto ipv6-icmp ip6 saddr fe80::/10 ip6 hoplimit 255 icmpv6 type nd-router-advert accept
meta l4proto ipv6-icmp ip6 saddr fe80::/10 ip6 hoplimit 1 icmpv6 type { 130, 131, 132, 143 } accept
ip protocol icmp icmp type echo-request limit rate 5/second burst 10 packets accept
meta l4proto ipv6-icmp icmpv6 type echo-request limit rate 5/second burst 10 packets accept
# DHCP clients, never a host DHCP server or general DNS resolver.
udp sport 67 udp dport 68 accept
ip6 saddr fe80::/10 udp sport 547 udp dport 546 accept
# mDNS is link-local, including FIPS LAN discovery; not an arbitrary UDP port.
ip daddr 224.0.0.251 ip ttl 255 udp dport 5353 accept
ip6 daddr ff02::fb ip6 hoplimit 255 udp dport 5353 accept
# Dashboard, authenticated federation, encrypted FIPS transport, and P2P.
tcp dport { 80, 443, 5679, 8443, 8333, 9735 } accept
# FIPS published UDP (not upstream 8668), WireGuard, Tailscale transport.
udp dport { 2121, 51820, 41641 } accept
ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 } jump local_services
ip6 saddr { fc00::/7, fe80::/10 } jump local_services
iifname { "wg0", "tailscale0" } jump local_services
# The separately owned web_tunnel table must still authorize its peer.
iifname "wg-web" tcp dport { 18081, 18443 } accept
}
chain forward {
type filter hook forward priority 10; policy drop;
ct state invalid drop
ct state established,related accept
# Authenticated WireGuard clients retain their existing full-tunnel route.
iifname "wg0" ip saddr 10.44.0.0/16 oifname != "wg0" accept
# Rootless apps use host sockets. These are rootful Podman/CNI/Docker
# compatibility paths; their independent isolation/filter tables still run.
iifname "podman*" accept
iifname "cni-podman*" accept
iifname "docker0" accept
ct status dnat oifname "podman*" accept
ct status dnat oifname "cni-podman*" accept
ct status dnat oifname "docker0" accept
# No blanket br-* or LAN/WAN route: br-tollgate must never become free access.
}
chain output {
type filter hook output priority 10; policy accept;
}
}
'''.replace('APP_PORTS', ports)
return {
'etc/archipelago/host-firewall.nft': host.encode(),
'etc/fips/fips.nft': fips.encode(),
'etc/fips/fips.d/80-web-ui.nft': (MARKER + 'tcp dport { 80, 443, 8443, 5679 } accept\n').encode(),
'etc/fips/fips.d/85-app-ports.nft': (MARKER + 'tcp dport { ' + ports + ' } accept\n').encode(),
'etc/fips/fips.d/90-ssh.nft': (MARKER + '# SSH over mesh is disabled until explicitly appointed.\n').encode(),
}
def protected(path, root, directory=False):
relative = path.relative_to(root)
current = root
for part in ('', *relative.parts):
if part:
current /= part
info = current.lstat()
is_dir = current != path or directory
if (not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode) and info.st_nlink == 1)
or info.st_uid != os.geteuid() or info.st_mode & 0o022):
raise ValueError('unprotected firewall object: ' + str(current))
if not directory and info.st_size > 65536:
raise ValueError('firewall input exceeds limit')
def run_nft(args, nft='/usr/sbin/nft'):
result = subprocess.run([nft, *args], stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=15, env={'PATH': '/usr/sbin:/usr/bin', 'LC_ALL': 'C'})
if result.returncode or len(result.stdout) > 1048576:
raise ValueError('nft check/apply failed; existing kernel rules retained or outcome requires inspection')
return result.stdout
def apply(root=Path('/'), nft='/usr/sbin/nft'):
# The CLI has a fixed root; the parameter exists for disposable namespace tests.
base = root / 'etc/archipelago/host-firewall.nft'
fips = root / 'etc/fips/fips.nft'
dropins = root / 'etc/fips/fips.d'
protected(dropins, root, True)
files = [base, fips, *sorted(dropins.glob('*.nft'))]
if len(files) > 66:
raise ValueError('too many firewall drop-ins')
total = 0
for path in files:
protected(path, root)
total += path.stat().st_size
if total > 131072 or not base.read_text().startswith(MARKER) or not fips.read_text().startswith(MARKER):
raise ValueError('unrecognized image firewall baseline')
tables = json.loads(run_nft(['--json', 'list', 'tables'], nft))['nftables']
for row in tables:
table = row.get('table', {})
name = table.get('name')
if table.get('family') == 'inet' and name in TABLES:
observed = json.loads(run_nft(['--json', 'list', 'table', 'inet', name], nft))['nftables']
declarations = [entry['table'] for entry in observed if 'table' in entry]
if len(declarations) != 1 or declarations[0].get('comment') != TABLES[name]:
raise ValueError('foreign reserved table preserved: ' + name)
run_nft(['--check', '--file', str(base)], nft)
run_nft(['--file', str(base)], nft)
if __name__ == '__main__':
if len(sys.argv) != 1 or os.geteuid() != 0:
raise SystemExit('fixed root firewall service takes no arguments')
fd = os.open('/run/archipelago-host-firewall.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_nlink != 1 or info.st_mode & 0o077:
raise ValueError('unsafe firewall lock')
fcntl.flock(fd, fcntl.LOCK_EX)
apply()
finally:
os.close(fd)