1966 字
10 分钟
Ubuntu配置sshd防止境外IP登陆

之所以写这篇文章是因为服务器被境外IP扫描登录,虽然没有攻破,看着海量的错误日志也心烦,所以做了一个防止境外IP登陆的防护方案,这里分享出来给需要的人。 文章中的内容来自于AI生成,实际测试可以使用,不过资源有限没有经过完整性测试(全球IP测试)。

查看 SSH 当前监听端口#

Terminal window
sudo ss -lntp | grep sshd

找到0.0.0.0:[::]:后面的数字,就是sshd监听的端口号了,留着备用,这里假设端口号是22222

修改/etc/nftables.conf文件#

默认的/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也没有用,建议直接改成以下内容

/etc/nftables.conf
#!/usr/sbin/nft -f
include "/etc/nftables.d/*.nft"

创建/etc/nftables.d/ssh-geo.nft#

Terminal window
# 如果没有/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内容

/etc/nftables.d/ssh-geo.nft
destroy table inet ssh_geo
table 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.nftcn6.nft#

使用脚本执行 “下载 → 校验 CIDR → 生成临时 nft 文件 → nft -c 检查 → 原子替换 → 重载 ssh_geo”的流程。 这样即使网络返回 HTML 错误页、下载中断或数据格式异常,也不会把正在工作的 SSH 白名单破坏掉。

IPdeny 目前确实提供中国大陆聚合后的 IPv4 和 IPv6 CIDR 文件,并持续更新:IPv4 为 cn-aggregated.zone,IPv6 也有对应 aggregated 文件。(IPdeny)

自动更新脚本#

创建:

Terminal window
sudo nano /usr/local/sbin/update-cn-nft.sh

内容:

/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}"
fi
done
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 ipaddress
import 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}" <<EOF
table 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=false
fi
if [[ ! -f "${CN6_TARGET}" ]] || ! cmp -s "${CN6_NEW}" "${CN6_TARGET}"; then
UNCHANGED=false
fi
if [[ "${UNCHANGED}" == "true" ]]; then
log "CIDR 列表没有变化,无需更新"
exit 0
fi
#
# 备份当前配置,以便后续加载失败时恢复文件。
#
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} 条"

然后:

Terminal window
sudo chmod 755 /usr/local/sbin/update-cn-nft.sh

第一次先手工运行:

Terminal window
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] 更新完成

生成的文件就如下格式:

/etc/nftables.d/sets/cn4.nft
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
...
}
}

以及:

/etc/nftables.d/sets/cn6.nft
set cn6 {
type ipv6_addr
flags interval
auto-merge
elements = {
2001:250::/32,
...
}
}

脚本只执行:

Terminal window
nft -f /etc/nftables.d/ssh-geo.nft

不会执行:

Terminal window
nft -f /etc/nftables.conf

更不会执行:

flush ruleset

验证ssh_geo结果#

Terminal window
sudo nft list table inet ssh_geo

应该会列出类似以下结果

Terminal window
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)

创建:

Terminal window
sudo nano /etc/systemd/system/update-cn-nft.service
/etc/systemd/system/update-cn-nft.service
[Unit]
Description=Update China CIDR nftables sets
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/update-cn-nft.sh

再创建:

Terminal window
sudo nano /etc/systemd/system/update-cn-nft.timer
/etc/systemd/system/update-cn-nft.timer
[Unit]
Description=Daily China CIDR nftables update
[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target

启用:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now update-cn-nft.timer

查看:

Terminal window
systemctl list-timers update-cn-nft.timer

手动测试 service:

Terminal window
sudo systemctl start update-cn-nft.service

查看执行日志:

Terminal window
sudo journalctl -u update-cn-nft.service

这里还有一个我建议你在正式长期运行前补上的安全措施:至少准备一个不依赖 GeoIP 的“救援 SSH 来源”,比如固定办公 IP、VPN/Tailscale 网段或云厂商控制台。国家 IP 数据库并不是绝对准确的,一旦你的正常公网 IP 被数据源暂时归错区域,cn4/cn6 自动更新后,新 SSH 连接就可能被拒绝。

如果真的发生了这种情况也不用着急,可以到云服务控制台上使用VNC登录,然后把自己的IP加入白名单中。

Ubuntu配置sshd防止境外IP登陆
https://fuwari.vercel.app/posts/linux/ubuntu配置sshd防止境外ip登陆/
作者
wvfeng
发布于
2026-09-17
许可协议
CC BY-NC-SA 4.0