Automating Ingress whitelists with Plex

I recently needed to tackle a problem for one of my users. I employ whitelists for more sensitive services, which include some Dropbox-like functionality. One of my users was unable to use a VPN, but their IP address kept rotating.

How can I update nginx whitelists as well as firewall rules automatically (and maybe somewhat safely)? Read on for my latest crime against Kubernetes.

How is access handled with Synology Drive?

Synology Drive in my environment has two components. The first is the web interface, which is front-ended by a Kubernetes ingress with an associated service and endpoint (because the actual web interface is, of course, on the Synology). The basic configuration looks like this:

kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
  name: ingress-synology
  namespace: externalsvc
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod-dns01
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/proxy-body-size: "0"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
    nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,136.226.0.0/16,67.183.150.241/32,71.212.140.237/32,71.212.91.169/32,73.42.224.105/32,76.22.86.230/32"
spec:
...

The important line is the nginx.ingress.kubernetes.io/whitelist-source-range annotation, which controls who can connect. Without it, users will get a 403 error.

The second piece is a firewall rule to allow access to 6690, which is the TCP port that Synology Drive uses for transmitting data.

Luckily, my firewall is configured by a script that flushes all rules and then configures the firewall in the form of iptables commands (I am that old). An example of the rule looks like this:

iptables -A FORWARD -s "$SRC" -d "$HOST_NAS01" -p tcp --dport "$PORT" -j ACCEPT

Reading the latest IP address from Tautulli

Because my user sometimes watches videos on Plex, I have a log of some metadata from the stream. This includes the IP address. It turns out Tautulli has a decent API I could use.

curl -s "https://tautulli.ccrow.org/api/v2?apikey=<TAUTULLI_API_KEY>&cmd=get_history&user=<USER>&length=1" \
    | jq -r '.response.data.data[0].ip_address // empty'

The above curl command uses the API to run the get_history command for a given user. This provides a JSON payload. The legth parameter ensures I only return a single item, which I opted for instead of parsing all of the user’s watch history entries locally. We pipe that into jq to extract the IP address and return nothing if it isn’t found, which is helpful for our bash command.

The goal of our script is to produce a text file that contains one line per CIDR address that we can use to update our firewall rule, as well as the ingress whitelist. We need to do a few things to the result, like deduplicating the CIDR addresses. There seems to be a shell command for everything these days:

# Loop over users and fetch most recent IP
for user in "${USERS[@]}"; do
  ip=$(curl -s "${TAUTULLI_URL}?apikey=${APIKEY}&cmd=get_history&user=${user}&length=1" \
    | jq -r '.response.data.data[0].ip_address // empty')

  if [[ -n "$ip" ]]; then
    echo "${ip}/32" >> "$CIDR_FILE"
  else
    echo "No IP found for $user" >&2
  fi
done

### Deduplicate and sort
sort -u "$CIDR_FILE" -o "$CIDR_FILE"

echo "CIDR list built at $CIDR_FILE"

Updating our Ingress whitelist

We can then update a list of ingress YAML definitions to update the whitelist:

# --- Update ingress files ---

# Create a comma-separated string of CIDRs
CIDR_LIST=$(paste -sd, "$CIDR_FILE")

for file in "${INGRESS_FILES[@]}"; do
  if [[ -f "$file" ]]; then
    sed -i "s#nginx.ingress.kubernetes.io/whitelist-source-range:.*#nginx.ingress.kubernetes.io/whitelist-source-range: \"$CIDR_LIST\"#" "$file"
    echo "Updated whitelist-source-range in $file"
  else
    echo "File not found: $file"
  fi
done

Why use sed? Some of my YAML files have multiple manifests that are delimited by --- , and I was not very uniform. Sed is a simple way to find and replace the existing string.

We then simply check our changes into git for ArgoCD to sweep up and apply.

Updating our firewall rules

I mentioned that my firewall is simply a script that is run over SSH. I ensured that our $CIDR_FILE is copied alongside the script and updated my iptables script to include a simple loop:

### Clients I allow to connect to synology drive
CIDR_FILE="cidrs.txt"

# --- Ports to allow ---
PORTS=(6690 6281)  # You can adjust per requirement

# --- Apply iptables rules ---
if [[ ! -f "$CIDR_FILE" ]]; then
  echo "CIDR file not found: $CIDR_FILE"
  exit 1
fi

while IFS= read -r SRC; do
  for PORT in "${PORTS[@]}"; do
    iptables -A FORWARD -s "$SRC" -d "$HOST_NAS01" -p tcp --dport "$PORT" -j ACCEPT
    echo "Added rule: $SRC -> $HOST_NAS01 port $PORT"
  done
done < "$CIDR_FILE"

The above will insert two rules per client CIDR to allow TCP port 6690 and 6281. We loop through the file using the SRC variable as a placeholder for the line, we then loop through the PORTS array to add the rule. The above implementation will be very different depending on your firewall.

For those that want to see the update script, which is sadly triggered by a cronjob, in all of its glory:

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

### --- Config ---
source /root/secrets.sh
APIKEY=$TAUTULLI_API_TOKEN
TAUTULLI_URL="https://tautulli.ccrow.org/api/v2"

CIDR_FILE="$HOME/personal/homelab/tautulli-api/cidrs.txt"
FIREWALL_DIR="$HOME/personal/firewall"

# Static CIDRs
STATIC_CIDRS=(
  "136.226.0.0/16"
  "10.0.0.0/8"
)

# Usernames to query
USERS=(
  "User1"
  "User2"
)

# GitOps repo base directory
BASEDIR="$HOME/personal/gitops-cd"
INGRESS_FILES=(
  "$BASEDIR/manifests/externalsvc/synology.yaml"
  "$BASEDIR/manifests/kiwix/kiwix.yaml"
)

### --- Build CIDR file ---

# Add static CIDRs
for cidr in "${STATIC_CIDRS[@]}"; do
  echo "$cidr" >> "$CIDR_FILE"
done

# Loop over users and fetch most recent IP
for user in "${USERS[@]}"; do
  ip=$(curl -s "${TAUTULLI_URL}?apikey=${APIKEY}&cmd=get_history&user=${user}&length=1" \
    | jq -r '.response.data.data[0].ip_address // empty')

  if [[ -n "$ip" ]]; then
    echo "${ip}/32" >> "$CIDR_FILE"
  else
    echo "No IP found for $user" >&2
  fi
done

### Deduplicate and sort
sort -u "$CIDR_FILE" -o "$CIDR_FILE"

echo "CIDR list built at $CIDR_FILE"

# --- Update ingress files ---

# Create a comma-separated string of CIDRs
CIDR_LIST=$(paste -sd, "$CIDR_FILE")

for file in "${INGRESS_FILES[@]}"; do
  if [[ -f "$file" ]]; then
    sed -i "s#nginx.ingress.kubernetes.io/whitelist-source-range:.*#nginx.ingress.kubernetes.io/whitelist-source-range: \"$CIDR_LIST\"#" "$file"
    echo "Updated whitelist-source-range in $file"
  else
    echo "File not found: $file"
  fi
done

echo "updating git in 10 sec"
sleep 10

# --- Git commit changes ---
cd "$BASEDIR"
#git pull || echo "ERROR: can't pull"; exit 1
if [[ -n "$(git status --porcelain)" ]]; then
  git add .
  git commit -m "automated whitelist update"
  echo "Changes committed in $BASEDIR"
else
  echo "No changes to commit in $BASEDIR"
fi

git push

echo "updating firewall in 10 sec"
sleep 10
cd $FIREWALL_DIR
./install_fw2.sh

Never underestimate the word ethic of a lazy system administrator!

Leave a Reply

Your email address will not be published. Required fields are marked *