A collection of custom scripts for UniFi OS gateways. Each subdirectory is a self-contained tool with its own config and documentation.
Applies iptables, ip6tables, ebtables, ip rule, ip route, sysctl, echo, and route-sync directives idempotently from a config file. A companion daemon monitors for kernel events that indicate rules were flushed (e.g., UBIOS provisioning cycles) and re-applies them automatically.
The problem: UniFi OS periodically re-provisions the network stack, which flushes custom iptables rules, policy routes, routing table entries, and kernel tunables. These scripts solve that by applying rules idempotently and monitoring for flushes to restore them.
rules/
inject-rules.sh # Rule injection engine
rules-monitor.sh # Background daemon that watches for flushes
conf/
example.conf # Template config with directive reference
<hostname>.conf # Per-device config (git-ignored)
The scripts resolve config files in this order (first match wins):
- Explicit path passed as
$1 conf/$(hostname).confinject-rules.conf(flat file, legacy)custom-routes.conf(flat file, legacy)
Per-host configs under conf/ are git-ignored. Copy conf/example.conf to conf/<hostname>.conf and edit it for each device.
| Directive | Idempotency mechanism |
|---|---|
iptables <args> |
Swaps -I/-A to -C to check before inserting |
ip6tables <args> |
Same as iptables (swaps to -C to check before inserting) |
ebtables <args> |
Deletes with -D then re-adds (ebtables lacks -C) |
ip rule add <args> |
Counts matching rules; skips if 1 exists, deduplicates if >1 |
ip rule del <args> |
Deletes all copies if present, no-op if absent |
ip route add <args> |
Adds; treats "File exists" as success |
ip route del <args> |
Deletes; treats "No such process" as already absent |
ip -6 rule add|del <args> |
Same as IPv4 variants |
ip -6 route add|del <args> |
Same as IPv4 variants |
sysctl -w <key>=<value> |
Reads /proc/sys; writes only if value differs. -w is optional. |
route-sync <iface> <table> [subnet] |
Mirrors routes from an interface into a routing table using ip route replace. Optional CIDR filter. |
echo <value> > <path> |
Reads sysfs/procfs path; writes only if value differs. For VLAN sub-interfaces where sysctl fails. |
Lines starting with # and blank lines are ignored. See conf/example.conf for the full directive reference and more examples.
# Policy routing — send a prefix to a custom table
ip rule add to 192.0.2.0/24 lookup 100 priority 100
ip route add 192.0.2.0/24 dev br100 table 100
# Sync host routes from a tunnel into that table
route-sync vti64 100 192.0.2.0/24
# NAT bypass for tunnel traffic
iptables -t nat -I POSTROUTING -d 192.0.2.0/24 -j RETURN
# IPv6 NAT bypass (ip6tables)
ip6tables -t nat -I POSTROUTING -d 2001:db8::/32 -j RETURN
# Block discovery broadcasts on a peering bridge
iptables -I OUTPUT -o br3998 -p udp --dport 10001 -j DROP
# Block STP BPDUs at layer 2
ebtables -A OUTPUT -o br3998 -d 01:80:c2:00:00:00/ff:ff:ff:ff:ff:f0 -j DROP
# Extend ARP/NDP reachable time on a peering bridge (4 hours)
sysctl -w net.ipv4.neigh.br3998.base_reachable_time_ms=14400000
sysctl -w net.ipv6.neigh.br3998.base_reachable_time_ms=14400000
# Direct sysfs write (bridge ageing time — no sysctl path)
echo 1440000 > /sys/class/net/br3998/bridge/ageing_timeInjecting rules:
# Uses conf/$(hostname).conf automatically
./rules/inject-rules.sh
# Or specify a config explicitly
./rules/inject-rules.sh /path/to/custom.confThe script exits non-zero if any directive fails. A file lock (/var/run/inject-rules.lock) prevents concurrent runs from racing.
Starting the monitor:
./rules/rules-monitor.shThe monitor backgrounds itself and writes its PID to /var/run/rules-monitor.pid. It kills any existing instance on startup. Logs go to /var/log/rules-monitor.log.
What it watches depends on what's in the config:
- Route/route-sync directives:
ip monitor route, filtered to referenced interfaces and tables. - ip rule directives:
ip monitor rulefor policy rule flushes. - iptables/ip6tables/ebtables directives: polls every 10 seconds, checking whether a sample rule still exists.
- sysctl directives: polls every 10 seconds, checking whether the first sysctl value still matches.
- echo directives: polls every 10 seconds, checking whether a sample sysfs/procfs value still matches.
When a relevant event fires, the monitor debounces for 5 seconds of silence (to let UBIOS finish provisioning), then runs inject-rules.sh. A 10-second cooldown after each inject suppresses self-triggered events.
Both scripts are typically called from a UniFi on-boot setup script:
/path/to/rules/inject-rules.sh
/path/to/rules/rules-monitor.shApplies CLI commands to UniFi switches remotely via SSH + telnet. Options not exposed in the controller UI (e.g., disabling LLDP transmit on IXP ports) are applied idempotently and re-applied periodically since they do not persist across switch reboots.
switch-config/
apply-switch-config.sh # CLI injection engine (runs from management host)
switch-config-monitor.sh # Background daemon for periodic re-application
conf/
example.conf # Template config with directive reference
<hostname>.conf # Per-runner-host config (git-ignored)
The scripts resolve config files using the runner's hostname (the machine that SSHes into switches), not the switch hostname. Resolution order (first match wins):
- Explicit path passed as
$1 conf/$(hostname).confswitch-config.conf(flat file)
Per-host configs under conf/ are git-ignored. Copy conf/example.conf to conf/<hostname>.conf and edit it for each runner host.
# Target switch via SSH (key auth must be pre-configured)
switch admin@switch1.example.net
# Disable LLDP on IXP ports (range expands to 0/25 0/26 0/27 0/28)
interface 0/25-28
no lldp transmit
# Optional: verify state before/after applying (checks all ports above)
verify-all show lldp interface :: Transmit :: Disabled| Directive | Description |
|---|---|
switch <user@host> |
Begins a switch block. All indented lines below belong to it. |
interface <port-list> |
Enter interface context. Ports can be single (0/13), space-separated (0/1 0/2), ranges (0/13-16), or mixed (0/1 0/3-5). |
| (indented command) | CLI command run inside the interface context (e.g., no lldp transmit). |
configure <command> |
Global configure-mode command (not interface-scoped). |
verify <port> <show-cmd> :: <field> :: <expected> |
Pre/post check for a single port. If current value matches <expected>, the port is skipped. |
verify-all <show-cmd-template> :: <field> :: <expected> |
Expands to a verify for every port in the preceding interface block. The port is appended to the show command template. |
Lines starting with # and blank lines are ignored. See conf/example.conf for the full directive reference and more examples.
# Disable LLDP transmit and BPDU filter on IXP-facing ports
switch admin@switch1.example.net
interface 0/25-28
no lldp transmit
spanning-tree bpdufilter
verify-all show lldp interface :: Transmit :: Disabled
verify-all show spanning-tree interface :: BPDU Filter Mode :: Enabled
# Multiple commands per interface
switch admin@switch2.example.net
interface 0/1 0/2
no lldp transmit
no cdp enableApplying switch config:
# Uses conf/$(hostname).conf automatically
./switch-config/apply-switch-config.sh
# Specify a config explicitly
./switch-config/apply-switch-config.sh /path/to/custom.conf
# Dry run — show what would be applied without SSHing
./switch-config/apply-switch-config.sh -nThe script exits non-zero if any directive fails. A file lock (/tmp/apply-switch-config.lock) prevents concurrent runs.
Starting the monitor daemon:
# Default interval: 30 seconds
./switch-config/switch-config-monitor.sh
# Custom interval: 60 seconds
./switch-config/switch-config-monitor.sh 60
# Or via environment variable
INTERVAL=60 ./switch-config/switch-config-monitor.shThe monitor backgrounds itself and writes its PID to /var/run/switch-config-monitor.pid. It kills any existing instance on startup. Logs go to /var/log/switch-config-monitor.log.
Both scripts are typically called from a UniFi on-boot setup script:
/path/to/switch-config/apply-switch-config.sh
/path/to/switch-config/switch-config-monitor.sh- Bash 4+ (for associative arrays; UniFi gateways have bash 5.x)
- SSH key authentication pre-configured to target switches
- The runner machine must be able to reach switches via SSH
A cron-invoked supervisor that watches every per-subsystem monitor in this repo and restarts any that have died or stalled.
The problem it solves: the monitors in rules/,
switch-config/, and neighbor-poll-wrapper/ all background
themselves once at boot from /data/on_boot.d and then run forever
(in theory). In practice, log evidence on at least one production
gateway shows individual monitors going silent for hours at a time —
PID still valid, no error, no restart sequence — consistent with the
process being SIGSTOP'd by some UBIOS subsystem during config sync
and later SIGCONT'd. While stopped, the monitor doesn't run its
apply cycle, the wrapper bind-mount can drift off, and the IX fabric
sees a storm.
The fix: an external supervisor that lives in cron's process tree
(not ours) and detects "stuck" via heartbeat-file mtime, not just
"dead" via PID liveness. SIGTERM cannot be delivered to a stopped
process; the watchdog uses SIGKILL and re-launches via the
monitor's own start script.
lib/
monitor-watchdog.sh # the supervisor
install-monitor-watchdog-cron.sh # idempotent cron installer
monitors.conf # registry of supervised monitors
monitors.conf is whitespace-separated, one row per monitor:
<name> <pidfile> <heartbeat-file> <max_silence_sec> <launcher-path>
Each in-repo monitor writes its PID to /var/run/<name>.pid and
touches /var/run/<name>.heartbeat on every loop iteration (or on a
dedicated 10s ticker for event-driven monitors). The watchdog checks
both — PID alive AND heartbeat younger than max_silence_sec — and
restarts on either failure.
The cron entry is installed via the install-monitor-watchdog-cron.sh
script (idempotent — uses a sentinel comment in
/etc/cron.d/unifi-scripts-monitor-watchdog). Ansible runs this
once per playbook apply.
./lib/install-monitor-watchdog-cron.sh # install/update
./lib/install-monitor-watchdog-cron.sh --remove # uninstallmax_silence_sec (typically 60-90s) + 60s cron tick = ~2 minutes
worst case. Versus the unbounded gap with no supervisor (we observed
2.5 hours in production), this is a 75× improvement.
A future migration to systemd units with WatchdogSec= would bring
this under 10 seconds, but cron is the lowest-friction path that
works on every UBIOS version we've tested.
GPLv3. See LICENSE.