This is a server app that serves as a companion for an Nginx install configured as a reverse proxy and compiled with the ngx_http_auth_request_module
.
It validates Nginx requests against a list of IP addresses. In order for an IP to be added to the whitelist a key must be presented in a query string. Once whitelisted, each IP will be valid for a configurable amount of time.
This app was designed to be particularly easy to integrate with Nginx Proxy Manager running in Docker.
- 1. Obligatory security warning
- 2. Prerequisites
- 3. How does it work?
- 4. How to run the whitelister
- 5. Configuring the validator
- 6. How to integrate with Nginx
- 7. Validation logic
- 8. Credits
nginx-ip-whitelister was designed to be a security improvement over leaving your Emby/Jellyfin completely open to the whole Internet.
It is not better than using a full-fledged VPN. If you already have OpenVPN, WireGuard, Tailscale or a SSH tunnel working and you're considering trading them for this, think twice!
While having Emby/Jellyfin exposed directly as HTTP links is more convenient, it's also less secure. You will be literally trading security for convenience.
In case you're still foolish enough to use this:
- nginx-ip-whitelister won't work on its own. You must use Nginx as a reverse proxy.
- Enable HTTPS on the reverse proxy! If you don't do this you might as well give up the whole thing right now.
- Use a long key for validation. For example:
dd status=none if=/dev/urandom bs=1024 count=1|sha256sum
- Consider enabling Basic Authentication on the reverse proxy as an additional layer of protection and using a long username and password.
- Understand that there's no 100% foolproof way to prevent the access link from making its way into the world, hopping from friend to friend.
- Stop nginx-ip-whitelister. This will cut all access instantly, because Nginx will refuse requests if it cannot reach the validating backend.
- Change the keys before you restart the app/container.
- Check the logs to see what went wrong.
In order to use nginx-ip-whitelister you must have already accomplished the things below:
- The host that runs Emby/Jellyfin has a public IP (your ISP allocates one for your home router, or you're using a VPS etc.)
- You have a [sub]domain A record pointing at that public IP (and you use DDNS to keep it in sync if it's dynamic etc.)
- You forward a port in your firewall to an Nginx install acting as reverse proxy in front of Emby/Jellyfin.
- Nginx was compiled with the
ngx_http_auth_request_module
. - You have configured SSL for your domain (highly recommended), so that all connections to Emby/Jellyfin are encrypted.
- Bottom line, if you connect to
https://your.domain[:PORT]/
you can see and use your Emby/Jellyfin.
It is beyond the scope of this documentation to explain how to achieve all this. For what it's worth I recommend using Nginx Proxy Manager because it makes some of the things above a lot easier.
By default your Emby/Jellyfin install will show 403 errors to any visitor.
To make it work your friend and relatives need to use a link like this:
https://your.domain[:PORT]/?ACCESS-KEY[:TOTP]
The link goes to the Nginx reverse proxy, where it runs against the Nginx proxy configuration for your.domain
.
You add a configuration snippet to that host that will cause all requests to be validated against a 3rd party URL.
That 3rd party URL belongs to the nginx-ip-whitelister – which needs to be running at an address that the Nginx host can access, naturally.
Whenever nginx-ip-whitelister sees a valid access key in a request URL it adds the visitor's IP address to a whitelist. Once that happens, all the following requests from the IP (which usually means everybody and everything in their LAN) will be allowed through.
You can optionally configure more conditions for the visitors on top of using a key, such as netmasks, GeoIP, TOTP codes etc.
Copy .env.example
to .env
and edit to your liking. Then:
$ npm install --omit-dev
$ node index.js
You may want to use a tool like supervisor
or nodemon
that will restart the whitelister if it fails.
You can use the Dockerfile
and .dockerignore
included in the package and run the following command from the project root:
$ docker build --tag zuavra/nginx-ip-whitelister .
Yes, there's a dot at the end of the command.
This will build the image and publish it to your machine's local image repository, where it's now ready for being used by Docker containers:
You can run a Docker container that listens on the host's network interface. Use this if your Nginx or Nginx Proxy Manager are able to communicate directly with the host network.
See the docker-compose-standalone.yaml
file for an example.
You can of course also rely on .env
if you place this in the same dir, omit the environment:
section, and define the port as - "${PORT}:${PORT}/tcp"
.
If you intend to run both Nginx Proxy Manager and nginx-ip-whitelister as Docker containers you need to define a Docker network between them so the proxy will be able to reach the validator.
- Create the Docker network:
# docker network create nginx-network
- Tell each container to use the network by adding it to their
docker-compose.yaml
service definition:networks: - nginx-network
- Give the validator its own hostname, so it's easier to refer to it from the proxy config:
hostname: nginx-iw
- Add the network definition outside the service definition:
networks: nginx-network: external: name: nginx-network
- It's also a very good idea to make the nginx-ip-whitelister container depend on the Nginx / Nginx Proxy Manager container:
depends_on: - name-of-nginx-container
See the docker-compose-proxy-manager.yaml
file for an example that combines both service definitions into a single file.
The following variables need to be available in the app environment to work. You can defined them in an .env
file placed near index.js
if you're running a standalone app, or provide them in the compose configuration, or as docker command line parameters etc.
PORT
: defines the port that the validator listens on. Defaults to3000
.HOST
: defines the interface that the validator listens on. Defaults to0.0.0.0
.DEBUG
: if set toyes
it will log every request to the standard output. By default it will only log the startup messages.
In order to tell Nginx to use nginx-ip-whitelister you need to use the auth_request
directive to validate requests against the correct verification URL, and pass to it the original URI and the remote IP address.
In order to be able to use
auth_request
, Nginx needs to include thengx_http_auth_request_module
.
The auth_request
directive can be used in the http
, server
or location
contexts. Please see the example below.
If you're using Nginx Proxy Manager, edit the proxy host that you're using for Emby/Jellyfin and add this example configuration in the "Advanced" tab.
auth_request /__auth;
location = /__auth {
internal;
proxy_pass http://nginx-iw:3000/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
}
If you're running the app standalone or in a non-networked container please replace nginx-iw
with the appropriate hostname or IP address.
Use /verify
to call the conditional validator.
You can also use /approve
to always pass the check, and /reject
to always fail the check.
The following headers can optionally be passed to the validator from Nginx to adjust the timeout policy for the whitelist.
The header names are case insensitive. You can only use these headers once each – additional uses will be ignored.
The timeout policy is always enforced, whether you use these headers or not. Using them allows you to adjust the timeouts – see the defaults below.
x-nipw-fixed-timeout
: A strictly positive integer, followed by the suffixd
,h
,m
ors
to indicate an amount of days, hours, minutes or seconds, respectively. The fixed timeout is compared against the moment when an IP was first added to the whitelist and it does not change. In other words, if you set a fixed timeout of6h
, the IP will be de-listed 6 hours later, period. If you don't provide this header, the fixed timeout defaults to 2 hours.x-nipw-sliding-timeout
: Same format as the fixed timeout. The sliding timeout is compared against the most recent access from that IP, and if successful the last access is reset to now. In other words, if you set a sliding timeout of '30m', the IP will not be de-listed unless there's no access for 30 straight minutes. If you don't provide this header, the sliding timeout defaults to 5 minutes.
Both timeout policies are enforced in parallel – each IP has a fixed time window from when it started as well as a condition to not be inactive for too long.
The following headers can optionally be passed to the validator from Nginx to impose additional condition upon the requests.
The header names are case insensitive. Each of these headers can be used multiple times.
Please don't use commas or semicolons inside header values, they sometimes cause header libraries to split the value into separate ones.
x-nipw-key
: Define additional authentication keys that will only apply to this proxy host.x-nipw-netmask-allow
: Define one or more IP network masks to allow. An IP that doesn't match any of these masks will be rejected.x-nipw-netmask-deny
: Define one or more IP network masks to deny. An IP that matches any of these masks will be rejected. These headers will be ignored if any-netmask-allow
headers are defined.x-nipw-geoip-allow
: Define one or more two-letter ISO-3166-1 country codes to allow. An IP that doesn't match any of these countries will be rejected. Private IPs always pass this check.x-nipw-geoip-deny
: Define one or more two-letter ISO-3166-1 country codes to deny. An IP that matches any of these countries will be rejected. Private IPs always pass this check. These headers will be ignored if any-geoip-allow
header is defined.x-nipw-totp
: Define one or more TOTP secrets. If any-totp
header is defined, the visitor will have to append a valid TOTP code matching one of the secrets to the URL key, separated by a colon:/?ACCESS-KEY:TOTP-CODE
. If none of the secrets have been matched the request will be rejected.
Please understand that GeoIP matching is far from perfect. This project uses a "lite" GeoIP database which is not super-accurate, but even exhaustive databases can make mistakes. Accept the fact that occasionally you will end up blocking (or allowing) an IP that shouldn't be.
The logic works in the following order:
- If the validation app cannot be reached by Nginx or returns any status code other than 2xx (including 500 if it malfunctions), request is rejected.
- If any allow netmasks are defined and the IP doesn't match any of them, request is rejected.
- If any deny netmasks are defined and the IP matches any of them, request is rejected.
- If any GeoIP allow countries are defined and the IP is not private and doesn't match any of them, request is rejected.
- If any GeoIP deny countries are defined and the IP is not private and matches any of them, request is rejected.
- If the IP is found in the whitelist and has not expired (subject to both sliding and fixed timeout), the last access timestamp is updated, request is approved.
- If the visitor's URL key doesn't match any of the defined keys, request is rejected.
- If any TOTP secrets are defined and the visitor's URL TOTP code doesn't match any of them, request is rejected.
- The IP is added to the whitelist with a creation timestamp and a last access timestamp, request is approved.
Remember that the whitelist is stored in RAM and will be lost every time you stop or restart the app (or its container).
This project uses IP Geolocation by DB-IP.