- Shell 100%
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. |
||
|---|---|---|
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| LICENSE | ||
| README.md | ||
| rules.example.json | ||
| ufw-myrules | ||
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 to203.0.113.1(both IPv4 ✅)2001:db8:1::/48→ bound to2001: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
- 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 (
tcpandudponly). bindarrays must contain string entries only.
- Protocols are validated strictly (
- Parse existing rules — It queries
ufw statusand extracts all rules tagged with theufw-myrulescomment, including any destination (bind) addresses. - 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.
- Remove stale rules — Stale rules (e.g., from a previous IP that has since changed) are deleted from UFW.
- Add missing rules — New rules that don't yet exist in UFW are created.
- 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-myrulesis inspired by this project.
License
MIT