DEV Community

Carrie
Carrie

Posted on

SafeLine WAF running on Rootless Docker

  • This article was originally written by obuno.
  • Original source: https://blog.synack.li/posts/safeline-on-rootless-docker/
  • The technical details in this article have not been officially verified by the SafeLine team. Please test in a non-production environment and do not apply directly to production systems. SafeLine is not responsible for any damage or issues caused by improper use.

In today’s post we’ll get going at getting SafeLine
excellent WAF (Web Application Firewall) to agree at running on Rootless Docker setup.

Prerequisites#

  • Docker installed in rootless mode (dockerd-rootless-setuptool.sh install)
  • SafeLine CE compose.yaml and .env present
  • sudo access for sysctl (one-time)

Setting up Docker in Rootless mode is a bit beyond the goal of that article, you’ll find all you need here.

Once this has been done, let’s get down at making SafeLine run on such a setup. In order to build your SafeLine setup, you’d need to do this by hands. That means that you’d need to download the docker-compose file and create your own .env file.

That is what I did logged in as the docker running user:

mkdir -p /home/user/data/safeline/
cd /home/user/data/safeline/
wget "https://waf.chaitin.com/release/latest/compose.yaml"
touch ".env"
cat > /home/user/data/safeline/.env << 'EOF'
SAFELINE_DIR=/home/user/data/safeline
IMAGE_TAG=latest
MGT_PORT=9443
POSTGRES_PASSWORD="<apassword>"
SUBNET_PREFIX=172.22.222
IMAGE_PREFIX=chaitin
ARCH_SUFFIX=
RELEASE=
REGION=-g
MGT_PROXY=0
EOF

Now comes a few identified issues, issues we will address further on:

Problem 1 — Ports 80/443 not binding on the host:#

In rootless Docker, network_mode: host does not mean the real host network. Containers land in the rootlesskit network namespace instead. As a result, nginx inside safeline-tengine binds to 80/443 correctly inside the container, but those ports are never exposed to the real host interface. Additionally, rootless Docker cannot bind privileged ports (< 1024) without a sysctl change.

Problem 2 — Real client IPs not visible to SafeLine (SNAT):#

Rootlesskit’s default port driver SNATs all incoming traffic before it reaches the container, so SafeLine/nginx sees the rootlesskit gateway IP instead of the real client IP. This breaks IP-based WAF features: block lists, rate limiting, geo-blocking and IP reputation rules all become ineffective. The fix is to switch the port driver to slirp4netns, which handles port forwarding at a lower level and preserves the original source IP.

Now let’s fix these issues:

Step 1 — Switch rootlesskit port driver to slirp4netns#

This is the most important step — it both enables privileged port binding and preserves real client IPs. With slirp4netns as the port driver, CAP_NET_BIND_SERVICE via setcap is no longer needed or effective; the sysctl approach (Step 2) is the only path for privileged ports.

Create the Docker daemon override file (under your docker user owner):

bashCopy
mkdir -p ~/.config/systemd/user/docker.service.d
cat > ~/.config/systemd/user/docker.service.d/override.conf << EOF
[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=slirp4netns"
EOF

Reload and restart the Docker user daemon:


bashCopy
systemctl --user daemon-reload
systemctl --user stop docker
pkill rootlesskit # ensure full teardown
systemctl --user start docker
systemctl --user status docker

Verify the driver is active:


bashCopy
cat ~/.config/systemd/user/docker.service.d/override.conf
systemctl --user show docker | grep Environment

Step 2 — Lower the unprivileged port start on the host#

Required for binding ports 80/443 in rootless mode. With slirp4netns as the port driver, this is the only supported method — setcap cap_net_bind_service on rootlesskit does not work with the slirp4netns port driver.

bashCopy
# Temporary (verify first)
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80

# Persistent (survives reboot)
echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee /etc/sysctl.d/99-unprivileged-ports.conf
sudo sysctl --system

Verify:


bashCopy
sudo sysctl net.ipv4.ip_unprivileged_port_start
# Expected: net.ipv4.ip_unprivileged_port_start = 80

Step 3 — Fix the tengine service in compose.yaml#

Why this is needed#

SafeLine’s default compose.yaml uses network_mode: host for tengine with no explicit port mappings. In rootless Docker this means nginx binds inside the rootlesskit netns only — invisible to the real host.

The fix#

Edit compose.yaml. Find the tengine service and remove network_mode: host, replacing it with explicit port mappings and a network assignment:

yamlCopy
  tengine:
container_name: safeline-tengine
restart: always
image: ${IMAGE_PREFIX}/safeline-tengine${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
ports:
- "80:80"
- "443:443"
networks:
safeline-ce:
ipv4_address: ${SUBNET_PREFIX}.x # pick a free IP — see note below
volumes:
# ... unchanged ...
environment:
# ... unchanged ...
ulimits:
nofile: 131072
# network_mode: host ← REMOVE this line

Finding a free IP: Check .env for SUBNET_PREFIX, then review other containers' ipv4_address entries in compose.yaml to pick an unused last octet.

I went for this:
networks:
safeline-ce:
ipv4_address: ${SUBNET_PREFIX}.6

Remove any sysctls block from tengine (if present)#

If your compose file has this under tengine, remove it — it is not permitted with explicit port mappings:

yamlCopy
# REMOVE if present:
sysctls:
- net.ipv4.ip_unprivileged_port_start=0

Step 4 — Bring SafeLine up#

bashCopy
cd /path/to/safeline
docker compose up -d
docker compose ps

Expected state — all containers Up:


safeline-tengine    Up
safeline-mgt Up
safeline-detector Up
safeline-pg Up
safeline-chaos Up
safeline-fvm Up
safeline-luigi Up

Step 5 — Verify port binding on the host#

bashCopy
ss -tlnp | grep -E ':80|:443|:9443'

Expected — slirp4netns owning all three ports:


LISTEN 0 1 0.0.0.0:80        0.0.0.0:*    users:(("slirp4netns",...))
LISTEN 0 1 0.0.0.0:443 0.0.0.0:* users:(("slirp4netns",...))
LISTEN 0 1 0.0.0.0:9443 0.0.0.0:* users:(("slirp4netns",...))

You can also verify nginx is listening inside the container via /proc:


bashCopy
docker exec safeline-tengine cat /proc/1/net/tcp | awk '{print $2}' | grep -E "^00000000:(0050|01BB)"
# 0x0050 = port 80, 0x01BB = port 443

Step 6 — Configure upstream applications#

Connecting tengine to an external app network (optional)#

If your upstream apps live in a separate Docker compose stack, attach tengine to their network:

yamlCopy
# In SafeLine compose.yaml

services:
tengine:
networks:
safeline-ce:
ipv4_address: ${SUBNET_PREFIX}.x
your-app-network: # join the upstream network
aliases:
- safeline-tengine

# Bottom of compose.yaml
networks:
safeline-ce:
external: false
your-app-network:
external: true
name: actual_docker_network_name # from: docker network ls

Find the network name:


bashCopy
docker network ls
docker inspect <upstream-container> | grep -A 5 Networks

Adding a site in SafeLine UI#

  1. Browse to https://<host>:9443
  2. Add your upstream app (IP:port or container name — see note below)
  3. SafeLine generates nginx vhost configs in /etc/nginx/sites-enabled/IF_backend_*
  4. nginx reloads automatically

Upstream addressing#

Method Status Notes
172.1x.x.x:PORT (static IP) Reliable if IPs are statically assigned in compose, I went for this
container_name:PORT SafeLine UI accepts it although nginx validation fails

Step 7 — Securing the SafeLine Admin Console on TCP:9443#

⚠️ Obviously, securing any external access toward port TCP:9443 is highly recommended, I did that through UFW rules on the host itself, thus allowing inbound connectivity to TCP:9443 for tolerated IP stacks only.

That’s it, you can now enjoy your Rootless SafeLine setup !
Hope this helps,
obuno

Top comments (0)