SIP 服务暴露到公网后,经常会收到持续的探测和批量扫描。如果业务只需要中国 IPv4 地址访问,可以在网络入口增加一层地址白名单,减少无关流量进入应用。

这类配置最需要小心的地方,是服务器上已经运行着 SSH、网站和多个 Docker 容器。为了限制两个端口而修改整个系统的默认入站策略,很容易影响原本正常的服务。

本文记录一种范围明确的做法:创建独立的 nftables 规则表,只检查从公网网卡进入、访问本机 506015060 的 TCP/UDP 流量,在 Docker 进行目标地址转换之前完成过滤。随后通过规则检查、对照连接、计数器和抓包验证效果,并保留自动回滚与手动撤销的方法。

一、确认配置范围

本文对应的验证环境为 Ubuntu 22.04、Linux 5.15、nftables 1.0.2,服务通过 Docker bridge 网络发布端口。

项目 本文配置
外部流量入口 eth0
限制端口 506015060
限制协议 TCP、UDP
IPv4 来源 仅允许指定中国地址集合
IPv6 拒绝从该入口访问这两个端口
SSH 与其他端口 保留原有规则和默认策略
容器内部、回环通信 不匹配这张表的入口条件
服务器主动连接的回包 保留

地址列表使用 IPdeny 的中国 IPv4 网段文件。它是 IPv4 CIDR 列表,因此本文不会把它当作 IPv6 白名单使用。

以下服务器命令在 root 的 Bash 会话中执行,可以先运行 sudo -i。测试客户端上的命令会单独标明。示例地址 203.0.113.10198.51.100.20 是文档占位地址,执行测试前需要替换。

1. 检查网卡、监听端口和现有规则

BASH
ip -brief address
ip route show default
ss -lntup '( sport = :22 or sport = :5060 or sport = :15060 )'

ufw status verbose
nft list tables
iptables -S
ip6tables -S

公网 IP 在云平台上可能先被转换为实例的私网地址,因此 ip address 中不一定能看到公网 IP。应确认实际接收外部流量的是哪张网卡;如果不是 eth0,后面的规则也要相应修改。

同时确认 Docker 发布的是哪些宿主机端口:

BASH
docker ps --format 'table {{.Names}}\t{{.Ports}}'

本文匹配的是宿主机对外发布的端口。例如映射为 15060:5060 时,入口规则应该匹配 15060

如果 UFW 的状态是 inactive,但配置文件中的默认入站策略为 DROP,不要为了添加这两个端口的限制而直接执行 ufw enable。另外,systemd 中的 UFW 单元显示 active,也不等于 UFW 已启用过滤,应以 ufw status 和实际规则为准。

2. 保存修改前的状态

BASH
CN_SIP_BACKUP="/root/firewall-backups/cn-sip-$(date +%Y%m%d-%H%M%S)"
install -d -m 700 "$CN_SIP_BACKUP"

nft -j list ruleset > "$CN_SIP_BACKUP/nft.before.json"
nft list ruleset > "$CN_SIP_BACKUP/nft.before.txt"
iptables-save > "$CN_SIP_BACKUP/iptables.before"
ip6tables-save > "$CN_SIP_BACKUP/ip6tables.before"
ss -lntu > "$CN_SIP_BACKUP/listeners.before"

保留这个终端及 CN_SIP_BACKUP 变量,后面会用它比较原有规则。再从另一台机器记录 SSH、已有网站和 SIP 端口的连通情况,作为上线后的对照。

下面按首次部署编写。如果已经存在 inet cn_sip_ports 表或同名配置文件,应先确认其用途,避免覆盖其他配置。

二、把过滤放在 Docker 地址转换之前

Docker 会为发布的容器端口创建地址转换和转发规则。只在宿主机的普通 INPUT 链添加限制,不一定能覆盖这些流量;Docker 官方也说明了发布端口流量与 UFW 常规入站过滤之间的关系。Docker 防火墙文档

这里选择在 prerouting 中创建一个独立的过滤链,优先级设为 -150。在本文环境中,它位于连接跟踪之后、Docker 常规 DNAT 的 -100 优先级之前,可以同时识别连接方向和原始发布端口。nftables 手册

flowchart TD
    A[外部数据包进入 eth0] --> B{目标为本机地址且为指定 TCP/UDP 端口?}
    B -->|否| C[继续原有网络规则]
    B -->|是| D{服务器主动连接的回包?}
    D -->|是| C
    D -->|否| E{IPv4 来源在中国地址集合内?}
    E -->|是| C
    E -->|否| F[计数并丢弃]
    C --> G[按原有规则交给宿主机或 Docker]

这张表的基础链使用 policy accept,表示它不会额外阻止范围外的流量。这不等于修改宿主机其他链的默认策略,后续已有的规则仍会继续处理数据包。

三、生成中国 IPv4 地址集合

先确认所需工具存在:

BASH
command -v nft python3 curl tcpdump

缺少工具时再安装对应软件包:

BASH
apt-get update
apt-get install -y nftables python3 curl tcpdump

安装工具与启用通用防火墙服务是两件事。本文使用后面创建的独立服务加载规则,不需要另外启用 nftables.service

1. 保存规则模板

BASH
install -d -m 755 /etc/nftables.d
install -d -m 700 /var/lib/cn-sip-firewall

cat > /etc/nftables.d/cn-sip-ports.nft.in <<'NFT'
table inet cn_sip_ports {
    set cn4 {
        type ipv4_addr
        flags interval
        auto-merge
        elements = { __CN_NETWORKS__ }
    }

    counter cn_ipv4_allowed { }
    counter noncn_ipv4_dropped { }
    counter ipv6_dropped { }
    counter outbound_replies { }

    chain cn_only {
        ct direction reply counter name outbound_replies return
        ip saddr @cn4 counter name cn_ipv4_allowed return
        meta nfproto ipv4 counter name noncn_ipv4_dropped drop
        meta nfproto ipv6 counter name ipv6_dropped drop
    }

    chain prerouting {
        type filter hook prerouting priority -150; policy accept;
        iifname "eth0" fib daddr type local meta l4proto { tcp, udp } th dport { 5060, 15060 } jump cn_only
    }
}
NFT

几处条件分别承担不同作用:

  • iifname "eth0":限定外部入口,避免影响回环和 Docker 内部接口。
  • fib daddr type local:只处理目标为本机地址的流量。
  • th dport { 5060, 15060 }:只匹配两个指定端口。
  • ct direction reply:保留服务器主动发起连接的回包,即使本地连接恰好使用这两个端口。
  • ip saddr @cn4 ... return:名单内流量继续走原有规则。
  • 最后两条 drop:分别统计并丢弃名单外 IPv4 和 IPv6。

这里没有用 ct state established accept 来整体跳过检查,否则之前已经建立的名单外入站连接也可能被直接放过。

2. 创建列表校验与生成脚本

不把下载内容直接拼进正在运行的防火墙。先解析 CIDR、检查数据规模,再生成候选文件:

BASH
cat > /usr/local/sbin/build-cn-sip-rules <<'PY'
#!/usr/bin/python3
import ipaddress
import pathlib
import sys

if len(sys.argv) != 3:
    raise SystemExit("usage: build-cn-sip-rules INPUT_ZONE OUTPUT_NFT")

source = pathlib.Path(sys.argv[1])
output = pathlib.Path(sys.argv[2])
lines = [line.strip() for line in source.read_text().splitlines() if line.strip()]
networks = [ipaddress.IPv4Network(line, strict=True) for line in lines]

# 针对完整 cn.zone 设置的完整性检查,不用于其他国家或聚合列表。
if not 1000 <= len(networks) <= 50000:
    raise SystemExit("Unexpected CN network count; keep the current rules")
if any(not network.is_global or network.prefixlen < 8 for network in networks):
    raise SystemExit("Unexpected network in CN source list")

merged = list(ipaddress.collapse_addresses(networks))
template = pathlib.Path("/etc/nftables.d/cn-sip-ports.nft.in").read_text()
if template.count("__CN_NETWORKS__") != 1:
    raise SystemExit("Invalid rule template")

elements = ",\n            ".join(map(str, merged))
output.write_text(template.replace("__CN_NETWORKS__", elements))
print(f"source networks: {len(networks)}, merged networks: {len(merged)}")
PY

chmod 750 /usr/local/sbin/build-cn-sip-rules

合并相邻或重叠网段不会扩大原有地址范围。本文一次下载得到 8811 条网段,等价合并后是 5513 条;这个数量会随数据源更新而变化。

3. 下载、检查,再安装候选文件

BASH
curl --fail --show-error --silent --location \
  --connect-timeout 10 --max-time 60 --retry 2 \
  https://www.ipdeny.com/ipblocks/data/countries/cn.zone \
  -o /var/lib/cn-sip-firewall/cn.zone.next

/usr/local/sbin/build-cn-sip-rules \
  /var/lib/cn-sip-firewall/cn.zone.next \
  /etc/nftables.d/cn-sip-ports.nft.next

nft -c -f /etc/nftables.d/cn-sip-ports.nft.next

nft -c 只检查,不应用规则。如果下载、解析或检查任一步失败,就停在这里,不继续安装。全部成功后执行:

BASH
install -m 640 /etc/nftables.d/cn-sip-ports.nft.next \
  /etc/nftables.d/cn-sip-ports.nft
mv /var/lib/cn-sip-firewall/cn.zone.next \
  /var/lib/cn-sip-firewall/cn.zone

到此为止,文件已经准备好,运行中的防火墙还没有变化。

四、只加载和撤销自己的规则表

不要使用 nft flush ruleset 清空系统规则,也不要把完整的历史 Docker 规则当作日常加载文件。Docker 会动态维护自己的链和地址转换状态。

下面的管理脚本只操作 inet cn_sip_ports。重载时,把“删除旧表”和“创建新表”放进同一次 nftables 事务提交,避免分成两个命令造成短暂的空档。nftables 原子替换说明

BASH
cat > /usr/local/sbin/cn-sip-firewall <<'PY'
#!/usr/bin/python3
import fcntl
import pathlib
import subprocess
import sys

NFT = "/usr/sbin/nft"
TABLE = ["inet", "cn_sip_ports"]
RULES = pathlib.Path("/etc/nftables.d/cn-sip-ports.nft")

def run(args, **kwargs):
    return subprocess.run([NFT, *args], check=True, **kwargs)

def main():
    action = sys.argv[1] if len(sys.argv) == 2 else ""
    if action not in ("apply", "check", "remove", "status"):
        raise SystemExit("usage: cn-sip-firewall apply|check|remove|status")

    with open("/run/lock/cn-sip-firewall.lock", "w") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        exists = subprocess.run(
            [NFT, "list", "table", *TABLE],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        ).returncode == 0

        if action == "status":
            run(["-t", "list", "table", *TABLE])
        elif action == "remove":
            if exists:
                run(["delete", "table", *TABLE])
        else:
            rules = RULES.read_text()
            if "flush ruleset" in rules:
                raise SystemExit("Refusing a global ruleset flush")
            transaction = ("delete table inet cn_sip_ports\n" if exists else "") + rules
            run(["-c", "-f", "-"], input=transaction, text=True)
            if action == "apply":
                run(["-f", "-"], input=transaction, text=True)

if __name__ == "__main__":
    main()
PY

chmod 750 /usr/local/sbin/cn-sip-firewall
/usr/local/sbin/cn-sip-firewall check

脚本的操作范围建立在前面这份受控模板上,因此配置目录和脚本应由 root 管理,不应允许普通用户写入任意 nftables 命令。

五、配置开机加载与临时自动回滚

1. 创建独立 systemd 服务

BASH
cat > /etc/systemd/system/cn-sip-firewall.service <<'UNIT'
[Unit]
Description=CN-only inbound TCP/UDP ports 5060 and 15060
DefaultDependencies=no
After=local-fs.target
Before=network-pre.target docker.service shutdown.target
Wants=network-pre.target
Conflicts=shutdown.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/cn-sip-firewall apply
ExecReload=/usr/local/sbin/cn-sip-firewall apply
ExecStop=/usr/local/sbin/cn-sip-firewall remove

[Install]
WantedBy=multi-user.target
UNIT

systemd-analyze verify /etc/systemd/system/cn-sip-firewall.service
systemctl daemon-reload

服务从本地文件读取规则,启动时不依赖在线下载名单。它只管理自己的表,也不需要重启 Docker。

2. 先安排回滚,再启用规则

在应用之前,安排一个五分钟后执行的撤销操作:

BASH
systemd-run --unit=cn-sip-firewall-rollback \
  --on-active=5m --timer-property=AccuracySec=1s \
  /usr/bin/systemctl disable --now cn-sip-firewall.service

systemctl is-active cn-sip-firewall-rollback.timer

确认定时器为 active 后,再启用服务:

BASH
systemctl enable --now cn-sip-firewall.service
systemctl status cn-sip-firewall.service --no-pager
/usr/local/sbin/cn-sip-firewall status

这个服务执行完加载操作就会退出进程,active (exited)oneshot 服务的正常状态。过滤由内核执行,不需要一个常驻用户态进程。

如果五分钟内没有完成验证,不要提前取消回滚。定时器触发后会停止服务、删除独立规则表,并取消该服务的开机启动。

六、先确认 SSH 和原有规则没有变化

1. 必须新建一次 SSH 连接

保留旧会话,另外打开终端连接服务器:

BASH
ssh -o ControlMaster=no -o ControlPath=none root@203.0.113.10

关闭 SSH 连接复用,是为了确认真正建立了新的连接。旧会话没有断开,并不足以证明新的 SSH 连接仍然可用。

然后复查原本已经开放的网站、管理接口和其他业务端口。对照测试只使用已有服务,不为测试额外开放端口。

2. 比较原有 nftables 规则

下面的脚本过滤掉本次新增表以及会自然变化的计数器和 handle,再与修改前的快照比较:

BASH
python3 - "$CN_SIP_BACKUP/nft.before.json" <<'PY'
import json
import pathlib
import subprocess
import sys

def normalize(value):
    if isinstance(value, list):
        return [normalize(item) for item in value]
    if isinstance(value, dict):
        return {
            key: normalize(item)
            for key, item in value.items()
            if key not in ("handle", "packets", "bytes")
        }
    return value

def original_objects(data):
    result = []
    for item in data["nftables"]:
        if "metainfo" in item:
            continue
        value = next(iter(item.values()))
        own = value.get("family") == "inet" and (
            value.get("table") == "cn_sip_ports"
            or ("table" in item and value.get("name") == "cn_sip_ports")
        )
        if not own:
            result.append(item)
    return normalize(result)

before = json.loads(pathlib.Path(sys.argv[1]).read_text())
after = json.loads(subprocess.check_output(["nft", "-j", "list", "ruleset"], text=True))
if original_objects(before) != original_objects(after):
    raise SystemExit("Existing rules changed; inspect the difference before continuing")
print("PASS: original nftables rules are unchanged")
PY

比较期间如果有其他程序或管理员修改规则,结果也可能不同,需要查明原因。本文环境中的 Docker 规则使用 iptables-nft 后端,可以同时保留并检查 iptables-saveip6tables-save 的前后输出。

七、验证白名单确实在过滤

验证分成三个层次:规则是否加载、包是否命中、命中后是否被转发。仅看配置文件,或者只看一次连接超时,都不完整。

1. 查看规则和计数器

BASH
nft list chain inet cn_sip_ports prerouting
nft list chain inet cn_sip_ports cn_only
nft list counters table inet cn_sip_ports

实时观察:

BASH
watch -n 2 'nft list counters table inet cn_sip_ports'
计数器 含义
cn_ipv4_allowed 匹配中国 IPv4 名单、继续交给原有规则的包
noncn_ipv4_dropped 在这两个端口被丢弃的名单外 IPv4 包
ipv6_dropped 在这两个端口被丢弃的 IPv6 包
outbound_replies 因为属于服务器主动连接的回包而保留的包

例如一次 15 秒采样中,noncn_ipv4_dropped5025 增加到 5450,表示这段时间丢弃了 425 个包,平均约每秒 28 个。这里统计的是数据包,不能直接解释成独立连接数、来源 IP 数或攻击次数。

这些计数器合并统计两个端口及 TCP/UDP。要区分具体端口和来源,可以配合下一节的抓包。

2. 用名单内、名单外的来源做对照

准备来源位于名单内和名单外的测试环境,先在服务器上确认实际来源地址是否包含在集合中:

BASH
# 替换为服务器实际看到的测试来源地址。
TEST_SOURCE_IP=198.51.100.20
nft get element inet cn_sip_ports cn4 "{ $TEST_SOURCE_IP }"

找到元素表示地址在集合中;没有找到则应按名单外来源测试。判定依据是服务器收到的数据包来源地址,不能只根据客户端的本地网卡地址或所在地推断。

在测试客户端上分别连接 TCP 端口:

BASH
SIP_SERVER_IP=203.0.113.10

for port in 22 443 5060 15060; do
  nc -vz -w 5 "$SIP_SERVER_IP" "$port"
done

其中 22443 仅在服务器原本开放相应服务时用作对照。预期结果如下:

来源 5060/15060 TCP 原本可用的 SSH、HTTPS
中国名单内 不受本次规则阻止 保持原状
中国名单外 被丢弃,通常表现为超时 保持原状

白名单规则中的 return 不会替代后续规则或应用程序。名单内连接失败时,仍应检查原有防火墙、云端网络策略和服务是否正常监听。

3. UDP 必须结合服务端观察

UDP 没有 TCP 那样的连接握手,客户端“发送成功”只说明数据交给了本地网络栈,不说明对端应用收到。

在测试客户端发送少量探测包:

BASH
python3 - <<'PY'
import socket
import time

server = "203.0.113.10"  # 替换为服务器公网 IP
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
    for port in (5060, 15060):
        for _ in range(3):
            client.sendto(b"\r\n", (server, port))
            time.sleep(0.1)
        print(f"sent 3 UDP probes to {server}:{port}")
PY

这些包只用于观察网络路径,不发起注册或呼叫。应用也可能不回应这种探测内容,因此不能用“没有响应”单独判断防火墙结果。

发送前,在服务器上启动抓包:

BASH
TEST_SOURCE_IP=198.51.100.20  # 替换为实际测试来源
timeout 20 tcpdump -ni any -q -s 96 \
  "src host $TEST_SOURCE_IP and (tcp or udp)"

这里使用 any,便于同时观察外部网卡和 Docker bridge。过滤条件没有固定目的端口,以免 DNAT 改变容器侧端口后,把转发包排除在抓包范围之外。

对名单外来源,应同时确认:

  1. 目标流量确实到达 eth0
  2. noncn_ipv4_dropped 增长。
  3. 对应测试包没有继续出现在 Docker bridge 和容器接口上。

对名单内来源,则应看到放行计数增长,并在服务正常、原有规则允许的前提下继续进入容器。

本文实际验证中,向 5060/UDP15060/UDP 各发送的 3 个名单外探测包都到达了外部网卡,但没有进入 Docker;名单内 TCP 流量可以继续进入容器。TCP/UDP 的名单匹配、IPv6、其他端口、内部接口和出站回包,也在独立网络命名空间中做过隔离验证。隔离验证与真实网络验证是两层证据,不能相互代替。

如果公网网卡根本没有收到测试包,只能说明问题发生在服务器之前,不能据此认定是本机 nftables 拦截。抓包结束时显示的 packets dropped by kernel 是抓包过程的丢包统计,也不是这张防火墙表的拦截数。

八、验证完成后取消临时回滚

确认新 SSH 连接正常、原有规则未变、目标端口过滤符合预期,再取消定时器:

BASH
systemctl stop cn-sip-firewall-rollback.timer
systemctl is-active cn-sip-firewall-rollback.timer
systemctl is-active cn-sip-firewall-rollback.service

systemctl is-active cn-sip-firewall.service
systemctl is-enabled cn-sip-firewall.service

前两个回滚单元应处于非活动状态,防火墙服务应为 activeenabled。如果定时器已经触发,需先确认规则是否已撤销,再重新安排回滚和应用,不能仅停止定时器就认为规则仍在。

enabled 表示开机加载关系已经建立,不等于做过实际重启验证。需要验证重启行为时,应在合适的维护窗口重启,再重新检查服务、规则和新 SSH 连接。

九、更新名单与撤销规则

1. 手动更新名单

本文默认使用下载时的名单快照,不额外安排自动更新。更新时继续采用“下载候选文件、生成、原子重载”的过程:

BASH
cat > /usr/local/sbin/update-cn-sip-firewall <<'SH'
#!/bin/bash
set -euo pipefail

exec 9>/run/lock/cn-sip-update.lock
flock -x 9

rules=/etc/nftables.d/cn-sip-ports.nft
zone=/var/lib/cn-sip-firewall/cn.zone

curl --fail --show-error --silent --location \
  --connect-timeout 10 --max-time 60 --retry 2 \
  https://www.ipdeny.com/ipblocks/data/countries/cn.zone \
  -o "$zone.next"

/usr/local/sbin/build-cn-sip-rules "$zone.next" "$rules.next"
cp -a "$rules" "$rules.previous"
chmod 640 "$rules.next"
mv -f "$rules.next" "$rules"

if systemctl reload cn-sip-firewall.service; then
  mv "$zone.next" "$zone"
else
  cp -a "$rules.previous" "$rules.restore"
  mv -f "$rules.restore" "$rules"
  echo "Reload failed; restored saved configuration" >&2
  exit 1
fi
SH

chmod 750 /usr/local/sbin/update-cn-sip-firewall

需要更新时执行:

BASH
/usr/local/sbin/update-cn-sip-firewall
/usr/local/sbin/cn-sip-firewall status

管理脚本会先验证完整事务,再提交到内核。下载或 CIDR 校验失败时不会替换当前配置;nftables 校验或事务提交失败时,原有运行规则保持不变,更新脚本会恢复磁盘上的旧配置。

重载会重新创建这张表,计数器也会重新开始计数。 如果要保留长期统计,应在重载前记录计数,或者另外接入监控系统。

2. 撤销本次限制

正常撤销只需要:

BASH
systemctl disable --now cn-sip-firewall.service

这会取消开机启动,并通过 ExecStop 删除 inet cn_sip_ports。若服务状态异常而规则仍在,可直接清理自己的表:

BASH
/usr/local/sbin/cn-sip-firewall remove

最后检查:

BASH
nft list tables
systemctl is-enabled cn-sip-firewall.service

无需恢复整份历史 Docker 规则,也无需重启 Docker。

结语

这套配置把影响范围限制在一张独立规则表、一张外部网卡和两个指定端口上。中国 IPv4 地址通过集合统一维护,名单外流量在 Docker 地址转换前被丢弃,SSH、其他服务与容器内部通信继续使用原有规则。

真正完成配置,需要同时回答三个问题:规则有没有加载、测试包有没有到达、到达后有没有继续进入容器。新 SSH 连接、原有规则对比、计数器和抓包共同构成了验证过程,自动回滚则为远程操作保留了恢复路径。

地址白名单只能按名单中的网络范围过滤,无法判断名单内请求是否正常,也不能替代 SIP 服务本身的认证。后续维护时保留这一边界,定期检查名单与拦截计数,就能知道规则仍在做什么,以及哪些流量仍需要由应用处理。