GeoIP SIP Firewall Runbook¶
Operational reference for the India-only GeoIP filter that protects the SIP ports on the cloud Asterisk servers. This is the perimeter layer — it drops non-India source IPs at the kernel before Asterisk or fail2ban ever see the packet. fail2ban is the second layer, for abuse that originates from inside India (see Fail2Ban Runbook).
Both environments run an identical stack as of 2026-05-31:
| Env | Host | SIP ports | WireGuard allow-ifaces |
|---|---|---|---|
| Prod | 147.93.168.216 | 5060, 5080 (UDP+TCP) | wg1 + src 10.10.10.0/24 |
| Staging | 94.136.188.221 | 5060, 5080 (UDP+TCP) | wg0 (prod/NUC tunnel) + wg1 (customer tunnels, 10.20.0.0/16) |
The only per-host difference is which WireGuard interface(s) carry trusted SIP — prod relays the Tata trunk over wg1; staging receives the prod/NUC tunnel on wg0 and customer tunnels on wg1. The allow-rules are tailored per host so tunnel traffic is never GeoIP-filtered; everything else flows through the same india_sip set + DROP.
If the india_sip ipset is empty, ALL SIP is dropped
The firewall is allow-list by membership: the DROP rule fires for any SIP packet whose source isn't in the india_sip set (or the WireGuard range). An empty or missing set means every customer phone is dropped → total SIP outage. After any reboot, ipset restore, or maintenance, verify the set has ~7,000+ entries (see Verify).
How it works¶
Three moving parts:
| Part | What it is | Where |
|---|---|---|
india_sip ipset | hash:net set of ~7,100 Indian CIDR blocks | kernel; saved to /etc/ipset.conf |
| iptables INPUT rules | allow WireGuard + allow india_sip + DROP the rest, on UDP and TCP ports 5060/5080 | kernel; saved to /etc/iptables/rules.v4 |
| Monthly refresh | update-india-geoip.sh re-downloads the CIDR list and swaps the set | cron 0 3 1 * * |
The iptables rule chain (INPUT, evaluated top-down)¶
These rules are inserted ahead of the ufw chains so they decide SIP traffic first:
1. ACCEPT udp in=wg1 dports 5060,5080 # WireGuard iface
2. ACCEPT udp src 10.10.10.0/24 dports 5060,5080 # GeoIP: Allow WireGuard SIP
3. ACCEPT udp match-set india_sip src dports 5060,5080 # GeoIP: Allow India SIP
4. DROP udp dports 5060,5080 # GeoIP: Drop non-India SIP
5. ACCEPT tcp src 10.10.10.0/24 dports 5060,5080 # (same four rules, TCP)
6. ACCEPT tcp match-set india_sip src dports 5060,5080
7. DROP tcp dports 5060,5080
The WireGuard allows (rules 1–2) come first so NUC-relayed Tata trunk traffic (10.10.10.0/24) is never subject to GeoIP — the NUC's public egress IP is irrelevant because the packets arrive over the tunnel.
CIDR data source¶
update-india-geoip.sh downloads the aggregated India zone from ipdeny.com:
~7,100 aggregated IN CIDR blocks. The script aborts if the download returns fewer than 5,000 lines (guards against a truncated/empty download wiping the set).
Boot persistence¶
Two units restore state on reboot, in order:
ipset-restore.service(oneshot,Before=netfilter-persistent.service) — runsipset restore -f /etc/ipset.confso the set exists before the iptables rules that reference it load. On stop itipset saves back.netfilter-persistent— loads/etc/iptables/rules.v4(the match-set rules).
Order matters: if iptables loaded a --match-set india_sip rule before the set existed, the restore would fail. ipset-restore.service running first prevents that.
Verify the filter is healthy¶
ssh root@147.93.168.216
# 1. Set exists and is populated (expect ~7000+)
ipset list india_sip -t # header only: 'Number of entries: 7103'
# 2. Rules are present and matching traffic
iptables -L INPUT -v -n --line-numbers | grep -A1 -i "india\|GeoIP"
# Look at the pkts counter on the ACCEPT match-set line (India traffic flowing)
# and the DROP line (attackers being blocked) — both should be non-zero.
# 3. Boot units are enabled
systemctl is-enabled ipset-restore.service netfilter-persistent
# 4. Saved file matches the live set
echo "live: $(ipset list india_sip | grep -c '^[0-9]')"
echo "saved: $(grep -c '^add india_sip' /etc/ipset.conf)"
Healthy example from 2026-05-31:
| Counter | Value | Meaning |
|---|---|---|
india_sip entries | 7,103 | CIDR blocks loaded |
| Rule 3 ACCEPT (UDP India) pkts | 642K | legit Indian SIP flowing |
| Rule 4 DROP (UDP non-India) pkts | 42,739 | foreign attackers dropped |
| Rule 7 DROP (TCP non-India) pkts | 1,899 | foreign TCP SIP probes dropped |
Common operations¶
Manually refresh the CIDR list now¶
ssh root@147.93.168.216
/usr/local/sbin/update-india-geoip.sh # writes to stdout
# verify it printed 'Updated india_sip: <N> entries' and N > 7000
ipset list india_sip -t
The script uses an atomic swap: builds india_sip_new, ipset swaps it over the live set (zero-downtime — no window where the set is empty), destroys the old, then ipset save > /etc/ipset.conf. There is no need to reload iptables; the rules reference the set by name, so the swapped-in contents apply instantly.
Whitelist a specific non-India IP (e.g. an overseas softphone, a vendor)¶
The GeoIP set is meant for India CIDRs only. To allow a one-off foreign IP, add a dedicated ACCEPT rule above the DROP rule rather than polluting the set:
ssh root@147.93.168.216
cp /etc/iptables/rules.v4 /etc/iptables/rules.v4.bak-$(date +%F)
# Insert before the DROP (find the line number from iptables -L INPUT --line-numbers)
iptables -I INPUT <N> -s <IP>/32 -p udp -m multiport --dports 5060,5080 \
-m comment --comment "Allow overseas: <who>" -j ACCEPT
iptables -I INPUT <N> -s <IP>/32 -p tcp -m multiport --dports 5060,5080 \
-m comment --comment "Allow overseas: <who>" -j ACCEPT
netfilter-persistent save # persist to rules.v4
Document who/why in the comment — undocumented allow rules are how perimeters rot.
Confirm whether a customer is being GeoIP-dropped¶
# Are their packets even reaching us, and are they being dropped at the SIP rules?
tcpdump -ni eth0 "host <CUSTOMER_IP> and udp port 5080" # packets arriving?
ipset test india_sip <CUSTOMER_IP> # in the India set?
# 'NOT in set' + packets arriving + phone can't register → GeoIP is dropping them
If a legitimate Indian customer's IP isn't in the set (ISP allocated a block newer than the last successful refresh), either run the refresh, or add their /24 as an explicit ACCEPT rule as above.
Restore after a wipe / failed boot (SIP totally down)¶
If ipset list india_sip shows the set missing or empty and SIP is down:
ssh root@147.93.168.216
# Rebuild from the saved file (fast, no download):
ipset restore -f /etc/ipset.conf
ipset list india_sip -t # confirm entries restored
# If the saved file is also bad, re-download live:
/usr/local/sbin/update-india-geoip.sh
If iptables rules themselves are gone (set present but no DROP rules):
netfilter-persistent reload # reloads /etc/iptables/rules.v4
iptables -L INPUT -v -n | grep -i geoip # confirm the GeoIP rules are back (6 on prod, 8 on staging)
Resolved: monthly auto-refresh silent failure (set -e + ((count++)))¶
Fixed on prod and staging 2026-05-31. For most of its life the cron refresh never completed — the set was static, seeded by hand on 2026-04-04.
What was wrong¶
update-india-geoip.sh ran under set -euo pipefail and counted adds with:
On the first successful add, ((count++)) is post-increment: it evaluates the old value 0, and an arithmetic expansion that evaluates to 0 returns exit status 1. Under set -e that non-zero status killed the script before it ever reached ipset swap / ipset save. (Confirmed by reproducing set -e; count=0; true && ((count++)) → exits 1.)
Evidence it had been silently failing: /var/log/geoip-update.log showed only Downloading India IP ranges... for the May 1 run and never the closing Updated india_sip: <N>; /etc/ipset.conf mtime was stuck at 2026-04-04; the live set (7,103) exactly matched that stale saved file. It kept working only because the manual seed was close enough and Indian CIDRs drift slowly.
The fix (applied)¶
count=$((count+1)) is an assignment (always exit 0); the trailing || true also swallows a duplicate-add failure. After applying, a manual run printed Updated india_sip: 7142 entries (up from 7,103 — 39 CIDRs had been missing since April) and /etc/ipset.conf mtime advanced to 2026-05-31. The prod script was backed up to update-india-geoip.sh.bak-2026-05-31 first.
Still open: no alerting on cron failure¶
The cron still redirects output to /var/log/geoip-update.log, which nobody reads. A future silent failure (download outage, ipdeny.com schema change, ipset error) would again rot undetected. Recommended follow-up: have the script ping a healthcheck (or raise a Netdata alarm) when it does not print the Updated india_sip: <N> success line. Until then, the manual check is:
# Should show a recent date and a healthy count on both hosts:
tail -1 /var/log/geoip-update.log; stat -c '%y' /etc/ipset.conf; ipset list india_sip -t | grep entries
History¶
| Date | Change |
|---|---|
| 2026-04-04 | india_sip set seeded manually on prod (7,103 CIDRs); iptables GeoIP rules + ipset-restore.service established. |
| 2026-05-31 | Fixed the set -e + ((count++)) bug so the monthly cron actually refreshes (prod set 7,103 → 7,142). Stood up the full GeoIP stack on staging (94.136.188.221) to mirror prod: ipset + iptables rules (allow wg0/wg1 + india_sip, DROP rest, UDP+TCP), ipset-restore.service, netfilter-persistent, monthly cron. |
Related¶
- Fail2Ban Runbook — the second defense layer (abuse from inside India)
- Network & Security — overall perimeter / firewall map
- UAT Test Cases — TC-10.2 — SIP GeoIP blocking acceptance check
- Troubleshooting — Error 55 — customer IP-change interplay with the SIP perimeter