A bash script for managing UFW firewall rules from a JSON configuration file. Useful for creating firewall rules to restrict or allow access from dynamic IP addresses — for example, home connections with changing IPs, VPN endpoints, or trusted services with DNS-based addresses.
Find a file
Paul Git 395bb47200
Close lock fd with bash {LOCK_FD} syntax
Remove unused is_url helper.
Add a comment block for acquire_lock and move the
parse_arguments header below it for clarity.
Fix indentation of a done in parse_rule_metadata.
2026-05-02 00:02:04 +08:00
.gitignore Initial version for release 2026-03-25 22:02:05 +08:00
AGENTS.md Add AGENTS.md and initial refactor ufw-myrules based on the AGENTS.md 2026-04-22 19:40:01 +08:00
CLAUDE.md Add AGENTS.md and initial refactor ufw-myrules based on the AGENTS.md 2026-04-22 19:40:01 +08:00
LICENSE Initial version for release 2026-03-25 22:02:05 +08:00
README.md Track and report ignored/failed UFW rules 2026-04-22 20:39:15 +08:00
rules.example.json Support binding rules to local IPs 2026-03-26 15:34:51 +08:00
ufw-myrules Close lock fd with bash {LOCK_FD} syntax 2026-05-02 00:02:04 +08:00

ufw-myrules v2.1

█░█ █▀▀ █░█░█   █▀▄▀█ █░█ █▀█ █░█ █░░ █▀▀ █▀
█▄█ █▀░ ▀▄▀▄▀   █░▀░█ ░█░ █▀▄ █▄█ █▄▄ ██▄ ▄█

A bash script for managing UFW firewall rules from a JSON configuration file. Useful for creating firewall rules to restrict or allow access from dynamic IP addresses — for example, home connections with changing IPs, VPN endpoints, or trusted services with DNS-based addresses.

Scope: This project is intended for Debian/Ubuntu systems only.

Each time the script runs, domain names are resolved via DNS lookup and rules are intelligently synced: stale rules are removed and new rules are created automatically.

Features

  • JSON-based configuration — define all your rules in a single, readable config file
  • DNS resolution — domain names are resolved to IPs on each run via dig
  • Smart sync — only adds missing rules and removes stale ones (no duplicate churn)
  • IPv4 and IPv6 support — handles both address families, with options to skip either
  • Bind to local IP — optionally restrict rules to specific local addresses, with automatic address family validation
  • Cron-friendly mode — silent unless changes are made or errors occur
  • Purge capability — remove all managed rules in one command
  • Allow and deny actions — whitelist or blacklist addresses per rule
  • URL address sources — fetch IP lists from remote URLs with configurable TTL-based caching
  • Cache management — flush cached URL responses on demand with --flush-cache
  • Dry-run mode — preview exactly which rules would be added or removed without touching UFW
  • Detailed change summary — after each run, every added and removed rule is listed individually with its action, source, destination, port, and protocol, so you always know exactly what changed

Dependencies

Package Command Install
bash bash Pre-installed on most systems
jq jq sudo apt install jq
dnsutils dig sudo apt install dnsutils
ufw ufw sudo apt install ufw
curl curl sudo apt install curl
coreutils sha256sum sudo apt install coreutils
iproute2 ip sudo apt install iproute2
util-linux flock sudo apt install util-linux

Installation

Download the script:

sudo wget -O /usr/local/bin/ufw-myrules \
https://code.paulg.it/paulgit/ufw-myrules/raw/branch/main/ufw-myrules
sudo chmod +x /usr/local/bin/ufw-myrules

Create the configuration directory and copy the example config:

sudo mkdir -p /etc/ufw-myrules
sudo cp rules.example.json /etc/ufw-myrules/rules.json

Edit the config to match your environment:

sudo nano /etc/ufw-myrules/rules.json

Configuration

The configuration file is a JSON file containing a rules array. Each rule object defines a set of firewall rules to create.

Fields

Field Type Required Description
name string Yes A human-readable label for the rule (used in log output)
addresses array of strings/objects Yes IP addresses, CIDR ranges, domain names, or URL sources (as objects)
ports array of integers Yes Port numbers to allow/deny
protocols array of strings Yes Protocols: "tcp", "udp", or both
action string Yes "allow" or "deny"
bind string or array of strings No Local IP address(es) to bind the rule to. If omitted, rules apply to all addresses (to any).
  • addresses can contain IPv4 addresses (203.0.113.5), IPv6 addresses (2001:db8:1::/48), CIDR ranges (198.51.100.0/24), or domain names (home.example.com). Domain names are resolved via DNS on each run.
  • ports are integer port numbers. Multiple ports per rule are supported.
  • protocols accepts "tcp" and/or "udp". Each protocol generates separate UFW rules.
  • bind restricts the rule to traffic destined for a specific local IP on the host. See the Bind addresses section below.

Bind addresses

By default, rules apply to all IP addresses on the host (UFW's to any). The optional bind field lets you restrict a rule so it only applies to traffic destined for a specific local IP address.

This is useful when your server has multiple IP addresses (e.g. a management IP, a public IP, or both IPv4 and IPv6 addresses) and you want fine-grained control over which address a rule applies to.

Address family validation: When bind is set, the script automatically validates that source and bind addresses are the same IP family. IPv4 source addresses are only paired with IPv4 bind addresses, and IPv6 sources only with IPv6 bind addresses. Mismatched pairs are silently skipped — no errors, no invalid rules.

Single bind address

Bind to one specific local IP:

{
  "name": "Allow SSH on management interface",
  "addresses": ["203.0.113.5", "home.example.com"],
  "ports": [22],
  "protocols": ["tcp"],
  "action": "allow",
  "bind": "10.0.0.1"
}

Only IPv4 source addresses will generate rules (bound to 10.0.0.1). Any IPv6 addresses resolved from home.example.com are skipped since there is no IPv6 bind address.

Dual-stack bind (array)

Provide both IPv4 and IPv6 bind addresses:

{
  "name": "Allow Web on public IPs",
  "addresses": ["198.51.100.0/24", "2001:db8:1::/48"],
  "ports": [80, 443],
  "protocols": ["tcp"],
  "action": "allow",
  "bind": ["203.0.113.1", "2001:db8::1"]
}

This generates:

  • 198.51.100.0/24 → bound to 203.0.113.1 (both IPv4 )
  • 2001:db8:1::/48 → bound to 2001:db8::1 (both IPv6 )

Multiple same-family bind addresses

If your host has multiple IPv4 addresses and you want the rule on all of them:

{
  "name": "Allow HTTPS on both public IPs",
  "addresses": ["10.0.0.0/8"],
  "ports": [443],
  "protocols": ["tcp"],
  "action": "allow",
  "bind": ["203.0.113.1", "203.0.113.2"]
}

This creates two rules per source — one bound to each public IP.

Behaviour summary

Scenario Behaviour
bind omitted Uses to any — rules apply to all local IPs (unchanged from previous versions)
bind set to a single IP Rules are restricted to that local IP; only sources of the same address family get rules
bind set to an array Each source is paired with all bind addresses of the same family
bind: "any" Explicit form of the default — equivalent to omitting bind
IPv4 source + IPv6 bind only Source is silently skipped (no matching family)
IPv6 source + IPv4 bind only Source is silently skipped (no matching family)
Domain resolves to both v4 and v6 Only resolved IPs with a matching-family bind address get rules

URL address sources

Addresses can also be specified as objects to fetch IP lists from remote URLs. The remote file should contain one IP address or CIDR range per line. Comments (#) and blank lines are ignored.

Field Type Required Description
url string Yes The URL to fetch the IP list from (http or https)
ttl string No How long to cache the response before re-fetching. Supports m (minutes), h (hours), d (days), or bare seconds. Defaults to 1h.

Example URL address entry:

{
  "url": "https://example.com/trusted-ips.txt",
  "ttl": "24h"
}

String and object addresses can be mixed freely in the same addresses array:

"addresses": [
  "203.0.113.5",
  "home.example.com",
  {
    "url": "https://example.com/office-ips.txt",
    "ttl": "12h"
  }
]

Expected remote file format:

# Trusted office IPs - updated weekly
203.0.113.10
203.0.113.11
198.51.100.0/24
2001:db8:abcd::/48

URL sources work with bind — each IP fetched from the URL is matched against bind addresses by family, just like any other source address.

Example config

{
  "rules": [
    {
      "name": "Allow SSH from Home",
      "addresses": ["203.0.113.5", "home.example.com"],
      "ports": [22],
      "protocols": ["tcp"],
      "action": "allow"
    },
    {
      "name": "Allow Web on public IPs",
      "addresses": ["198.51.100.0/24", "2001:db8:1::/48"],
      "ports": [80, 443],
      "protocols": ["tcp"],
      "action": "allow",
      "bind": ["203.0.113.1", "2001:db8::1"]
    },
    {
      "name": "Allow DNS from trusted resolver",
      "addresses": ["9.9.9.9"],
      "ports": [53],
      "protocols": ["tcp", "udp"],
      "action": "allow"
    },
    {
      "name": "Block known bad actor",
      "addresses": ["192.0.2.66"],
      "ports": [22, 80, 443],
      "protocols": ["tcp"],
      "action": "deny"
    },
    {
      "name": "Allow VPN from dynamic host",
      "addresses": ["vpn.example.com"],
      "ports": [1194],
      "protocols": ["udp"],
      "action": "allow"
    },
    {
      "name": "Allow monitoring on management interface",
      "addresses": [
        "10.0.0.1",
        {
          "url": "https://example.com/monitoring-ips.txt",
          "ttl": "12h"
        }
      ],
      "ports": [8443],
      "protocols": ["tcp"],
      "action": "allow",
      "bind": "172.16.0.1"
    },
    {
      "name": "Allow trusted network list",
      "addresses": [
        {
          "url": "https://example.com/trusted-ips.txt",
          "ttl": "24h"
        }
      ],
      "ports": [443],
      "protocols": ["tcp"],
      "action": "allow"
    }
  ]
}

See rules.example.json for a ready-to-use starting point.

Usage

Run the script as root:

sudo ufw-myrules

The script reads the config, resolves any domain names, computes the difference between desired and existing rules, then adds missing rules and removes stale ones.

Summary output

After each run a summary is printed showing the total count of deleted, created, and ignored rules. Any rules that were actually added or removed are listed individually beneath their respective count line, making it easy to see exactly what changed and why:

Total rules deleted: 2
    allow  from 1.2.3.4                               to any                  port 22              proto tcp
    deny   from 5.6.7.8                               to any                  port 80,443          proto tcp
Total rules created: 1
    allow  from 9.10.11.12                            to 10.0.0.1             port 443             proto tcp
Total rules ignored: 0
Done.

Each line shows the action (allow/deny), the source address, the destination/bind address, the port(s), and the protocol. If no rules were added or removed in a category, no detail lines appear beneath that count.

When one or more rules are ignored, Total rules ignored is followed by a per-category breakdown and a detail line for each ignored rule. There are three categories:

  • Skipped (no source IP) — the source address could not be resolved (DNS failure, URL fetch failure, or a malformed UFW status line). No UFW call was attempted.
  • Failed (ufw error) — UFW was called but returned an error message (e.g. a bad address or conflicting rule).
  • Failed (unexpected output) — UFW returned output that was neither a recognised success nor a recognised error string. The raw UFW output is shown so you can diagnose the cause.
Total rules deleted: 0
Total rules created: 0
Total rules ignored: 3
  Skipped (no source IP):      1
  Failed (ufw error):          1
  Failed (unexpected output):  1
    Skipped add (no source IP): allow to any port 22 proto tcp
    Error add: allow from 10.0.0.1 to any port 80 proto tcp
      ufw: ERROR: Bad source address
    Unexpected output add: allow from 203.0.113.5 to any port 443 proto tcp
      ufw: Skipping adding existing rule
Done.

In cron mode, the entire summary (including rule details) is only printed when at least one rule was added, deleted, or ignored — keeping cron logs clean while still surfacing meaningful change information.

Purge/delete rules

Remove all existing ufw-myrules tagged rules from UFW before syncing. Only rules commented as ufw-myrules are affected.

sudo ufw-myrules --purge

Delete all managed rules without creating any new ones:

sudo ufw-myrules --purge --no-new

Skip IPv6

Use --skip-ipv6 to only apply IPv4 rules and skip IPv6 rules entirely. Domain names will not be resolved for AAAA records.

sudo ufw-myrules --skip-ipv6

Skip IPv4

Use --skip-ipv4 to only apply IPv6 rules and skip IPv4 rules entirely. Domain names will not be resolved for A records.

sudo ufw-myrules --skip-ipv4

Dry run

Use --dry-run (-d) to preview what the script would do without making any changes to UFW. The full summary is printed — including every rule that would be added or removed — but no ufw add or delete commands are executed.

sudo ufw-myrules --dry-run

This is useful for validating a new config file before applying it, or for auditing what a scheduled cron run would change.

No new rules

Use --no-new (-n) to skip creating any new rules. This is useful when combined with the smart sync to only remove stale rules without adding replacements.

sudo ufw-myrules --no-new

Cron mode

Use --cron-mode (-c) to suppress all progress output. Only errors and a summary of changes (when rules are created, deleted, or ignored) will be printed. Ideal for cron jobs.

sudo ufw-myrules --cron-mode

Custom config file

Use --config (-f) to specify an alternative config file path. The default is /etc/ufw-myrules/rules.json.

sudo ufw-myrules --config /path/to/my-rules.json

Flush cache

Use --flush-cache to clear all cached URL responses. On the next run, all URL address sources will be re-fetched regardless of their TTL.

sudo ufw-myrules --flush-cache

This is useful when you know a remote IP list has been updated and you want to apply changes immediately without waiting for the TTL to expire.

Combining options

Options can be combined together:

sudo ufw-myrules --cron-mode --skip-ipv6
sudo ufw-myrules --purge --no-new --cron-mode
sudo ufw-myrules --purge --skip-ipv6
sudo ufw-myrules --config /etc/ufw-myrules/webservers.json --cron-mode
sudo ufw-myrules --no-new --skip-ipv4
sudo ufw-myrules --flush-cache --cron-mode
sudo ufw-myrules --purge --flush-cache
sudo ufw-myrules --dry-run
sudo ufw-myrules --dry-run --purge
sudo ufw-myrules --dry-run --config /etc/ufw-myrules/webservers.json

Cron Setup

To keep your firewall rules in sync automatically (e.g., when dynamic IPs change), add a cron job:

sudo crontab -e

Add a line to run every 15 minutes:

*/15 * * * * /usr/local/bin/ufw-myrules --cron-mode

In cron mode, the script produces no output unless rules are actually changed or an error occurs — keeping your cron logs clean.

ufw-myrules also uses a lock file at /run/lock/ufw-myrules.lock to prevent concurrent runs. If another instance is already running (for example from cron or a systemd timer), the new run exits immediately with an error.

How It Works

  1. Build desired rules — The script reads the JSON config, resolves any domain names to IP addresses via dig, fetches any URL address sources (with TTL-based caching), validates bind address family matches, and builds a complete list of desired UFW rules.
    • Protocols are validated strictly (tcp and udp only).
    • bind arrays must contain string entries only.
  2. Parse existing rules — It queries ufw status and extracts all rules tagged with the ufw-myrules comment, including any destination (bind) addresses.
  3. Compute diff — The desired and existing rule sets are compared. Rules present in existing but not in desired are marked as stale. Rules present in desired but not in existing are marked as missing.
  4. Remove stale rules — Stale rules (e.g., from a previous IP that has since changed) are deleted from UFW.
  5. Add missing rules — New rules that don't yet exist in UFW are created.
  6. Print summary — A count of deleted, created, and ignored rules is printed. Each rule that was actually added or removed is listed beneath its count line showing the action, source, destination, port, and protocol. When rules are ignored, a per-category breakdown (no source IP / ufw error / unexpected output) and a detail line per ignored rule are shown beneath the ignored count. In cron mode the summary is suppressed entirely when no changes or errors occurred.

This approach ensures that each run is idempotent and efficient — no unnecessary rule churn, and dynamic DNS changes are picked up automatically.

Tips / Use Cases

Dynamic home IP

If your home IP changes frequently, set up a DDNS service (e.g., DuckDNS, No-IP, Cloudflare DDNS) and use the domain name in your config:

{
  "name": "Allow SSH from Home",
  "addresses": ["myhome.duckdns.org"],
  "ports": [22],
  "protocols": ["tcp"],
  "action": "allow"
}

With a cron job running every 15 minutes, your firewall will always reflect your current home IP.

Restrict SSH to a management IP

If your server has a dedicated management interface, bind the SSH rule to that IP so SSH is never exposed on the public interface:

{
  "name": "SSH on management only",
  "addresses": ["10.0.0.0/8", "admin.example.com"],
  "ports": [22],
  "protocols": ["tcp"],
  "action": "allow",
  "bind": "172.16.0.1"
}

Dual-stack web server with specific public IPs

Bind web traffic to your public-facing IPv4 and IPv6 addresses while keeping other local IPs unexposed:

{
  "name": "Public web traffic",
  "addresses": ["0.0.0.0/0", "::/0"],
  "ports": [80, 443],
  "protocols": ["tcp"],
  "action": "allow",
  "bind": ["203.0.113.1", "2001:db8::1"]
}

IPv4 0.0.0.0/0 is paired with the IPv4 bind 203.0.113.1, and IPv6 ::/0 with 2001:db8::1.

Block known bad actors

Use deny rules to block specific IPs from reaching sensitive ports:

{
  "name": "Block bad actor",
  "addresses": ["192.0.2.66", "198.51.100.99"],
  "ports": [22, 80, 443],
  "protocols": ["tcp"],
  "action": "deny"
}

Remote IP lists

Use URL address sources to maintain shared IP lists across multiple servers, or to integrate with external threat intelligence feeds:

{
  "name": "Allow from corporate VPN exits",
  "addresses": [
    {
      "url": "https://internal.example.com/vpn-exit-ips.txt",
      "ttl": "6h"
    }
  ],
  "ports": [22, 443],
  "protocols": ["tcp"],
  "action": "allow"
}

The TTL controls how often the list is re-fetched. Set shorter TTLs (e.g. 1h) for frequently changing lists, or longer TTLs (e.g. 7d) for stable lists. If a fetch fails, the previously cached version is used automatically.

Multiple servers, same config

Use the same rules.json across multiple servers for consistent firewall policies. Just install the script and copy the config to each machine. The bind field can be set per-server to match each machine's local IPs, or omitted for rules that should apply everywhere.

Inspired by / See also

  • ufw-cloudflare — Automatically whitelist Cloudflare IPs with UFW. The smart sync and cron mode approach in ufw-myrules is inspired by this project.

License

MIT