Networks and cybersecurity 17 min read

Configuring Reliable GeoIP Filtering on MikroTik RouterOS

Generate a CIDR list by geographic region, validate it before publishing, import it without wiping the last working version, and apply it to the correct firewall chains.

RouterOS does not contain a built-in GeoIP database allowing you to select a country in a firewall rule. The mechanism relies on external CIDR prefix lists : the router imports them into an address-list, then an input or forward rule uses this list as a criterion.

The tricky part is not the final rule. It consists of obtaining verifiable data, never replacing a functional list with an empty file, and placing the filter in the chain that actually corresponds to the service. This guide builds an IPv4 list for Belgium, while allowing you to easily add France, the Netherlands, or Luxembourg.

How GeoIP filtering works

A geographic database associates address blocks with a territory code. For a firewall, these blocks simply become networks like 203.0.113.0/24. RouterOS performs no geographic lookup during packet processing : it only checks if the source address belongs to one of the entries in the list.

ComponentRoleRisk to control
RIR dataProvide registered prefixes by ISO codeThe registration country may differ from the actual location
GeneratorValidate and convert CIDRs to RouterOS commandsAn empty download must never overwrite the active list
Address-listStore networks in RouterOSThousands of entries consume memory and import time
FirewallAllow or deny based on the listA wrong chain or order makes the filter ineffective
SchedulerUpdate the dataPermissions and errors must be visible

The lists used below come from the allocation data of regional Internet registries, grouped by ISO code and published daily. They are smaller than a highly detailed geolocation database, which is better suited for a router. However, they describe the registration of Internet resources, not a GPS position.

Recommended architecture

The router should not directly depend on a web page transformed on the fly. A Linux server or NAS downloads the data, validates each CIDR, builds a .rsc file, and then publishes it over HTTPS. MikroTik downloads this file, performs a dry-run import, and applies it only if its syntax is valid.

Secure GeoIP update pipeline with HTTPS download, CIDR validation, temporary list, and controlled switchover
The replacement occurs after download, CIDR validation, loading into a temporary list, and a volume check.

Preparing the list server

The example assumes a server with bash, curl, and Python 3. Python only uses the standard ipaddress module here : no additional library is required.

Create a directory published by your web server, for example /srv/www/geoip, then protect it with HTTPS. The following script downloads the Belgian list, rejects a file that is too short, validates each IPv4 network, aggregates the prefixes that can be aggregated, and generates a RouterOS file atomically.

#!/usr/bin/env bash
set -Eeuo pipefail

COUNTRIES=(be)
BASE_URL="https://raw.githubusercontent.com/ipverse/rir-ip/master/country"
PUBLISH_DIR="/srv/www/geoip"
FINAL_FILE="$PUBLISH_DIR/GeoIP-BE.rsc"
WORK_DIR="$(mktemp -d)"
PUBLISH_TMP="$(mktemp "$PUBLISH_DIR/.GeoIP-BE.XXXXXX")"

cleanup() {
    rm -rf "$WORK_DIR"
    if [[ -n "${PUBLISH_TMP:-}" && -f "$PUBLISH_TMP" ]]; then
        rm -f "$PUBLISH_TMP"
    fi
}
trap cleanup EXIT

RAW_FILE="$WORK_DIR/all-prefixes.txt"
VALID_FILE="$WORK_DIR/validated-prefixes.txt"
: > "$RAW_FILE"

for country in "${COUNTRIES[@]}"; do
    curl --fail --location --silent --show-error --retry 3 \
        "$BASE_URL/$country/ipv4-aggregated.txt" \
        --output "$WORK_DIR/$country.txt"
    grep -Ev '^(#|$)' "$WORK_DIR/$country.txt" >> "$RAW_FILE"
done

python3 - "$RAW_FILE" "$VALID_FILE" <<'PY'
import ipaddress
import sys

source, destination = sys.argv[1:3]
networks = []
with open(source, encoding="utf-8") as handle:
    for raw_line in handle:
        line = raw_line.strip()
        if not line:
            continue
        network = ipaddress.ip_network(line, strict=True)
        if network.version != 4:
            raise SystemExit(f"Non-IPv4 prefix: {line}")
        networks.append(network)

collapsed = list(ipaddress.collapse_addresses(networks))
if len(collapsed) < 100:
    raise SystemExit("Abnormally short list: publication cancelled")

with open(destination, "w", encoding="ascii") as handle:
    for network in collapsed:
        handle.write(f"{network}\n")
PY

python3 - "$VALID_FILE" "$PUBLISH_TMP" <<'PY'
import sys

source, destination = sys.argv[1:3]
with open(source, encoding="ascii") as handle:
    prefixes = [line.strip() for line in handle if line.strip()]

with open(destination, "w", encoding="ascii") as output:
    output.write(':do { /ip firewall address-list remove '
                 '[find where list="GeoIP-BE-next"] } on-error={}\n')
    for prefix in prefixes:
        output.write('/ip firewall address-list add '
                     f'list="GeoIP-BE-next" address={prefix} '
                     'comment="GeoIP generated"\n')
    output.write(':local imported [/ip firewall address-list print '
                 'count-only where list="GeoIP-BE-next"]\n')
    output.write(':if ($imported < 100) do={ '
                 ':error "Liste GeoIP trop courte" }\n')
    output.write('/ip firewall address-list remove '
                 '[find where list="GeoIP-BE"]\n')
    output.write(':foreach id in=[/ip firewall address-list find '
                 'where list="GeoIP-BE-next"] do={ '
                 '/ip firewall address-list set numbers=$id '
                 'list="GeoIP-BE" }\n')
    output.write(':log info ("GeoIP: active list, " . '
                 '$imported . " prefixes")\n')
PY

chmod 0644 "$PUBLISH_TMP"
mv -f "$PUBLISH_TMP" "$FINAL_FILE"
PUBLISH_TMP=""
echo "Published: $FINAL_FILE"

To build a broader zone, replace COUNTRIES=(be) with COUNTRIES=(be fr nl lu) and adapt the names GeoIP-BE and GeoIP-BE-next. Start with a single zone : an extended list increases import time and memory usage.

Why this generator is safer

  • curl --fail turns an HTTP error into a failure instead of saving an error page as a list ;
  • ipaddress rejects invalid CIDR syntax and verifies that it is indeed IPv4 ;
  • a minimum threshold prevents the publication of an empty or manifestly incomplete file ;
  • the public file is only replaced after complete generation ;
  • the RouterOS file first loads GeoIP-BE-next, checks its volume, then switches to GeoIP-BE.

Run the generator manually, check the published file, then schedule it daily on the server. Keep the logs and do not immediately delete the last known valid version.

Testing the import on MikroTik

Before any automation, export the firewall and enable Safe Mode. Replace the documentation domain below with your own HTTPS URL.

/ip firewall export file=avant-geoip
/system backup save name=avant-geoip
/tool fetch \
    url="https://updates.example.net/geoip/GeoIP-BE.rsc" \
    output=file dst-path="GeoIP-BE.rsc"

/import file-name="GeoIP-BE.rsc" verbose=yes dry-run=yes
/import file-name="GeoIP-BE.rsc" verbose=yes

The dry-run mode of the import is available on recent versions of RouterOS 7. It parses the file without modifying the configuration. The environment for this guide targets RouterOS 7.16 or later.

/ip firewall address-list print count-only where list="GeoIP-BE"
/ip firewall address-list print where list="GeoIP-BE"
/log print where message~"GeoIP"

A Belgian import normally produces well over a hundred prefixes. The threshold serves only as a safeguard against a manifest failure ; it proves neither the completeness nor the geographic quality of the dataset.

Automating the download in RouterOS

Create a script named update-geoip with the policies ftp, read, write, policy, and test. The ftp policy is required for file operations, even if the download itself uses HTTPS.

:local geoUrl "https://updates.example.net/geoip/GeoIP-BE.rsc"
:local geoFile "GeoIP-BE.rsc"

:do { /file remove [find where name=$geoFile] } on-error={}

:do {
    /tool fetch url=$geoUrl output=file dst-path=$geoFile
    /import file-name=$geoFile verbose=yes dry-run=yes
    /import file-name=$geoFile verbose=yes
    :log info "GeoIP: update complete"
} on-error={
    :log error "GeoIP: failed, previous list kept"
}

The imported file works on the temporary list. A download failure, a syntax error, or an abnormal volume interrupts the procedure before the deletion of GeoIP-BE.

After a successful manual test, schedule the execution once a day, at a time when the router is under low load.

/system scheduler add \
    name=update-geoip-daily \
    start-time=05:40:00 interval=1d \
    on-event="/system script run update-geoip" \
    policy=ftp,read,write,policy,test \
    comment="GeoIP list refresh"

Applying the list to the right place

The input chain handles traffic destined for the router itself. The forward chain handles traffic that traverses the MikroTik to an internal server. A GeoIP rule placed only in input therefore does not restrict a server published via dst-nat.

Case 1 : restricting access to a VPN hosted on the router

The example allows a WireGuard endpoint on UDP 51820 from the GeoIP list. It assumes that a rule dropping WAN traffic to the router exists further down. Do not publish WinBox or WebFig directly to the Internet.

/ip firewall address-list
add list=GeoIP-TRUSTED address=198.51.100.10 \
    comment="Fallback address - RFC 5737 example"

/ip firewall filter
add chain=input action=accept in-interface-list=WAN \
    protocol=udp dst-port=51820 \
    src-address-list=GeoIP-TRUSTED \
    comment="VPN: fallback access"

add chain=input action=accept in-interface-list=WAN \
    protocol=udp dst-port=51820 \
    src-address-list=GeoIP-BE \
    comment="VPN: allow GeoIP zone"

Replace the documentation address with a real controlled fallback address. Place the rules after the acceptance of established,related and the rejection of invalid packets, but before the general WAN drop.

Case 2 : restricting a server published via dst-nat

The following rule drops new TCP connections to public port 443 when they do not belong to GeoIP-BE. A fallback rule placed before it allows an explicit exception.

/ip firewall filter
add chain=forward action=accept in-interface-list=WAN \
    connection-state=new connection-nat-state=dstnat \
    protocol=tcp dst-port=443 \
    src-address-list=GeoIP-TRUSTED \
    comment="Server: fallback access"

add chain=forward action=drop in-interface-list=WAN \
    connection-state=new connection-nat-state=dstnat \
    protocol=tcp dst-port=443 \
    src-address-list=!GeoIP-BE \
    comment="Server: deny outside GeoIP zone"

Insert these rules before any general acceptance of dstnat connections. The port must be the public port seen by the filter. If external port 8443 is translated to 443, use 8443 in this criterion.

Testing without locking yourself out

  1. Work from the LAN and enable Safe Mode.
  2. First, add your fallback access in GeoIP-TRUSTED.
  3. Import the list and check its number of entries before adding a drop rule.
  4. Test from an external connection located in the allowed zone.
  5. Then test from another controlled exit, for example a mobile access or a lab VPN.
  6. Check the counters without enabling per-packet logging.
  7. Exit Safe Mode only when the service and the fallback path are working.
/ip firewall filter print stats where comment~"GeoIP|zone"
/ip firewall address-list print count-only where list="GeoIP-BE"
/system resource print
/log print where message~"GeoIP"

Limitations and common mistakes

  • Confusing allocation with location : a prefix registered in Belgium can be announced or used elsewhere.
  • Allowing a zone as proof of identity : a relay located in the zone passes the same filter as a legitimate user.
  • Deleting the active list before download : a failure turns a restrictive filter into a complete lockout.
  • Downloading over HTTP : a security list must be transported over HTTPS from a controlled origin.
  • Importing without dry-run : a syntax error can leave a partial update.
  • Placing the rule in input for an internal server : published traffic traverses forward.
  • Forgetting IPv6 : an IPv4 rule does not protect a service also accessible in IPv6.
  • Loading too many zones : large lists slow down the import and consume router resources.
  • Logging every drop : use rule counters to avoid log overload.

For IPv6, generate a separate list from ipv6-aggregated.txt, import it into /ipv6 firewall address-list, and create the corresponding rules in /ipv6 firewall filter. Do not let IPv6 become an unintended bypass route.

Conclusion

Reliable GeoIP filtering on MikroTik relies less on the quantity of blocked countries than on the quality of the update chain. The data must be retrieved over HTTPS, validated as CIDR networks, published atomically, imported as a dry run, and loaded into a temporary list before any switchover.

Once this solid foundation is in place, the address-list becomes a simple firewall criterion : input for a router service, forward for a published server. The measure reduces noise and exposure, but intentionally maintains a secondary role behind the VPN, authentication, updates, and a minimal firewall policy.

Technical references

About the author

Jeremy Kraft runs the infrastructure and the self-hosted services of the IBCSC. These guides describe configurations that were actually deployed and re-checked before publication.

Discussion

Comments

0

No comment yet. Feel free to open the discussion with a question or your own experience.

Have an account? Sign in

Plain text, 2 000 characters maximum. New comments are moderated. Your name is shown with the comment. No e-mail address is asked; only a non-reversible fingerprint of your IP address is kept to fight spam.