EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Security / Changing Office IPs: Whitelisting with an SSH Forced Command
Security

Changing Office IPs: Whitelisting with an SSH Forced Command

by Ton-Or ·11 Nov 2026·12 min read
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
Jump to steps

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.

Whitelist the entire block

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.

DDNS

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
PC beside adminQuery 2 IP echoesPush only if matchedHistory in SQLiteroot's authorized_keysrestrict,command="/usr/local/sbin/office-ip-push"Ignore commands sent by clientssh -i push_keyScript: IP = first field of $SSH_CLIENTCheck IPv4 and geoiplookup must return THNot TH or lookup fails = REJECTUnchanged = OK same; else write tmp + mvsystemctl startip-allow-sync.serviceallow + ignore new IPPanel whitelistClear bans csf -raReport to chatIP change or push fails
The whitelisted IP comes from the pushing machine's actual connection, not from a message sent by the client.

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.

A building entrance at night with a single mail slot in the wall. A guard inside reads only the postmark of origin and records the name in a ledger if the postmark is domestic, accepting no requests for anything else.
A single slot in the wall: the sender can leave only an envelope, while the guard reads only the origin postmark applied by the postal service, not what the sender wrote.

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.

If this key leaksWhat the attacker getsWhy I accept itKey A pushes IP to whitelistrestrict + commandWhitelist only a Thai IPof the key holder's machinecommand ignores client inputIP only from SSH_CLIENTNot TH = REJECTKey B controls alert botalert-ctl + restrictfrom= stable sourcestatus log test runmute N and unmutemute up to 1440 minutesOther commands = print helpNo shell or port forwardingNo agent forwarding or ptyEdits need a separate root keyfrom= works for B with its stable source, but not Abecause the source IP is what keeps changing
Neither key provides a shell. Writing a table like this before issuing a key makes the maximum acceptable damage clear.

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.

A before-and-after image. On the left, a janitor's large key ring includes a master key that opens every room. On the right, a small key labeled for one task opens only a single mailbox.
A master key opens every room, while a single-task key opens only one mailbox. The intent is to limit the damage from a leaked key to that scope.
The numbers and rules in this article are specific to my environment

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

  1. 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.
  2. Keep the forced command script as small as possible, readable on a single screen, and log every invocation, both accepted and rejected.
  3. Install all script dependencies on every machine, such as geoip-bin, because a missing dependency will cause every request to be rejected.
  4. Pin the host key fingerprint on the client; do not trust a guessed value on the first connection.
  5. 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.

3 minutesIP check interval
2 sourcesMust agree
6 hoursheartbeat
1440 minutesMaximum mute

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.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first