Configuring Awall on Alpine Linux

Recently I have started to move more linux workloads to Alpine Linux. Its size, speed, and most importantly its simplicity has really won me over. As I start to move these workloads to Alpine based systems I needed to learn more about securing the servers with a firewall and in particular having the firewall play nicely with Docker.

In this post we will walk through step-by-step how to configure an Alpine Linux firewall called awall which is a simple interface for linux iptables.

Awall Background

Awall is a firewall tool in Alpine Linux that generates iptables. It uses a set of json configuration files that are converted to iptables for your firewall. The configuration files for awall live in /etc/awall.

The common practice is to store all of your user generated firewall rules in /etc/awall/optional or /etc/awall/private. Awall also ships with a set of pre-defined defaults you can refer to. These can be found in /usr/share/awall/mandatory.

If we take a look at the contents of /usr/share/awall/mandatory/services.json we see our first taste of what we can do with awall. The file below is shortened for brevity but it contains common definitions for services that use standard ports such as ssh or http.

{
  "http": { "proto": "tcp", "port": 80 },
  "http-alt": { "proto": "tcp", "port": 8080 },
  "https": { "proto": "tcp", "port": 443 },
  "ping": [
    { "proto": "icmp", "type": 8, "reply-type": 0 },
    { "proto": "icmpv6", "type": 128, "reply-type": 129 }
  ],
  "ssh": { "proto": "tcp", "port": 22 }
}

We will use these definitions in our firewall rules and can even create our own shortcuts if needed.

Default deny all

We will start with a firewall that allows nothing and then add only ssh and ping access to start. This will help get us comfortable with working with awall before allowing more external traffic.

We will create two files to accomplish this. First create the file /etc/awall/optional/default.json which will define our network interfaces and will setup a basic deny rule. You will need to know what eth interfaces you have so run ip link show to get that information. In my this case the host has a single interface eth0.

{
  "description": "default deny all",
  "zone": {
    "WAN": { "iface": ["eth0"] }
  },
  "policy": [{ "in": "WAN", "action": "drop" }, { "action": "reject" }]
}

In this rule we define a zone which can be referenced in other policies. We define our WAN zone and include a single interface. Note that you can also use the syntax eth+ where + indicates all numbers from 0 onwards. This is handy if you have multiple interfaces such as eth1 and eth2. The iface property is an array and can accept multiple interfaces if needed.

We then define a policy to drop all incoming traffic on WAN. We also have a blanket reject policy for all other traffic.

Allow ssh

We then want to enable ssh access otherwise we would lose the connection to our server. Lets create another file /etc/awall/optional/ssh.json. In this file we allow ssh and we utilize the built in ssh service from the built-in awall services we looked at before. We allow traffic in on the WAN interfaces and out the _fw which is the built in zone for the firewall.

//file:/etc/awall/optional/ssh.json
{
  "description": "allow ssh",
  "filter": [
    {
      "in": "WAN",
      "out": "_fw",
      "service": "ssh",
      "action": "accept"
    }
  ]
}

Allow ping

Another useful tool for debugging is the ability to ping so lets go ahead and add that as well.

//file:/etc/awall/optional/ping.json
{
  "description": "allow ping",
  "filter": [
    {
      "in": "WAN",
      "service": "ping",
      "action": "accept"
    }
  ]
}

Enable the firewall

At this point we have a decent firewall setup so lets enable the policies and test them out.

To list the policies created above run awall list. This should give you a read out of the policies.

awall list

default   disabled  default deny all
ping      disabled  allow ping
ssh       disabled  allow ssh

As you can see they are all disabled so lets go ahead and enable them one-by-one.

awall enable default
awall enable ssh
awall enable ping

With our rules enabled we need to tell awall to generate the iptables and then enforce them. One nice thing about awall is that it requires double confirmation. When you run awall activate it will start enforcing your rules right away - but if you dont confirm after a short period it will rollback the changes preventing you from a potential perilous mistake.

Go ahead and activate the rules with awall activate.

Checking ping

If your rules applied correctly you should be able to ping your server at its IP address. At this point try to play around with enabling and disabling the ping policy.

Run a persistent ping against your server from your machine, then run awall disable ping && awall activate on the server. You should be able to see in realtime the ping stop working when the policy is disabled.

Inbound and outbound

At this point we have a pretty restrictive firewall which makes our server kind of useless. Lets make things more interesting and enable inbound http and https connections. We will also enable outbound connections for a number of typical protocols.

//file:/etc/awall/optional/inbound.json
{
  "description": "allow inbound http/https",
  "filter": [
    {
      "in": "WAN",
      "out": "_fw",
      "service": ["http", "https"],
      "action": "accept"
    }
  ]
}

Our inbound policy will allow http and https. Again these shortcuts are defined for us by the default awall services and refer to port 80 and 443.

//file:/etc/awall/optional/outbound.json
{
  "description": "allow outbound dns, http/https, ssh, ntp, ssh and ping",

  "filter": [
    {
      "in": "_fw",
      "out": "WAN",
      "service": ["dns", "http", "https", "ssh", "ntp", "ping"],
      "action": "accept"
    }
  ]
}

Our outbound policy allows our server to send dns requests, http and https requests, as well as ssh, ntp, and ping requests.

Go ahead and enable the policies with awall enable inbound and awall enable outbound. Then run awall activate.

Docker

We now have a simple firewall that can protect our server for any number of internet workloads. In order to facilitate running our workloads we will utilize Docker to run containers. Docker is a first class citizen on Alpine so we can run apk add docker to get it on our machine.

We want Docker to run at startup so we add it to the startup config by running rc-update add docker boot. Now that its registered as a service we can run service docker start and then service docker status to confirm its up an running.

At this point if you were to run a container it would startup but that container will have zero connectivity. It wont be able to connect to the internet and it wont be able to connect to other containers running within Docker. Lets fix that!

Back in our /etc/awall/optional/default.json we need to add the interfaces that Docker manages. Lets go ahead and add a zone for all Docker interfaces.

{
  "description": "default deny all connections",
  "variable": { "awall_dedicated_chains": true },
  "zone": {
    "WAN": { "iface": "eth+" },
    "DOCKER": { "iface": ["docker+", "br-+"] }
  },
  "policy": [{ "in": "WAN", "action": "drop" }, { "action": "reject" }]
}

Our new DOCKER zone includes all numbered interface that start with docker. We also add all numbered interfaces that start with br-.

Whenever you create a docker network the dameon creates a new interface for that network to communicate on. This allows containers connected to the same docker network to talk to each other.

To test this out create a new docker network with docker network create my-network. Then run ip link show and you will see a new interface with the prefix br-. This is mapped to my-network.

Delete the network with docker network rm my-network and your br- interface will be gone.

So now a DOCKER zone exists but we need to allow traffic. Lets edit our inbound and outbound policies. You can place this in a separate policy in /etc/awall/optional/docker.json if you prefer to control this policy separately.

//file:/etc/awall/optional/inbound.json
{
  "description": "allow inbound http/https",

  "filter": [
    {
      "in": "WAN",
      "out": "_fw",
      "service": ["http", "https"],
      "action": "accept"
    },
    {
      "in": "DOCKER",
      "action": "accept"
    }
  ]
}
//file:/etc/awall/optional/outbound.json
{
  "description": "allow outbound dns, http/https, ssh, ntp, ssh and ping",

  "filter": [
    {
      "in": "_fw",
      "out": "WAN",
      "service": ["dns", "http", "https", "ssh", "ntp", "ping"],
      "action": "accept"
    },
    {
      "out": "DOCKER",
      "action": "accept"
    }
  ]
}

These two rules allow your containers to talk to each other over any docker network created. It also allows the containers to have access to the internet.

Protecting unwanted container access

Docker has had a bit of a notorious streak with linux firewalls. Docker creates its own set of iptables which do not play nicely with many typical linux firewalls such as ufw or firewalld. This ultimately ends up in tragedy since people think their hosts are secure but Docker ends up exposing workloads directly to the internet unexpectedly.

You might expect that the inbound policy created earlier would prevent external communication with containers on ports other than 22, 80, or 443 - but this is sadly not the default behavior.

To demonstrate the problem run a nginx container on a port other than the ones defined in the inbound policy and try to access that port. An example of this might be docker run -it -p 88:80 nginx. The reality is you must be careful about how you expose ports. You may be used to simply passing -p 80:8080 for your web server or -p 5432:5432 for your postgres database - however using -p without a host ip by default exposes your workload on 0.0.0.0.

There are a number of potential ways to make a firewall with iptables that coincide with the Docker rules but its likely best not to attempt that unless you are an iptables guru.

Proxies and tunnels

The simple way to protect your workloads is to not expose them outside of your server at all and utilize a proxy or a secure tunnel.

In order to prevent this from happening you should get in the habit of only exposing a single reverse proxy container which maps exactly to your firewall ports - for us that would be 80 or 443. You can also utilize Cloudflare Tunnel (formerly Argo) which creates a secure tunnel from inside your server so nothing needs to be exposed at all.

If you do have containers you need to expose locally or to other containers you can utilize -p 127.0.0.1:5432:5432. The 127.0.0.1 reference in front of the host port will only expose this container to the local machine and not externally on the host.