Docker Firewalling - Unpublishing a port

Intro

I have a Docker container that has a port unconditionally published (e.g. -p 2368:2368).

I've changed my mind and decided I don't want this port exposed to the entire internet; only the docker host should see it.

How do I fix this without having to rebuild the container?

Table of contents

Intro

This question (and its lack of immediately obvious answer) is one of the many annoying teething pains I've dealt with as a Docker beginner.

Fortunately for me, accidentally publishing the port wasn't a problem in practice because my hosting provider lets me configure an upstream cloud firewall, so the port was never going to be internet reachable.

Being bored one afternoon, I decided to open the port up (on the cloud firewall) to my home ip and experiment to see if I can take it off line (while still having it reachable from the host)

Port publishing: What is it and how does it work?'

When your bring up a Docker container for the first time, you are given the choice of whether to "publish" any internal ports.

Publishing ports is required for the ports to be accessible to the outside world (including the docker host).

When you publish a port, two things happens.

  • First, you open the port up.

    By default, Docker won't let any traffic reach the containers (from anywhere; the hosts, other containers) unless you specifically allow it. This happens at the Docker network driver level. In the case of port 2368, we can expose it port without NAT by -p 2368

  • Next is the NAT side of things. In the case of the syntax -p 2368:2368 that means that inbound connections to port 2368 on any of the hosts interfaces are forwarded to the docker container.

  • We can also expose it selectively by specifying an address on the host.

Looking at the Docker iptables rules

First, we look at the rules in the NAT table so we get an understanding of how docker rules work.

The rules in the nat chain are responsible for rewriting the addresses so that connections addressed to external addresses on the host actually end up inside the docker container, and that replies from the container have a sane source address. We have masquerading which is basically "lying" to your clients, changing the source address of egress packets. We also have DNAT rules, these are the rules that govern redirecting inbound traffic on outside interfaces to the docker interface.

When traffic comes in to any interface except docker0 and its destined for 2368/tcp redirect to 172.17.0.2:2368

$ iptables --table nat   --append DOCKER --in-interface docker0 
       --proto tcp   --match tcp     --dport 2368
       --jump DNAT   --to-destination 172.17.0.2:2368

When egress/reply traffic leaves via an interface that isn't docker0 but still has a 172.17.xx source address, we rewrite the source address (masquerade) to reflect the address of the exit interface

$ iptables --table nat           --append POSTROUTING 
           --src 172.17.0.0/16 ! --out-interface docker0
           --jump MASQUERADE

More importantly, we also have the rules which permit the forwarding (these are in the DOCKER chain of the filter table). Here, we are simply specifying that traffic is allowed to flow from non-docker interfaces ( ! docker0 ) to the docker network, provided its destination is 172.17.0.2:2368

This is the rule which we will override

$ iptables --table filter          --append DOCKER           
           --dst 172.17.0.2/32   ! --in-interface docker0 
           --out-interface docker0 --proto tcp
           --match tcp             --dport 2369
           --jump ACCEPT

Changing the rules

I had a bit of play with changing around a few of the rules.

The first thing I tried was replacing "anything but docker0" with the loopback as the allowed input interface.

$ iptables  --table filter    --replace DOCKER 1
            --in-interface lo --out-interface docker0 
            --dst 172.17.0.2  --proto tcp 
            --dport 2368      --jump ACCEPT

In most cases, this would have most likely worked because the FORWARD chain is usually a default deny policy. But mine had a policy of ACCEPT. I don't know if changing it to default deny complicates docker, so I'll leave it alone for now.

While, I can take it off-line through messing up the NAT rules, that's quite a silly way to do it and has the potential to be a headache. Unless you know whay you're doing, it's best to leave them alone

Using the docker-isolation chain

The Docker Isolation chain is used whenever you wish to override the default rules with your own restrictions. The main docker chain has new rules added to the top when new containers are created, but the isolation chain is static. It is evaluated before the main chain and thus takes precedence.

By default, the chain contains only a return statement. It is best that you insert rather than append new entries.

Example 1: Allow access to 172.17.0.2:2368 only from lo (on the host)
With this rule, we solve the problem this blog post was about. This rule also prevents access from other containers

$ iptables --table filter     --insert DOCKER-ISOLATION  
         ! --in-interface lo  --out-interface docker0 
           --dst 172.17.0.2   --proto tcp 
           --dport 2368       --jump  DROP

Example 2: Preventing a container from talking to an internal network

$ iptables --table filter         --insert DOCKER-ISOLATION
           --in-interface docker0 --src    172.17.0.17
           --dst 192.168.50.0/24  --jump   DROP

Example 3: Restrict egress traffic from a container

This would be a good application of an ipset. We have a set of allowed destinations. Anything that doesn't match is blocked

$ iptables --table filter          --insert DOCKER-ISOLATION 
           --in-interface docker0  --src    172.17.0.25 -m set 
         ! --match-set allowed_addr  dst  --jump DROP