之所以写这篇文章是因为服务器被境外IP扫描登录,虽然没有攻破,看着海量的错误日志也心烦,所以做了一个防止境外IP登陆的防护方案,这里分享出来给需要的人。 文章中的内容来自于AI生成,实际测试可以使用,不过资源有限没有经过完整性测试(全球IP测试)。
查看 SSH 当前监听端口
sudo ss -lntp | grep sshd找到0.0.0.0:和[::]:后面的数字,就是sshd监听的端口号了,留着备用,这里假设端口号是22222。
修改/etc/nftables.conf文件
默认的/etc/nftables.conf一般是
#!/usr/sbin/nft -f
flush ruleset
table inet filter { chain input { type filter hook input priority filter; }
chain forward { type filter hook forward priority filter; }
chain output { type filter hook output priority filter; }}其中flush ruleset这条指令会清空所有规则。
如果服务器使用了docker,会导致把docker的规则清理掉,docker不能正常使用(如果已经影响了可以重启docker服务)
默认的table inet filter也没有用,建议直接改成以下内容
#!/usr/sbin/nft -f
include "/etc/nftables.d/*.nft"创建/etc/nftables.d/ssh-geo.nft
# 如果没有/etc/nftables.d/目录使用以下命令创建,如果存在可以跳过sudo mkdir -p /etc/nftables.d/sets/最终的文件架构如下
/etc/nftables.d/├── ssh-geo.nft└── sets/ ├── cn4.nft └── cn6.nft/etc/nftables.d/ssh-geo.nft内容
destroy table inet ssh_geotable inet ssh_geo { include "/etc/nftables.d/sets/cn4.nft" include "/etc/nftables.d/sets/cn6.nft"
chain input { type filter hook input priority -10; policy accept;
# 22222需要替换为真实的sshd端口号 tcp dport 22222 ip saddr @cn4 accept tcp dport 22222 ip6 saddr @cn6 accept tcp dport 22222 drop }}动态生成cn4.nft、cn6.nft
使用脚本执行
“下载 → 校验 CIDR → 生成临时 nft 文件 → nft -c 检查 → 原子替换 → 重载 ssh_geo”的流程。
这样即使网络返回 HTML 错误页、下载中断或数据格式异常,也不会把正在工作的 SSH 白名单破坏掉。
IPdeny 目前确实提供中国大陆聚合后的 IPv4 和 IPv6 CIDR 文件,并持续更新:IPv4 为 cn-aggregated.zone,IPv6 也有对应 aggregated 文件。(IPdeny)
自动更新脚本
创建:
sudo nano /usr/local/sbin/update-cn-nft.sh内容:
#!/usr/bin/env bash
set -Eeuo pipefail
CN4_URL="https://www.ipdeny.com/ipblocks/data/aggregated/cn-aggregated.zone"CN6_URL="https://www.ipdeny.com/ipv6/ipaddresses/aggregated/cn-aggregated.zone"
TARGET_DIR="/etc/nftables.d/sets"CN4_TARGET="${TARGET_DIR}/cn4.nft"CN6_TARGET="${TARGET_DIR}/cn6.nft"
SSH_GEO_CONFIG="/etc/nftables.d/ssh-geo.nft"
TMP_DIR="$(mktemp -d)"
cleanup(){ rm -rf "${TMP_DIR}"}
trap cleanup EXIT
log(){ printf '[%s] %s\n' "$(date '+%F %T')" "$*"}
fail(){ printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2 exit 1}
if [[ "${EUID}" -ne 0 ]]; then fail "必须使用 root 权限运行"fi
for cmd in curl python3 nft install; do if ! command -v "${cmd}" >/dev/null 2>&1; then fail "缺少命令: ${cmd}" fidone
mkdir -p "${TARGET_DIR}"
CN4_RAW="${TMP_DIR}/cn4.zone"CN6_RAW="${TMP_DIR}/cn6.zone"
CN4_NEW="${TMP_DIR}/cn4.nft"CN6_NEW="${TMP_DIR}/cn6.nft"
download(){ local url="$1" local output="$2"
log "下载 ${url}"
curl \ --fail \ --silent \ --show-error \ --location \ --retry 3 \ --retry-delay 2 \ --connect-timeout 10 \ --max-time 120 \ --output "${output}" \ "${url}"
if [[ ! -s "${output}" ]]; then fail "下载结果为空: ${url}" fi
# 防止 CRLF 干扰后面的处理 sed -i 's/\r$//' "${output}"}
validate_cidr_file(){ local family="$1" local file="$2"
python3 - "${family}" "${file}" <<'PY'import ipaddressimport sys
family = int(sys.argv[1])path = sys.argv[2]
count = 0
with open(path, "r", encoding="utf-8") as f: for line_no, line in enumerate(f, 1): value = line.strip()
if not value or value.startswith("#"): continue
try: network = ipaddress.ip_network(value, strict=True) except ValueError as ex: print( f"无效 CIDR: {path}:{line_no}: {value}: {ex}", file=sys.stderr, ) sys.exit(1)
if network.version != family: print( f"IP 版本错误: {path}:{line_no}: {value}", file=sys.stderr, ) sys.exit(1)
count += 1
if count < 10: print( f"CIDR 数量异常,仅发现 {count} 条: {path}", file=sys.stderr, ) sys.exit(1)
print(count)PY}
generate_set(){ local set_name="$1" local address_type="$2" local source="$3" local output="$4"
{ echo "set ${set_name} {" echo " type ${address_type}" echo " flags interval" echo " auto-merge" echo echo " elements = {"
awk ' NF && $1 !~ /^#/ { if (count > 0) { printf ",\n" }
printf " %s", $1 count++ }
END { if (count > 0) { printf "\n" } } ' "${source}"
echo " }" echo "}" } > "${output}"}
log "开始更新中国大陆 IPv4 / IPv6 CIDR"
download "${CN4_URL}" "${CN4_RAW}"download "${CN6_URL}" "${CN6_RAW}"
log "校验 IPv4 CIDR"CN4_COUNT="$(validate_cidr_file 4 "${CN4_RAW}")"
log "校验 IPv6 CIDR"CN6_COUNT="$(validate_cidr_file 6 "${CN6_RAW}")"
log "IPv4: ${CN4_COUNT} 条"log "IPv6: ${CN6_COUNT} 条"
generate_set "cn4" "ipv4_addr" "${CN4_RAW}" "${CN4_NEW}"generate_set "cn6" "ipv6_addr" "${CN6_RAW}" "${CN6_NEW}"
## 先对生成出来的两个 set 进行独立 nftables 语法检查。# 此处只执行 -c,不会改变实际防火墙。#
CHECK_TABLE="cn_geo_check_$$"CHECK_FILE="${TMP_DIR}/check.nft"
cat > "${CHECK_FILE}" <<EOFtable inet ${CHECK_TABLE} { include "${CN4_NEW}" include "${CN6_NEW}"}EOF
log "检查生成的 nftables set"
if ! nft -c -f "${CHECK_FILE}"; then fail "新 CIDR 文件无法通过 nftables 语法检查"fi
## 如果内容完全没有变化,则无需重载。#
UNCHANGED=true
if [[ ! -f "${CN4_TARGET}" ]] || ! cmp -s "${CN4_NEW}" "${CN4_TARGET}"; then UNCHANGED=falsefi
if [[ ! -f "${CN6_TARGET}" ]] || ! cmp -s "${CN6_NEW}" "${CN6_TARGET}"; then UNCHANGED=falsefi
if [[ "${UNCHANGED}" == "true" ]]; then log "CIDR 列表没有变化,无需更新" exit 0fi
## 备份当前配置,以便后续加载失败时恢复文件。#
if [[ -f "${CN4_TARGET}" ]]; then cp -a "${CN4_TARGET}" "${TMP_DIR}/cn4.old.nft"fi
if [[ -f "${CN6_TARGET}" ]]; then cp -a "${CN6_TARGET}" "${TMP_DIR}/cn6.old.nft"fi
## 使用 install + 临时文件,避免直接覆盖过程中产生半截文件。#
install -m 0644 "${CN4_NEW}" "${CN4_TARGET}.new"install -m 0644 "${CN6_NEW}" "${CN6_TARGET}.new"
mv -f "${CN4_TARGET}.new" "${CN4_TARGET}"mv -f "${CN6_TARGET}.new" "${CN6_TARGET}"
## 此时再检查真正的 ssh_geo 配置。# nft -c 不会改变当前运行规则。#
log "检查 ssh_geo 完整配置"
if ! nft -c -f "${SSH_GEO_CONFIG}"; then log "完整配置检查失败,恢复旧 CIDR 文件"
if [[ -f "${TMP_DIR}/cn4.old.nft" ]]; then cp -a "${TMP_DIR}/cn4.old.nft" "${CN4_TARGET}" fi
if [[ -f "${TMP_DIR}/cn6.old.nft" ]]; then cp -a "${TMP_DIR}/cn6.old.nft" "${CN6_TARGET}" fi
fail "ssh_geo 配置验证失败,没有修改运行中的防火墙"fi
## 只重新加载 ssh_geo,而不是整个 /etc/nftables.conf。## 你的 ssh-geo.nft 前面应包含:## destroy table inet ssh_geo## 因此这里会原子地重建自己的 table,# 不会碰 Docker 的 nftables/iptables-nft 规则。#
log "应用新的 ssh_geo 规则"
if ! nft -f "${SSH_GEO_CONFIG}"; then log "加载失败,恢复旧 CIDR 文件"
if [[ -f "${TMP_DIR}/cn4.old.nft" ]]; then cp -a "${TMP_DIR}/cn4.old.nft" "${CN4_TARGET}" fi
if [[ -f "${TMP_DIR}/cn6.old.nft" ]]; then cp -a "${TMP_DIR}/cn6.old.nft" "${CN6_TARGET}" fi
fail "nftables 加载失败;运行中的旧规则没有被事务修改"fi
log "更新完成"log "IPv4: ${CN4_COUNT} 条"log "IPv6: ${CN6_COUNT} 条"然后:
sudo chmod 755 /usr/local/sbin/update-cn-nft.sh第一次先手工运行:
sudo /usr/local/sbin/update-cn-nft.sh正常应该看到类似:
[2026-09-17 22:40:01] 开始更新中国大陆 IPv4 / IPv6 CIDR[2026-09-17 22:40:01] 下载 ...[2026-09-17 22:40:02] 校验 IPv4 CIDR[2026-09-17 22:40:02] 校验 IPv6 CIDR[2026-09-17 22:40:02] IPv4: xxxx 条[2026-09-17 22:40:02] IPv6: xxxx 条[2026-09-17 22:40:03] 检查生成的 nftables set[2026-09-17 22:40:03] 检查 ssh_geo 完整配置[2026-09-17 22:40:03] 应用新的 ssh_geo 规则[2026-09-17 22:40:03] 更新完成生成的文件就如下格式:
set cn4 { type ipv4_addr flags interval auto-merge
elements = { 1.0.1.0/24, 1.0.2.0/23, 1.0.8.0/21 ... }}以及:
set cn6 { type ipv6_addr flags interval auto-merge
elements = { 2001:250::/32, ... }}脚本只执行:
nft -f /etc/nftables.d/ssh-geo.nft不会执行:
nft -f /etc/nftables.conf更不会执行:
flush ruleset验证ssh_geo结果
sudo nft list table inet ssh_geo应该会列出类似以下结果
table inet ssh_geo { set cn4 { type ipv4_addr flags interval auto-merge
elements = { 1.0.1.0/24, 1.0.2.0/23, 1.0.8.0/21 ... } }
set cn6 { type ipv6_addr flags interval auto-merge
elements = { 2001:250::/32, ... } }
chain input { type filter hook input priority -10; policy accept;
# 22222需要替换为真实的sshd端口号 tcp dport 22222 ip saddr @cn4 accept tcp dport 22222 ip6 saddr @cn6 accept tcp dport 22222 drop }}所以不会主动动到 Docker 动态创建的防火墙规则。
做成每日自动更新
既然 IPdeny 的数据本身持续更新,比较适合每天更新一次。当前目录显示 IPv4 和 IPv6 中国大陆 aggregated 数据都有近期每日更新时间。(IPdeny)
创建:
sudo nano /etc/systemd/system/update-cn-nft.service[Unit]Description=Update China CIDR nftables setsAfter=network-online.targetWants=network-online.target
[Service]Type=oneshotExecStart=/usr/local/sbin/update-cn-nft.sh再创建:
sudo nano /etc/systemd/system/update-cn-nft.timer[Unit]Description=Daily China CIDR nftables update
[Timer]OnCalendar=*-*-* 04:30:00RandomizedDelaySec=30mPersistent=true
[Install]WantedBy=timers.target启用:
sudo systemctl daemon-reload
sudo systemctl enable --now update-cn-nft.timer查看:
systemctl list-timers update-cn-nft.timer手动测试 service:
sudo systemctl start update-cn-nft.service查看执行日志:
sudo journalctl -u update-cn-nft.service这里还有一个我建议你在正式长期运行前补上的安全措施:至少准备一个不依赖 GeoIP 的“救援 SSH 来源”,比如固定办公 IP、VPN/Tailscale 网段或云厂商控制台。国家 IP 数据库并不是绝对准确的,一旦你的正常公网 IP 被数据源暂时归错区域,cn4/cn6 自动更新后,新 SSH 连接就可能被拒绝。
如果真的发生了这种情况也不用着急,可以到云服务控制台上使用VNC登录,然后把自己的IP加入白名单中。