Protect SSH access during nftables changes with timed rollback

Protect SSH access during nftables changes with timed rollback

Protect SSH access during nftables changes with timed rollback

When managing a dedicated server remotely, one of the easiest firewall mistakes to make is assuming that the rules have passed validation because commands still work in the current SSH session. The opposite may be true: connection tracking can keep the existing session alive while new connections are already blocked. A reliable procedure is to save the current state and schedule recovery first, load the rules next, and then verify the management path with an independent login.

This article uses Debian 12, nftables, and systemd to demonstrate an incremental change that restricts only SSH connection sources. Connection addresses and credentials for AMDSSH servers are available in the console after payment; when a server has multiple IPv4 addresses, SSH uses the first one. The example does not assume a username or port, nor does it define a complete policy for application ports.

1. Establish who manages the rules

The following steps require an account with sudo privileges, nftables installed, and a ruleset managed entirely through native nftables. If container networking, virtualization bridges, or other firewall tools generate rules dynamically, do not apply this full-snapshot recovery procedure unchanged: a rollback could overwrite rules they added during the change. First identify who owns the rules, then use the change procedure for the relevant tool.

Inspect the current state:

sudo nft list ruleset
sudo systemctl cat nftables.service
command -v nft

Confirm the existing rules, the file loaded at startup, and the actual path to nft. This article uses a separate inet ops_guard table. If a table with that name already exists, check its purpose before proceeding; do not overwrite rules used for another task.

The rollback timer handles only this firewall rule change. It cannot repair failures caused by changing routing, the SSH service, or the management source at the same time. Change only one variable per operation.

Read the actual SSH connection parameters

Run the following in Bash within the current remote login. Do not first switch to another session that loses the connection environment variables:

test -n "$SSH_CONNECTION" || exit 1
read -r ADMIN_IP CLIENT_PORT SERVER_IP SSH_PORT <<< "$SSH_CONNECTION"
case "$ADMIN_IP" in
  *:*) printf '%s\n' 'IPv4 SSH session required'; exit 1 ;;
esac
printf 'source=%s destination=%s port=%s\n' \
  "$ADMIN_IP" "$SERVER_IP" "$SSH_PORT"

This retrieves the connection source as seen by the server. It may be an office egress address or a jump host address, rather than the workstation's private address. If the egress address can change, establish a stable management path first. This example requires the current session to use IPv4; do not generate the rules from an IPv6 session and try to continue anyway.

2. Save a snapshot and schedule automatic recovery

The snapshot must include an instruction to clear the old rules; otherwise, restoring it may conflict with existing objects. Restrict directory access to administrators so ordinary accounts cannot read the addressing information in the rules.

sudo install -d -m 700 /root/firewall-change
sudo sh -c '
set -e
umask 077
{
  printf "flush ruleset\n"
  nft list ruleset
} > /root/firewall-change/before.nft
'
sudo nft -c -f /root/firewall-change/before.nft

The -c option checks the file without loading it. It can detect syntax and rule-reference problems, but it cannot prove that remote login will remain available. If the check fails, stop the change; do not bypass the error and continue.

After confirming that no previous job with the same name remains, schedule recovery for three minutes from now:

sudo systemctl status ops-firewall-rollback.timer
sudo systemd-run \
  --unit=ops-firewall-rollback \
  --on-active=3m \
  "$(command -v nft)" \
  -f /root/firewall-change/before.nft
sudo systemctl list-timers ops-firewall-rollback.timer

It is normal for the first query to report that the unit does not exist. If a job already exists, investigate its origin first. Three minutes is the rollback waiting period for this operation, not a service delivery commitment. Confirm that the timer is scheduled before proceeding to load the rules. Do not change other network rules during this interval: restoring the entire ruleset will undo those changes too.

3. Restrict management sources with a separate table

Create an empty table on first use. Run the creation command only after confirming that no table with the same name exists. If the dedicated table for this operation already exists, skip this step:

sudo nft add table inet ops_guard

Then generate the candidate file:

sudo tee /root/firewall-change/candidate.nft > /dev/null <<EOF
delete table inet ops_guard
table inet ops_guard {
  chain input {
    type filter hook input priority 10; policy accept;
    iifname "lo" accept
    ct state established,related accept
    ip saddr $ADMIN_IP tcp dport $SSH_PORT accept
    tcp dport $SSH_PORT counter drop
  }
}
EOF
sudo chmod 600 /root/firewall-change/candidate.nft
sudo nft -c -f /root/firewall-change/candidate.nft
sudo nft -f /root/firewall-change/candidate.nft
sudo nft -a list table inet ops_guard

The deletion and recreation in this file are committed in a single nftables transaction, rather than clearing the table first and then appending rules one at a time. Every load of the candidate file requires the dedicated table to exist already, so the file cannot be used directly as a cold-start configuration.

The rules allow loopback traffic and established connections, permit the current IPv4 management source to access the actual SSH port, and then block other sources from accessing that port. The last rule also matches IPv6, so this example does not allow new SSH connections over IPv6. If IPv6 management access is required, add an explicit ip6 saddr allow rule beforehand and test it separately.

This table does not restrict other application ports. Its accept verdicts also cannot override a drop in another base chain: existing rules may still reject new connections. Do not treat this example as a complete security baseline, and certainly do not use it as a reason to remove existing application policies.

4. Decide whether to commit using a fresh, independent connection

Keep the original session open and start another connection from the allowed source. If the client has connection multiplexing enabled, disable it; otherwise, a new window may still use the old TCP connection.

Run the following commands in Bash on the client, entering the parameters supplied in the console when prompted:

read -r -p 'Host: ' HOST
read -r -p 'User: ' LOGIN
read -r -p 'Port: ' PORT
ssh -o ControlMaster=no -o ControlPath=none \
  -p "$PORT" "$LOGIN@$HOST"

Validation must cover at least three checks:

A password or key authentication error alone does not prove that the source restriction has failed. Determine which layer rejected the connection. Do not temporarily relax the rules for testing.

If the independent login fails, preserve the current state and wait for rollback rather than adding allow rules one after another. After recovery, inspect the logs for the service associated with the timer, then analyze the source address, port, and effects of other chains. Once validation succeeds, stop the timer:

sudo systemctl stop ops-firewall-rollback.timer
sudo systemctl is-active ops-firewall-rollback.timer

Also confirm that the recovery service has not already started. If the timer expired at that moment, read the live ruleset again. A successful stop command does not prove that the candidate configuration is still active.

5. Handle persistence and startup validation separately

Only address the startup file after the live rules have passed validation. Use the service configuration inspected earlier to confirm the actual load path. A common path on Debian 12 is /etc/nftables.conf, but a custom service may use a different one.

Once the rules are stable and no other manager is modifying them, export the complete live ruleset to a file that includes flush ruleset, check it with nft -c -f, and back up the original startup file before replacing it. Do not save only the dedicated table: doing so may leave out existing application rules after a reboot.

Making the configuration persistent is a second change. Schedule rollback again, load the complete startup file, and repeat the independent login test. Do not combine enabling the service, replacing the configuration, and rebooting the server into a single command. Arrange reboot validation separately, checking the rules, login from the allowed source, and application connectivity.

Finally, record the actual source address, SSH port, candidate file, snapshot, and validation results. The evidence needed to accept the change is not “the command succeeded.” It is all three conditions together: fresh connections work, the blocking path behaves as expected, and the startup configuration can reproduce the rules.

Frequently asked questions

Can rollback be cancelled while the original SSH session still works?

No. Connection tracking may keep an established session working. Open an independent new connection from an allowed source and complete authentication before cancelling the rollback timer.

Does this example close application ports as well?

No. It restricts only new connections to the actual SSH port. Other ports remain subject to existing rules; this is not a complete default-deny firewall policy.

EPYC 7543P dedicated serverFrom $450.2/month

Choose a model, then configure the server

The monthly total depends on the selected model and options. Full pricing details are listed on the configuration page. Connection credentials are available in the console after delivery.