173 lines
8.0 KiB
Python
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)
|