Firewall
The firewall module tags requests whose IP address or User-Agent is on a list, and can keep each list in sync with a Cloudflare WAF custom rule.
Hyperwatch doesn’t block anything itself: matching logs get a firewall field ({ list, action, value }), and the /firewall endpoint counts matches per list. Blocking happens at the edge, in Cloudflare.
Enabling the module
Section titled “Enabling the module”{ "modules": { "firewall": { "active": true, "path": "/path/to/firewall.json" } }}path defaults to firewall.json in the working directory. The file is reloaded within 5 seconds of a change. If a change is invalid, the previous lists stay in use and a warning is printed.
firewall.json
Section titled “firewall.json”{ "lists": [ { "id": "block-ips", "type": "ip", "action": "block", "cloudflare": { "rule_id": "c7fdacfb7ae3498a9d268e9117b3f8eb" }, "entries": [ { "value": "203.0.113.7", "reason": "Spam signups", "added": "2026-09-23", "source": "dashboard" }, { "value": "2001:db8::/32" } ] }, { "id": "challenge-user-agents", "type": "user_agent", "match": "eq", "action": "challenge", "cloudflare": { "rule_id": "45b7c00748d04d16b213d0dac77f536e" }, "entries": [ { "value": "Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/104.0.5112.48" } ] } ]}| Field | Description |
|---|---|
id |
Unique list name |
type |
ip: IPv4/IPv6 addresses and CIDRs. user_agent: User-Agent strings |
match |
user_agent lists only: eq (exact, default) or contains (substring) |
action |
block, challenge (Cloudflare managed challenge) or monitor (local only) |
cloudflare |
Optional. { "rule_id": "..." } links the list to a custom rule on the zone |
entries |
value is required. reason, added and source are free-form metadata |
- The first matching list, in file order, wins.
- IPv6 addresses are stored in their canonical form (
2001:db8::1). CIDRs must not have host bits set (10.0.0.0/8, not10.0.0.1/8). - Use
addEntry/removeEntryfromsrc/lib/firewall/liststo edit the file from code.savewrites atomically.
HTTP API
Section titled “HTTP API”Besides the /firewall aggregator (matches per list), the module serves:
| Endpoint | Description |
|---|---|
GET /firewall/lists.json |
All lists and their entries. Linked lists get pending: { added, removed } since the last sync, or null if never synced |
POST /firewall/lookup |
{ "addresses": [...], "user_agents": [...] } → the matching { list, action, value } (or null) for each |
POST /firewall/lists/:id/add |
{ "value", "reason", "source" } adds an entry |
POST /firewall/lists/:id/remove |
{ "value" } removes an entry |
Edits write firewall.json and apply right away. They don’t touch Cloudflare: run hyperwatch firewall sync up to push them.
Syncing with Cloudflare
Section titled “Syncing with Cloudflare”Each linked list owns one custom rule in the zone’s http_request_firewall_custom phase. Hyperwatch writes the rule’s whole expression:
ip:(ip.src in {203.0.113.7 2001:db8::/32})user_agent:(http.user_agent eq "a") or (http.user_agent eq "b"), orcontainsforcontainslists
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_ID=... hyperwatch firewall sync down --dry-runThe token needs permission to edit the zone’s WAF custom rules. --dry-run only reads them.
Each sync goes one way:
sync downapplies the values added or removed in Cloudflare since the last sync tofirewall.json, along with the rule’s action and description. It never writes to Cloudflare. Values added in Cloudflare get"source": "cloudflare".sync upapplies the values added or removed infirewall.jsonsince the last sync to the Cloudflare rule, along with the list’s action and description. It never changesfirewall.json.- Neither direction undoes a change still pending on the side it writes to:
sync downdoesn’t bring back a value you removed locally, andsync updoesn’t remove a value added in Cloudflare. - For a full sync, run
sync down, thensync up. - The last agreed state is kept in
firewall.sync.json, next tofirewall.json. Keep that file withfirewall.json: without it, the next sync is treated as a first sync, and removals are lost. On a first sync,downimports every value only in Cloudflare anduppushes every value only infirewall.json. - If the rule or
firewall.jsonchanges while a sync runs, that list is left alone, and the next sync finishes the job.
Sync refuses to touch a list, and says why, when:
- the rule’s expression isn’t one Hyperwatch would write (someone edited the rule by hand)
- the rule’s action has no list equivalent (
sync down) - the expression would go over Cloudflare’s 4,096-character limit. Split the list (
sync up) - the rule would end up empty (
sync up)
A rule’s enabled/disabled state is left as it is in Cloudflare.
hyperwatch firewall sync up|down [--dry-run] [--list <id>] [--file firewall.json] [--state firewall.sync.json]hyperwatch firewall check [--file firewall.json]hyperwatch firewall migrate <legacy-firewall.json> [--out firewall.json] [--force]hyperwatch firewall is only treated as a firewall command when it’s followed by one of these commands, an option, or nothing. Any other hyperwatch <path> still starts the server with that config file.
migrate converts the older rule-based format ({ "rules": [{ "id", "action", "match", "cloudflare" }] }):
- Rules that only match IPs (
address,addresses,cidrs) or exact user agents (user_agents) become lists. Cloudflare-linked rules keep their link. - Everything else (signatures, headers, identity, ASNs,
ua_regex, combined conditions) is written unchanged tofirewall.legacy.json.