Runbook: RB17 — nftables / iptables fallback firewall¶
Symptom¶
You need a host-level firewall change RIGHT NOW because one of: - Active DDoS / brute-force / scrape against the host; need to drop all traffic except SSH. - The application-layer rate limiter / WAF is failing open. - You suspect an intrusion and want to isolate the host while you investigate, without taking it offline (SSH still needed for recovery).
This runbook gives you the fallback ruleset that ships with MAID (nftables preferred, iptables fallback) and the literal commands to apply it.
Detection¶
maid_rate_limit_block_total{reason="ip_blocklist"}rate spike.- ssh logs full of brute force attempts (
journalctl -u sshd | grep 'Failed password'). - Web logs full of scrape / vulnerability scans.
- Operator decision based on threat report.
Blast radius¶
- Players affected: depends on the rule. The default fallback ruleset in this runbook DROPS all player traffic. Use only when isolating the host is preferable to leaving it open under attack.
- Data at risk: none from firewall changes themselves (no data path involvement). Risk is from being too restrictive and locking yourself out via SSH — read the SSH section carefully.
- AI/external systems: outbound to AI providers also blocked if you use the strict ruleset. Use the relaxed ruleset variant if you need outbound while inbound is locked down.
Lockout warning: if you apply the ruleset from a remote SSH session, ensure the rules ALLOW your current SSH connection's source port AND that the ruleset is
nft -fapplied atomically (do not use a long chain of imperative commands). Many operators have fired themselves out of their own box by sequencing rules wrong.
Prerequisites¶
- Tools:
nft(nftables) — preferred.iptables— fallback if nftables is not the host's active backend. - Access: root on the host (this runbook uses
sudoeverywhere). - Knowledge of:
- your SSH listening port (typically 22, sometimes 2222),
- the source IP/CIDR you are SSH'd from (so you don't lock yourself out),
- whether the host uses nftables natively (Debian 12, RHEL 9+) or legacy iptables (older RHEL/CentOS).
- Audit access:
tail -f /var/log/maid/ops-audit.jsonl. - Escalation: see ./escalation-contacts.md.template.
First 5 minutes (LITERAL commands)¶
Step 0: detect which backend is active¶
# nftables active?
sudo nft list ruleset 2>/dev/null | head -5 && echo "NFT_BACKEND=nft"
# Legacy iptables active?
sudo iptables -L -n 2>/dev/null | head -5 && echo "IPT_BACKEND=iptables"
# What does the host think is canonical?
which nft iptables
systemctl is-active nftables 2>/dev/null
systemctl is-active iptables 2>/dev/null
Step 1: snapshot the current ruleset (you may need to restore it)¶
sudo nft list ruleset > ./pre-incident-nft-$(date -u +%Y%m%dT%H%M%SZ).nft 2>/dev/null
sudo iptables-save > ./pre-incident-iptables-$(date -u +%Y%m%dT%H%M%SZ).rules 2>/dev/null
sudo ip6tables-save > ./pre-incident-ip6tables-$(date -u +%Y%m%dT%H%M%SZ).rules 2>/dev/null
Step 2: identify your own SSH source so you don't lock yourself out¶
# Your current SSH client's IP, as seen by the host:
who am i | awk '{print $5}' | tr -d '()' || true
# Or from netstat:
sudo ss -tnp | grep ':22 ' | awk '{print $5}'
# Capture as variables for the rules below
export SSH_PORT=22
export SSH_ALLOW_FROM=<your-cidr-or-ip> # e.g. 203.0.113.42/32 or 198.51.100.0/24
Baseline ruleset — nft list ruleset (read-only inspection)¶
Before applying any change, know what's there. The MAID baseline
(installed by install.sh) looks like this:
table inet maid_baseline {
set ip_blocklist {
type ipv4_addr; flags interval; auto-merge
}
chain input {
type filter hook input priority filter; policy drop;
# Loopback always
iif lo accept
# Established / related
ct state established,related accept
# ICMP / ICMPv6 reachability
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
# SSH (from the configured admin allowlist)
tcp dport 22 ip saddr @ssh_allow accept
# MAID game protocols (telnet + websocket)
tcp dport { 4000, 8080 } ip saddr != @ip_blocklist accept
# Default: drop
}
chain output {
type filter hook output priority filter; policy accept;
}
chain forward {
type filter hook forward priority filter; policy drop;
}
}
If your nft list ruleset differs materially from the above, capture
it (step 1) before you change anything.
Fallback ruleset (STRICT) — drop all except SSH¶
Use this when you are under active attack and need everything off except your admin SSH session.
Before you run this, double-check
SSH_ALLOW_FROMis set to a CIDR that includes your current SSH client. Test on a non-prod box first if you have one.
nftables variant¶
# Write the file LOCALLY in the project dir, then apply atomically
cat > ./rb17-strict.nft <<EOF
flush ruleset
table inet maid_emergency {
chain input {
type filter hook input priority filter; policy drop;
# Loopback
iif lo accept
# Established / related
ct state established,related accept
# SSH from your admin source ONLY
tcp dport ${SSH_PORT} ip saddr ${SSH_ALLOW_FROM} accept
}
chain output {
type filter hook output priority filter; policy accept;
}
chain forward {
type filter hook forward priority filter; policy drop;
}
}
EOF
# Apply atomically. If the file has a syntax error, nft aborts and the
# previous ruleset stays in place. This is why we write to a file first.
sudo nft -f ./rb17-strict.nft
# Verify
sudo nft list ruleset
iptables variant (only if nftables is not available)¶
# 1. Allow loopback + established/related FIRST (before flushing)
sudo iptables -I INPUT 1 -i lo -j ACCEPT
sudo iptables -I INPUT 2 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# 2. Allow your SSH source explicitly
sudo iptables -I INPUT 3 -p tcp --dport ${SSH_PORT} -s ${SSH_ALLOW_FROM} -j ACCEPT
# 3. NOW set default policy to DROP
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
# 4. Flush only after rules 1-3 are in place
sudo iptables -F INPUT
# (re-add rules 1-3; flush wiped them)
sudo iptables -I INPUT 1 -i lo -j ACCEPT
sudo iptables -I INPUT 2 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -I INPUT 3 -p tcp --dport ${SSH_PORT} -s ${SSH_ALLOW_FROM} -j ACCEPT
# 5. Verify
sudo iptables -L -n -v
The iptables variant is hazardous. If you have nftables, use it.
Fallback ruleset (RELAXED) — block a specific abuser, keep game open¶
Use this when the attacker is a small set of IPs (DDoS that fits in a blocklist).
# Add a single IP to the existing ip_blocklist set
sudo nft add element inet maid_baseline ip_blocklist '{ 198.51.100.7 }'
# Or a CIDR
sudo nft add element inet maid_baseline ip_blocklist '{ 198.51.100.0/24 }'
# Verify
sudo nft list set inet maid_baseline ip_blocklist
iptables equivalent:
sudo iptables -I INPUT 1 -s 198.51.100.7 -j DROP
sudo iptables -I INPUT 1 -s 198.51.100.0/24 -j DROP
Recovery — restoring the baseline ruleset¶
There is no packaged install-baseline.sh helper yet — restore from
the snapshot you captured before applying the temporary ruleset, or
re-apply a host-local baseline via your distro's standard firewall
tooling.
# Option A — restore the snapshot taken in "First 5 minutes"
sudo nft flush ruleset
sudo nft -f ./pre-incident-nft-<TS>.nft
# iptables variant:
sudo iptables-restore < ./pre-incident-iptables-<TS>.rules
# Option B — if no snapshot is available, rebuild a known-safe baseline:
#
# nftables (Debian / Ubuntu / RHEL with nftables.conf shipped):
sudo nft flush ruleset
sudo nft -f /etc/nftables.conf # distro-managed baseline
sudo systemctl enable --now nftables
#
# iptables-restore (legacy hosts, baseline saved by netfilter-persistent):
sudo iptables-restore < /etc/iptables/rules.v4
sudo ip6tables-restore < /etc/iptables/rules.v6
#
# ufw (Ubuntu) — rebuild a minimal allow-ssh-only baseline:
sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 4000/tcp # MAID telnet (adjust per /etc/maid/<inst>.env)
sudo ufw allow 8080/tcp # MAID HTTP
sudo ufw --force enable
TODO(M1.4): ship a canonical
/opt/maid/current/packaging/firewall/install-baseline.shor equivalent so the operator does not have to hand-rebuild on top of the distro firewall. Until then the snapshot path above is the primary recovery route.
Verify:
sudo nft list ruleset | head -50
maid-admin doctor --phase runtime --instance <inst>
# Players should be reconnecting; check listener counts:
sudo ss -tnp | grep -E ':4000|:8080' | wc -l
Post-incident¶
- File ticket with: attack source(s), traffic pattern, which ruleset applied (strict / relaxed), duration of lockdown, downstream player impact.
- If strict ruleset was used, evaluate whether the application-layer rate limiter should be tuned so that strict-mode isn't needed for this class of attack next time.
- Consider whether the abusing IPs should be persisted in the
baseline
ip_blocklistset (edited via/etc/maid/firewall/blocklist.conf). - Update this runbook with any nft / iptables version quirks you hit.
Escalation¶
- Solo path: snapshot → apply chosen ruleset atomically → verify your SSH still works → wait out attack → restore baseline.
- Hosting console URL: see ./escalation-contacts.md.template.
- If you lock yourself out of SSH, you will need console access via the hosting provider to recover.
- DNS registrar URL: see ./escalation-contacts.md.template.
- Comms channel URL: see ./escalation-contacts.md.template.
- Peer operator: see ./escalation-contacts.md.template.
- If the attack is sustained > 30 min and exceeds your edge bandwidth, the firewall on the host is not enough. Engage your hosting provider's DDoS mitigation (URL in escalation-contacts) and consider rotating the host's public IP.