Local firewall on Home Assistant?

Ok so I did spend a few hours working on this today, and bearing in mind that my configuration is:

  • SSH addon (port 22)
  • NGINX addon for HTTPS (port 443)
  • Wireguard addon (UDP 51820)
  • HAOS debug server enabled (port 22222)
  • I want SSDP (UPnP) to work so that HA can read my router’s state

and I don’t need any other port to be open on the local network (I can just connect securely via the wireguard IP if I need access to any other port directly),

I eventually set up the following iptables rules (please don’t set that if you’re using the default configuration without SSH credentials nor working port 443 proxy because you’d lose all access):

iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -i docker0 -j ACCEPT
iptables -A INPUT -i hassio -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 22222 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
iptables -A INPUT -p udp --dport 1900 -j ACCEPT
iptables -A INPUT -p icmp -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -j REJECT

iptables -A DOCKER-USER -i lo -j RETURN
iptables -A DOCKER-USER -i docker0 -j RETURN
iptables -A DOCKER-USER -i hassio -j RETURN
iptables -A DOCKER-USER -p tcp --dport 22 -j RETURN
iptables -A DOCKER-USER -p tcp --dport 22222 -j RETURN
iptables -A DOCKER-USER -p tcp --dport 443 -j RETURN
iptables -A DOCKER-USER -p udp --dport 51820 -j RETURN
iptables -A DOCKER-USER -p udp --dport 1900 -j RETURN
iptables -A DOCKER-USER -p icmp -j RETURN
iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
iptables -A DOCKER-USER -j REJECT
iptables -D DOCKER-USER -j RETURN

ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A INPUT -i docker0 -j ACCEPT
ip6tables -A INPUT -i hassio -j ACCEPT
ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 22222 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
ip6tables -A INPUT -p udp --dport 51820 -j ACCEPT
ip6tables -A INPUT -p udp --dport 1900 -j ACCEPT
ip6tables -A INPUT -p ipv6-icmp -j ACCEPT
ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
ip6tables -A INPUT -j REJECT

ip6tables -A DOCKER-USER -i lo -j RETURN
ip6tables -A DOCKER-USER -i docker0 -j RETURN
ip6tables -A DOCKER-USER -i hassio -j RETURN
ip6tables -A DOCKER-USER -p tcp --dport 22 -j RETURN
ip6tables -A DOCKER-USER -p tcp --dport 22222 -j RETURN
ip6tables -A DOCKER-USER -p tcp --dport 443 -j RETURN
ip6tables -A DOCKER-USER -p udp --dport 51820 -j RETURN
ip6tables -A DOCKER-USER -p udp --dport 1900 -j RETURN
ip6tables -A DOCKER-USER -p ipv6-icmp -j RETURN
ip6tables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
ip6tables -A DOCKER-USER -j REJECT
ip6tables -D DOCKER-USER -j RETURN

Note that:

  • It is necessary to set both INPUT and DOCKER rules, otherwise rules that go through forwarding to docker bypass the INPUT rules and don’t get filtered out
  • It is necessary to set the same rules with ip6tables, otherwise while ipv4 traffic will be filtered by iptables, ipv6 traffic would still be open
  • on ipv6, icmp is not called icmp but it’s called ipv6-icmp. It is very necessary to have it somewhat open for ipv6 traffic to work (unlike ipv4 that can work without and its icmp is essentially used for pings).
  • iptables rules do not persist across reboots

It is possible to set these rules via the SSH Addon, since the iptables rules are shared with the host. (At least for me with Protection mode disabled - I don’t know how that would look if it wasn’t).

The question for me now is how do I make it so that these rules are setup before all the docker containers are started, so that there isn’t a short period during boot where everything is accessible before the firewall rules are setup.

I could access the host via the Host debug SSH server, however there almost all of the filesystem is very read-only, so I can’t set the files to set this up on boot before the HA services start:

> mount
/dev/mmcblk0p3 on / type erofs (ro,relatime,user_xattr,acl,cache_strategy=readaround)
devtmpfs on /dev type devtmpfs (rw,relatime,size=781968k,nr_inodes=195492,mode=755)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
securityfs on /sys/kernel/security type securityfs (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000)
tmpfs on /run type tmpfs (rw,nosuid,nodev,size=392352k,nr_inodes=819200,mode=755)
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
pstore on /sys/fs/pstore type pstore (rw,nosuid,nodev,noexec,relatime)
bpf on /sys/fs/bpf type bpf (rw,nosuid,nodev,noexec,relatime,mode=700)
tmpfs on /etc/machine-id type tmpfs (ro,size=392352k,nr_inodes=819200,mode=755)
hugetlbfs on /dev/hugepages type hugetlbfs (rw,nosuid,nodev,relatime,pagesize=2M)
mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime)
debugfs on /sys/kernel/debug type debugfs (rw,nosuid,nodev,noexec,relatime)
fusectl on /sys/fs/fuse/connections type fusectl (rw,nosuid,nodev,noexec,relatime)
configfs on /sys/kernel/config type configfs (rw,nosuid,nodev,noexec,relatime)
/dev/mmcblk0p1 on /mnt/boot type vfat (rw,relatime,sync,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro)
/dev/mmcblk0p7 on /mnt/overlay type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/dropbear type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/modprobe.d type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/modules-load.d type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/udev/rules.d type ext4 (rw,relatime)
/dev/mmcblk0p7 on /root/.docker type ext4 (rw,relatime)
/dev/mmcblk0p7 on /root/.ssh type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/NetworkManager/system-connections type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/hostname type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/hosts type ext4 (rw,relatime)
/dev/mmcblk0p7 on /etc/systemd/timesyncd.conf type ext4 (rw,relatime)
/dev/mmcblk0p8 on /mnt/data type ext4 (rw,relatime,commit=30)
/dev/zram2 on /tmp type ext4 (rw,nosuid,nodev,nobarrier)
tmpfs on /var type tmpfs (rw,nosuid,nodev,relatime,nr_inodes=1048576)
/dev/mmcblk0p7 on /var/lib/NetworkManager type ext4 (rw,relatime)
/dev/mmcblk0p7 on /var/lib/bluetooth type ext4 (rw,relatime)
/dev/mmcblk0p8 on /var/lib/docker type ext4 (rw,relatime,commit=30)
/dev/mmcblk0p7 on /var/lib/systemd type ext4 (rw,relatime)
/dev/mmcblk0p8 on /var/log/journal type ext4 (rw,relatime,commit=30)

Side note: I’d also like to be able to run the workaround command for HAOS reverse DNS takes 7.5s before it fails, making lots of things painfully slow · Issue #3768 · home-assistant/operating-system · GitHub on boot at the same time. It’s less critical that it runs early, in fact it needs to run after the corresponding systemd service is up, but it needs to run on the host, unlike iptables that could theoritically run in the SSH addon if not for the “firewall should be setup before services are started” issue.

Until I find a way to solve this I’m setting up the rules as a startup script of the SSH addon, which is better than nothing.

#!/usr/bin/env python3

import subprocess

# The -m ... and --reject-with statements are just here so that we match the output of iptables -S
# but they are not necessary when otherwise creating the rules, as these are default values.
ipv4_rules = [
    "-A INPUT -i lo -j ACCEPT",
    "-A INPUT -i docker0 -j ACCEPT",
    "-A INPUT -i hassio -j ACCEPT",
    "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT",  # ssh addon
    "-A INPUT -p tcp -m tcp --dport 22222 -j ACCEPT",  # ssh HAOS host
    "-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT",  # https
    "-A INPUT -p udp -m udp --dport 51820 -j ACCEPT",  # wireguard
    "-A INPUT -p udp -m udp --dport 1900 -j ACCEPT",  # SSDP (UPnP)
    "-A INPUT -p icmp -j ACCEPT",  # ping
    "-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT",
    "-A INPUT -j REJECT --reject-with icmp-port-unreachable",
]


def run_command(command: str) -> str:
    print(f"> {command}")
    return subprocess.run(
        command, capture_output=True, text=True, check=True, shell=True
    ).stdout


def lines(command: str) -> set[str]:
    command_output = run_command(command)
    print(f"{command_output}")
    lines = command_output.splitlines()
    lines = (line.strip() for line in lines if len(line.strip()) > 0)
    return set(lines)


print("Setting up firewall rules")


existing_ipv4_rules = lines("iptables -S")
existing_ipv6_rules = lines("ip6tables -S")


# Create all rules
for ipv4_rule in ipv4_rules:
    if ipv4_rule not in existing_ipv4_rules:
        run_command(f"iptables {ipv4_rule}")

    ipv4_docker_rule = ipv4_rule.replace("INPUT", "DOCKER-USER").replace(
        "ACCEPT", "RETURN"
    )
    if ipv4_docker_rule not in existing_ipv4_rules:
        run_command(f"iptables {ipv4_docker_rule}")

    ipv6_rule = ipv4_rule.replace("-p icmp", "-p ipv6-icmp").replace(
        "icmp-port-unreachable", "icmp6-port-unreachable"
    )
    if ipv6_rule not in existing_ipv6_rules:
        run_command(f"ip6tables {ipv6_rule}")

    ipv6_docker_rule = ipv6_rule.replace("INPUT", "DOCKER-USER").replace(
        "ACCEPT", "RETURN"
    )
    if ipv6_docker_rule not in existing_ipv6_rules:
        run_command(f"ip6tables {ipv6_docker_rule}")

# We can now clear the default rules that allow all docker forwarding
if "-A DOCKER-USER -j RETURN" in existing_ipv4_rules:
    run_command("iptables -D DOCKER-USER -j RETURN")

if "-A DOCKER-USER -j RETURN" in existing_ipv6_rules:
    run_command("ip6tables -D DOCKER-USER -j RETURN")

(This requires adding iptables & ip6tables to list of packages to install in the SSH addon.)