Changing Office IPs: Whitelisting with an SSH Forced Command
An SSH key restricted to one task, with no trust in client commands and no shell for its holder, limits what a leaked key can do to what I designed it to do, provided I write a clear threat model before issuing it.
1-minute summary
- Symptom / problem
- An outbound IP behind CGNAT keeps changing and gets banned by the firewall again and again
- Who should read
- Admins who need a nearby machine to push its IP into a whitelist safely
- Fix in short
- Use an SSH key locked to a single command, read the IP from SSH_CLIENT, check the country fail-closed, and write down the threat model
I work over a fiber connection from a Thai provider that puts me behind CGNAT, so my public IP changes periodically. Sometimes it changes after a day, sometimes after a router reboot, and it often lands in a different block from the previous one. As a result, the server firewall (CSF and LFD) and DirectAdmin's brute-force detection keep banning my new IP. My old workaround was to whitelist the provider's entire block, which was far too broad for my comfort. This article describes what I switched to and how I applied the same approach to another seemingly unrelated task.
Why the first three approaches I considered were not enough
I tried each approach in turn, and every one had a weakness I encountered in practice.
Too broad
This lets every machine in the provider's block through the firewall, including other customers I do not know. I am not willing to trade that much risk for convenience.
Depends on an external host
I had configured the firewall to resolve a DDNS name. One day, the DDNS host I used stopped responding. The name would not resolve, so the new IP silently went unadded.
The third approach was to restrict the key with from= in authorized_keys. That does not work here because from= needs a stable source, while my source IP is precisely what keeps changing. I have already written about the other side of this in the article about from= preventing backup pulls after the source IP changed. There, the restriction was the weakness. Here, I need a design that does not depend on a stable source, with the server reading the IP itself from the actual connection.
The idea: have a nearby machine push its IP
There is a PC at the office that stays on all the time and accesses the internet through the same IP as my work machine. I have this PC tell the server what the current IP is. The watcher is a Python script using only the standard library, running as a Windows scheduled task every 3 minutes under the system account, so it starts working at boot without anyone logging in.
- Query two independent IP echo services and accept a change only when both return the same address. If they disagree, skip that cycle and log it.
- Keep a history of IP time ranges in SQLite, recording when each was first seen, when it was last seen, the provider, and rDNS, so I can look back through it.
- Push when the IP changes, retry after a failed push, and push every 6 hours as a heartbeat.
- Send a chat message when the IP changes, including the result from each server, and notify me when a push fails or recovers.
import urllib.request
SOURCES = ["https://echo-a.example.com/ip", "https://echo-b.example.net/ip"]
def current_ip():
seen = set()
for url in SOURCES:
try:
seen.add(urllib.request.urlopen(url, timeout=8).read().decode().strip())
except Exception:
return None # แหล่งไหนล่ม = ข้ามรอบนี้
return seen.pop() if len(seen) == 1 else None # สองแหล่งต้องตอบตรงกัน
I require both sources to agree because these services can return the wrong result in several ways: a temporary outage, returning a proxy's address instead, or a response cached somewhere along the way. If I trust one source and push the wrong IP into the whitelist, I end up adding an address that is not mine to the allowlist, which is worse than not pushing at all. Skipping a cycle when the two sources disagree is therefore safer. The only cost is waiting another 3 minutes for the next cycle.
I retry after failures and send a heartbeat every 6 hours because a single push when the IP changes can be missed, for example if the destination server happens to be rebooting at that moment. Without a retry, I might not notice until I get banned. The heartbeat is a safety net that brings the system back into the correct state within a few hours. The server script returns OK same when nothing needs changing, so it does nothing to the firewall.
The push is an ssh connection to the server with no command sent at all. The key's command is fixed on the server. On Windows, I pin the host key fingerprint in the plink command so I do not connect to an impersonating machine.
& C:\tools\push\plink.exe -ssh -batch -noagent -P 22 -i C:\tools\push\push.ppk `
-hostkey "SHA256:<HOSTKEY_FINGERPRINT>" root@host-a
On the server: a key that can do only one task
The line in root's authorized_keys is the core of this setup. restrict disables the facilities SSH can provide: pty, port forwarding, agent forwarding, and X11. Meanwhile, command= forces every connection using this key to run this one script, regardless of what the client asks to run.
restrict,command="/usr/local/sbin/office-ip-push" ssh-ed25519 AAAA...placeholder... ipwatch-push
The script reads nothing from the client. It trusts only the SSH_CLIENT variable that sshd sets from the actual connection, which the client cannot forge. The second requirement is that the address must be IPv4. The third is that geoiplookup must report Thailand as the country. If geoiplookup is missing or fails, I reject the request because I choose to deny access when uncertain (fail-closed). This means every destination machine must have the geoip-bin package installed beforehand.
#!/bin/bash
# /usr/local/sbin/office-ip-push (forced command; ไม่ใช้อะไรจาก client)
PUSH_DIR=/var/lib/ip-allow-sync/push
LOG=/var/log/ip-allow-sync.log
log(){ echo "$(date '+%F %T') office-ip-push: $*" >> "$LOG"; }
IP="${SSH_CLIENT%% *}"
if ! printf '%s' "$IP" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
log "REJECT bad source"; echo "REJECT bad-source"; exit 1
fi
if ! geoiplookup "$IP" 2>/dev/null | grep -q 'Edition: TH,'; then
log "REJECT $IP not TH"; echo "REJECT not-TH"; exit 1 # ไม่มี geoiplookup = ปฏิเสธ
fi
mkdir -p "$PUSH_DIR"
OLD=$(cat "$PUSH_DIR/office" 2>/dev/null)
[ "$OLD" = "$IP" ] && { echo "OK same $IP"; exit 0; }
echo "$IP" > "$PUSH_DIR/office.tmp" && mv -f "$PUSH_DIR/office.tmp" "$PUSH_DIR/office"
log "office ${OLD:-none} -> $IP"
systemctl start ip-allow-sync.service
echo "OK set $IP"
I write the state to a temporary file and then mv it over the existing file so another service can never read a half-written file. If the IP matches the stored address, the script returns OK same and exits without touching the firewall, preventing the heartbeat every 6 hours from needlessly disturbing the rules.

The service that actually changes the firewall
The script above only records the IP and starts ip-allow-sync.service to do the work. I keep them separate because this part must be safe to run repeatedly (idempotent) and work across multiple machines with different firewalls. The service works in this order: remove the old IP's lines from csf.allow and csf.ignore, add the new IP with a note, run csf -dr and csf -tr against the new IP to remove bans imposed before whitelisting, then update DirectAdmin's whitelist file, which accepts only bare IP addresses without comments. On machines running CrowdSec, it also removes decisions for both the old and new IPs.
# ส่วนหลักของบริการ (ย่อ) ลบของเก่า เพิ่มของใหม่ ปลดแบน
for f in /etc/csf/csf.allow /etc/csf/csf.ignore; do
[ -n "$OLDIP" ] && sed -i -E "/^${OLDIP//./\\.}([[:space:]#]|\$)/d" "$f"
echo "${NEWIP} # pushed-ip auto" >> "$f"
done
csf -dr "$NEWIP" >/dev/null 2>&1 # ปลดแบนที่เกิดก่อน whitelist
csf -tr "$NEWIP" >/dev/null 2>&1
csf -ra # ไม่ใช่ -r : -ra ทำให้ lfd อ่าน csf.ignore ใหม่ด้วย
One thing I had to fix later was that I initially used csf -r, which reloads the firewall rules, but LFD, which applies the bans, did not reread the ignore list. That left a chance of the new IP still being banned despite being whitelisted. Switching to csf -ra fixed it. Another point is that the service whitelists only one pushed IP per source at a time. The new address replaces the old one; they do not accumulate.
Example B: the same key approach controls a bot without a shell
I applied the same approach to another task. I have a few small VPS instances running a timer every minute to send only serious alerts: load, RAM, disk, stopped services, ssh logins from outside the whitelist, verification key changes, and nightly integrity reports. This bot sends messages only to one admin's chat and reads no replies. Each problem generates one alert when it occurs and another when it clears. The alert bot design is covered in the server monitoring bot article. Here, I am covering only permissions.
The problem was that I wanted to mute alerts temporarily before planned work, from another machine, without giving that machine root access. The answer was another key with its command restricted to alert-ctl, also using from= because the controlling machine has a stable source.
command="/usr/local/sbin/alert-ctl",restrict,from="203.0.113.5,198.51.100.0/24" ssh-ed25519 AAAA...placeholder... botctl
#!/bin/bash
# alert-ctl : สิ่งที่ client ขอจริงอยู่ใน SSH_ORIGINAL_COMMAND
read -r cmd arg <<<"${SSH_ORIGINAL_COMMAND:-help}"
case "$cmd" in
status) systemctl status alert-bot.timer --no-pager ;;
log) [[ "${arg:-50}" =~ ^[0-9]+$ ]] || arg=50
journalctl -u alert-bot.service -n "${arg:-50}" --no-pager ;;
test) systemctl start alert-bot-test.service ;;
run) systemctl start alert-bot.service ;;
mute) if [[ "$arg" =~ ^[0-9]+$ ]] && [ "$arg" -ge 1 ] && [ "$arg" -le 1440 ]; then
/usr/local/sbin/alert-mute "$arg"
else echo "mute <1-1440 minutes>"; fi ;;
unmute) /usr/local/sbin/alert-mute 0 ;;
*) echo "status | log N | test | run | mute <min, max 1440> | unmute" ;;
esac
The only accepted commands are status, log N, test, run, mute <นาที> with a maximum of 1440 minutes, and unmute. Anything else just prints usage instructions. Muted alerts are sent after the time expires; they are not discarded. The service has a time limit of 30 seconds per command and 50 seconds per unit, and sends its own warning message if it fails. Modifying the script itself requires a separate root key, not this one. Planned changes to users or keys on the VPS intentionally trigger alerts.
Write the threat model on paper before issuing the key
Before issuing a key like this, I write a short table answering three questions: what an attacker gets if the key leaks, why I can accept that, and what I do not guarantee.
For key A, an attacker who obtains it can do only one thing: whitelist their own Thai IP, not an IP they specify, because the script reads no values from the client. They cannot get a shell, use port forwarding or agent forwarding, or use a tty. I need to be explicit about what I do not guarantee. An attacker in Thailand can still add their own IP to the whitelist, and being whitelisted does not mean they can log in. The machine's SSH still requires its own key or authentication. I consider this an acceptable risk because all the attacker gains is exemption from IP bans, which are only one of several barriers.

Every IP in this article is an example. Whitelist design and command-restricted keys directly affect a machine's security. Before implementing this, assess the maximum acceptable damage in your own system and have the person responsible for that machine's security review it before enabling it.
Precautions for production use
- Test the key from a machine outside the whitelist. If you test from a machine already allowed through, you will not know whether the script worked or the connection simply was not blocked.
- Keep the forced command script as small as possible, readable on a single screen, and log every invocation, both accepted and rejected.
- Install all script dependencies on every machine, such as
geoip-bin, because a missing dependency will cause every request to be rejected. - Pin the host key fingerprint on the client; do not trust a guessed value on the first connection.
- Do not include real bot names, chat IDs, or key fingerprints in published documentation.
If you also use an AI assistant to work with SSH, the safeguards for that side are in the article on safe SSH with Claude. It complements this article's point that a key should have only the permissions its task requires.
To sum up, if I need one machine to perform a single task on another, I issue a key that can do only that task, set restrict and command=, have the script trust only what sshd tells it, and answer the three threat model questions before installing the key. Whether to add from= depends on whether the source is stable in that environment. I have not yet covered every detail of the whitelist service; I will come back and add more later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first