Traefik ForwardAuth middleware utilizing GeoJS.io to whitelist IP's based upon geolocation.
This software has not been battle-tested, scrutinized, audited, or checked for vulnerabilities in any way. Use at your own risk! I take no responsibility for someone bypassing this simple location-based fence.
A small python program that utilizes a user-defined whitelist of countries or country/regions. As IP's attempt to connect to your server, GeoWhitelist can be run as a forwardAuth
middleware alongside Traefik to authorize or block requests based upon or geolocation or source IP.
When a device attempts to connect, the X-Forward-For
header will be passed from Traefik to this middleware. The IP is checked against 3 filters:
- Is the IP a legit IP, loopback, link local, not unspecified, or not multicast?
- Does the IP appear in the geographical whitelist?
- Does the IP appear in the provided IP whitelist?
If the answer is yes to the first question, and any of the following two above, the middlware returns a status 200 - OK
code, allowing the remote to connect. Otherwise it returns a status 403 - FORBIDDEN
code, blocking the request and future requests for a period of time. Eventually, the codes could be customized for easier integration with tools like fail2ban
. Currently you could have fail2ban
use the Traefik access log to find the 403
codes and ban those as you please.
To hide a service, you could instead return a 404 - Not Found
code.
The decision is stored in either a local cache (non-persistent) or a Redis cache. The default cache time is 3h and can be customized.
If the IP is passed to the geographical lookup, there will be a small delay during the first lookup, but subsequent requests will happen immediately as the decision is served from the cache.
- Linux server (tested on AlmaLinux 9, Fedora 35+, Ubuntu 20.04)
- Traefik (tested with 2.5+)
- Python3 (tested with 3.6.8+)
- (optional) docker/podman (tested with podman v3.4.1+)
- (optional) Redis (tested with v6.2)
The app can run with Redis as a caching backend or using an internal Python cache. I haven't done enough extensive testing to see how the performance changes, but generally speaking, Redis is a more robust cache system. For testing or very small deployments, the internal cache should suffice.
Note: If the app is unable to reach Redis, it will fall back to the internal cache.
If you decide to use a Redis instance, make sure to modify the config.yaml
file to match your Redis settings.
Note: I haven't implemented the password setting for Redis yet as I only access it locally.
As it's a simple python program using a uvicorn/gunicorn
server, you could run it directly on your server, or in a container like docker/podman.
- Clone this repository:
git clone https://github.com/krair/GeoWhitelist
- Install the required python packages:
python3 -m pip install -r requirements.txt
-
Edit the
config/config.yaml
andconfig/whitelist.yaml
files to your liking. -
Run with
gunicorn
:
gunicorn -b 127.0.0.1:9500 -k uvicorn.workers.UvicornWorker --daemon GeoWhitelist:app
This will daemonize the process (run it in the background). Below we will point Traefik at this service.
If you don't want to mess with your current version of python or you have multiple versions, consider using a virtual environment. After pip install
'ing the requirements.txt
into your venv
from this repository, run the above command from the virtual environment's directory like:
./my-venv/bin/gunicorn -b 127.0.0.1:9500 -k uvicorn.workers.UvicornWorker --daemon GeoWhitelist:app
Example:
https://bartsimons.me/gunicorn-as-a-systemd-service/
Generally preferred, and if you are running Traefik via docker/podman anyways, this method is quite easy but will require you to build the container yourself.
CRITICAL: If you are running rootless podman, ensure that you are able to get the actual remote address from Traefik and not simply a 10.0.2.100
address. See below for workaround.
- Clone this repository:
git clone https://github.com/krair/GeoWhitelist
-
Edit the
config/config.yaml
andconfig/whitelist.yaml
files to your liking. -
Build the container:
<podman/docker> build -t localhost/geowl .
- Run the container: Podman:
<podman/docker> run --name geowl -v ./config:/app/config localhost/geowl
OR
- Podman/Docker compose:
version: '3.7'
services:
traefik:
...
geowl:
image: localhost/geowl
container_name: geowl
volumes:
- "./config:/app/config"
...
Note: The above are rough examples and you will have to adjust accordingly, especially for Podman.
This component works as a ForwardAuth middleware for Traefik. As such, we will pass all requests through it to make a decision based upon source IP.
This is a very generic example, and if you look at the above link, you can customize the GeoWhitelist middleware to only protect certain URI paths, reqest methods, etc.
Note: I have not tested this with other forwardAuth providers such as Authelia, Keycloak, Authentik, etc. I suppose if you were to put this GeoWhitelist middlware in front of those they should still work fine since I don't currently set any cookies or pass headers along. Any testing on this front would be appreciated.
I personally prefer the old fashioned file provider for Traefik as it simplifies my compose files. In your dynamic file, it would simply look something like:
http:
...
middlewares:
geowl:
forwardAuth:
address: "http://geowl:9500"
authRequestHeaders:
- "X-Forwarded-For"
...
And in the router declaration, we add the middleware:
http:
...
routers:
example-router:
rule: Host(`example.tld`)
entryPoints:
- web
middlewares:
- geowl
service: example
...
If Traefik doesn't pick up the changes automatically, you might need to restart your Traefik container.
Effectively the same as above, but we can declare the GeoWhitelist middleware with the Traefik container and use it on other services:
version: '3.8'
services:
traefik:
...
labels:
- "traefik.http.middlewares.geowl.forwardauth.address=http://geowl:9500"
- "traefik.http.middlewares.geowl.forwardauth.authRequestHeaders=X-Forwarded-For"
And to add the middleware to a container:
version: '3.8'
services:
example:
...
labels:
- "traefik.http.routers.example-router.middlewares=geowl"
...
If running rootless, the Traefik container must be started with the --net=slirp4netns:port_handler=slirp4netns
option. If this is not used, it will appear to Traefik (and hence the GeoWhitelist container) that all requests are coming from the local address 10.0.2.100
. This GitHub question explains some of it.
As of Podman version 4.4.0, a new network backend known as 'pasta' is now available which gives the correct originating address, and is more performant that the older Slirp4netns backend listed above. I highly recommend using it if you run your containers in a rootless environment.
I would recommend running the GeoWhitelist middleware in the same pod as Traefik for simplicity. If you DO decide to run in a separate pod, bind it to a local address. For example, create a dummy
loopback interface bound to an internal address (like 10.254.254.1
for example), and bind your pod to that address. Access it via Traefik using the 10.254.254.1:9500
address.
Open your whitelist.yaml
file. Under the [Geo]
heading there are some examples of how to list locations. Use a new line for each location. To add a whole country, use the two letter ISO country code like:
DE
To add a specific region within a country:
FR/Hauts-de-France
This can be used multiple times to list more than one region:
FR/Hauts-de-France
FR/Île-de-France
Note: The slash between the country code and the region are critical, as the program splits the line on the /
instead of a space. This allows us to use region names like New York
.
If you are unsure of the region name, spelling, etc., it must match the results from GeoJS.io. To check, send a request with the desired IP and have a look at the result. For example with 9.9.9.9
:
curl -Ls https://get.geojs.io/v1/ip/geo/9.9.9.9 | tr ',' '\n' | grep region | cut -d ':' -f 2
output:
"California"
This means there's no region attached to the IP address. See the Concept section below.
While this section is a bit repetitive and mimics the ipWhitelist middleware already provided by Traefik, the inclusion was fairly trivial so I added it anyways. I may end up removing it later to simplify the code and just focus on the geolocation feature.
To include an IP, simply add it to your whitelist.yaml
file under the [IP]
section:
192.168.1.14
To include a range of IP's, CIDR notation can be used:
10.11.12.0/24
There's still a fair amount of testing I would have to do for more complete documentation for this. But the general concept is that when an IP is sent to the GeoJS endpoint, we have a few fields we can use as filters in our whitelist.
For example, if I ask for details on 9.9.9.9
:
curl -L https://get.geojs.io/v1/ip/geo/9.9.9.9
output:
{"organization_name":"QUAD9-AS-1","region":"California","accuracy":100,"asn":19281,"organization":"AS19281 QUAD9-AS-1","timezone":"America\/Los_Angeles","longitude":"-122.2676","country_code3":"USA","area_code":"0","ip":"9.9.9.9","city":"Berkeley","country":"United States","continent_code":"NA","country_code":"US","latitude":"37.8767"}
For now, I have only implemented country_code
and an optional region
to the filter. Not all addresses give the region in their response:
curl -L https://get.geojs.io/v1/ip/geo/8.8.8.8
output:
{"organization_name":"GOOGLE","accuracy":1000,"asn":15169,"organization":"AS15169 GOOGLE","timezone":"America\/Chicago","longitude":"-97.822","country_code3":"USA","area_code":"0","ip":"8.8.8.8","country":"United States","continent_code":"NA","country_code":"US","latitude":"37.751"}
Therefore, by adding US/Californa
to our whitelist, would allow 9.9.9.9
and deny 8.8.8.8
from connecting to the server as we have created a restriction for regions. However, if we simply add US
with no region behind, both would be allowed to connect.
Thus GeoWhitelist is not a perfect solution, but if you are trying to protect a specific service behind Traefik, we generally know where we want the access coming from. Most home internet users will have a fairly accurate location attached to their IP.
Can someone get around this by using a VPN masking their IP? Absolutely. This is not meant to be a "silver bullet" for security, just an added layer to remove requests from obvious script kiddies and scanners.