unix / firewall

I front my web applications and CI servers with Cloudflare. Cloudflare provides DNS, a CDN, and a Web Application Firewall.

My servers run on virtual machines with public IPv4 and IPv6 addresses. The virtual machines in one region share one cloud firewall, so one rule change reaches every one of them.

The origin bypass problem

A CDN protects a public hostname. If the cloud firewall accepts connections from any address, a client can bypass the CDN. An automated scanner finds the origin IP address in certificate transparency logs or network scans.

Direct connections to port 80 or 443 skip edge security rules and consume origin resources.

Restricting ingress to CDN ranges

I lock down the network perimeter with cloud firewall rules. I accept connections on ports 80 and 443 only from Cloudflare IP ranges:

Two ports across 22 ranges require 44 rules. One more rule allows port 443 from the private subnet so virtual machines can reach each other.

Automating firewall rules

Entering 44 firewall rules manually in a web console takes time and invites typos.

I automate firewall synchronization with a small Go program: ubifw. The tool reads a declarative JSON configuration. It fetches current IP ranges directly from Cloudflare:

{
  "firewall": "default-firewall",
  "rules": [
    {
      "protocol": "tcp",
      "port": 22,
      "source": "0.0.0.0/0",
      "description": "SSH admin"
    },
    {
      "protocol": "tcp",
      "port": 443,
      "source": "cloudflare",
      "description": "Cloudflare HTTPS"
    }
  ]
}

The tool compares desired rules against the live cloud firewall through its REST API. It supports three phases:

  1. A dry run that displays planned additions and deletions.
  2. An apply phase that adds missing rules.
  3. A prune phase that deletes obsolete rules.

Before changing live rules, ubifw writes a local JSON rollback record. After an apply, it reads the firewall again to confirm the added rules exist. It deletes nothing until that read succeeds.

A dry run with nothing to change prints "no change". I run a dry run to find drift between the file and the live firewall.

Safe rollout order

I apply firewall changes in a safe order so I avoid downtime.

First, I ensure Cloudflare proxies the domain name. A DNS-only record resolves directly to the server IP address. A firewall lockdown then breaks client connections immediately.

Second, I add the CDN allow rules to the cloud firewall while the world rule stays active.

Third, I test the web application, Git over HTTPS, and CLI commands through the CDN.

Fourth, I delete the world rules, one for each port and address family.

Verifying with curl

I verify the lockdown with curl.

I test the public domain through the CDN:

curl -sI https://example.com/login

This request must return a 200 status code.

Then I test direct connection to the origin IP address:

curl -sk --max-time 5 --resolve example.com:443:198.51.100.1 https://example.com/login -I

This direct request must time out.

A dry run of ubifw must also print "no change".

Let's Encrypt renewals

Caddy provisions and renews TLS certificates via Let's Encrypt using HTTP-01 challenges on port 80. Cloudflare forwards ACME challenge requests to the origin.

I keep port 80 open to Cloudflare IP ranges on servers that renew certificates. Caddy serves ACME challenges and HTTPS redirects on port 80. It serves no application data on that port.

← All articles