Thoughts on Whatnot
Docker and ip6tables
November 11, 2020

Update: Combined the RELATED and ESTABLISHED rules. Don’t know why I didn’t do that in the first place.

As I’ve mentioned previously, I run several IPFS nodes. I was looking to do some port forwarding for the network that one of them is on, so I took a look at the official documentation and noticed a comment that said

If your router and internet service provider (ISP) support IPv6, enabling it will mitigate some connection issues.

I found this interesting, as I’d never heard of IPv6 having any particularly interesting features besides just having a ton more addresses available. So I started doing some research. Turns out, while it’s true that that’s pretty much the main new feature, there are several others that are built on top of that that are really neat.

To summarize quickly, as it’s a tad outside the scope of this post, IPv6’s absurdly massive address space allows a global, WAN-level IP address to be assigned to every machine. This means that even machines behind a router inside a LAN can have a global IP address, meaning that, among other things, NAT is just simply completely unnecessary in the vast majority of situations. This is huge for peer-to-peer networks, as all I have to do is open up my router’s firewall to allow access to a specific port on my machine, and when my machine advertises its address, it can actually advertise an address that will reach it. Nothing special needs to be done to try to figure out what WAN address is necessary for peers outside the network to try to connect to to get to your machine.

Once I realized what a huge difference this would make to connectivity, I set about getting IPv6 enabled on my network. There were a few minor issues getting it running on my local network, but I got it working fairly quickly and it worked quite nicely.

Then I set about getting it set up on my DigitalOcean droplet. In Docker.

Turns out, IPv6 support is, much to my surprise, rather poor in Docker. It exists, and it could certainly be worse, but it has a number of very important features that are just simply missing. The main feature that it does have is the ability to assign a new IPv6 address from the host machine’s allowed block to each container, providing the same benefits that a LAN receives from a lack of NAT, but inside a single machine.

But there was a complication. Docker uses iptables to set up the various port rules and address bindings that containers are given via the -p flag that’s passed to the run command. The Linux kernel has long had support for iptables-style rules with IPv6, via ip6tables, but Docker provides no support for it. None. If you set up your containers to receive their own global addresses, they become completely exposed to the internet. With the default ip6tables rules, every port is just open by default.

So, how do you fix this? First of all, a quick summary of iptables at a basic level. The configuration for iptables splits rules into several different pre-defined ‘tables’. For this example, I’m only really going to deal with the filter table, which is the default. In each table are several pre-defined ‘chains’. These chains define an ordered series of rules that are applied to packets that are being handled by the kernel. The user can create new chains for organizational purposes, but the pre-defined chains are special. In the filter table, the pre-defined chains are INPUT, FORWARD, and OUTPUT, used for packets that are being sent specifically to the machine, packets that are being handled by the machine but are not for it, and packets being sent from the machine, respectively. Here’s an example listing, as reported by ip6tables -nL --line-numbers:

Chain INPUT (policy DROP)
num  target     prot opt source               destination
2    ACCEPT     all      ::/0                 ::/0                 state RELATED,ESTABLISHED
4    ACCEPT     tcp      ::/0                 ::/0                 tcp dpt:22
5    ACCEPT     tcp      ::/0                 ::/0                 tcp dpt:80
6    ACCEPT     tcp      ::/0                 ::/0                 tcp dpt:443
7    ACCEPT     udp      ::/0                 ::/0                 udp dpt:443

Chain FORWARD (policy DROP)
num  target     prot opt source               destination
1    ACCEPT     all      2030:abcd:ef::/64    ::/0
2    ACCEPT     all      ::/0                 ::/0                 state RELATED,ESTABLISHED
5    ACCEPT     tcp      ::/0                 ::/0                 tcp dpt:4001
7    ACCEPT     udp      ::/0                 ::/0                 udp dpt:4001

Chain OUTPUT (policy ACCEPT)
num  target     prot opt source               destination

The first thing to note is the policy DROP for the INPUT and FORWARD chains. This means that packets that are not matched by any rule in the chain are sent to the DROP target. There are three built-in targets, ACCEPT, which allows the packet through, REJECT, which actively rejects it, resulting in an error on the remote end, and DROP, which just completely ignores the packet.

Important: If you are SSHed in to a server where you intend to make changes, then before you make any changes to the INPUT chain’s policy, make sure that you explicitly allow TCP port 22, or whichever port you use for SSH, or otherwise you could lose your connection. To do so, simply run

# ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT

Now, you may have noticed that port 4001, the IPFS swarm port, is not in the INPUT chain. They’re in FORWARD. The containers, with their own addresses, don’t count as traffic being sent to the machine itself. Because the packets are just being forwarded, there isn’t really a concept with them of input and output. Instead, the only way to differentiate is via the source and destination addresses.

Thankfully, iptables allows you to match addresses based on a mask, rather than one-by-one. For this example, I’m assuming that the Docker containers have addresses in the 2030:abcd:ef:::/64 subnet. The first thing that you’ll need to do is to allow outgoing packets from machines in that subnet to be routed through your machine:

# ip6tables -A FORWARD -s 2030:abcd:ef::/64 -j ACCEPT

Next, you’ll want to allow packets sent to those machines via established connections to get to where they’re going. Otherwise you’re going to wonder, like I did, why my attempts to test the connection by curling a web page from inside a container just hang, despite the outbound packets being allowed. To do this, simply run:

# ip6tables -A FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT

And you’re set. Now you can open up ports more explicitly to expose them to the internet. For example, if you have an IPFS node in a container, like I do, you probably want to expose port 4001:

# ip6tables -A FORWARD -p tcp --dport 4001 -j ACCEPT
# ip6tables -A FORWARD -p udp --dport 4001 -j ACCEPT

Remember, once you have the rules set up the way you want, and this is by no means a comprehensive guide to everything you might want to do, such as enabling ICMPv6 packets, if you want to expose a port to access something that’s not in a container, add it to the INPUT chain, and to expose a port that is in a container, add it to the FORWARD chain.

I hope you found this helpful. I know that I probably will if I need to do this again in like five years and can’t remember how. Unless, of course, Docker gets their act together and adds proper routing and firewall support to IPv6, like they have in IPv4. Maybe around the same time that DigitalOcean stops only allocating 16 addresses per droplet. No, DigitalOcean, a /124 block is not nearly big enough.

Sigh… Well, what can I expect. IPv6 has only been viable for nearly a decade. I guess I can’t really expect anyone to actually support it properly yet.