SIP 服务暴露到公网后,经常会收到持续的探测和批量扫描。如果业务只需要中国 IPv4 地址访问,可以在网络入口增加一层地址白名单,减少无关流量进入应用。
这类配置最需要小心的地方,是服务器上已经运行着 SSH、网站和多个 Docker 容器。为了限制两个端口而修改整个系统的默认入站策略,很容易影响原本正常的服务。
本文记录一种范围明确的做法:创建独立的 nftables 规则表,只检查从公网网卡进入、访问本机 5060 和 15060 的 TCP/UDP 流量,在 Docker 进行目标地址转换之前完成过滤。随后通过规则检查、对照连接、计数器和抓包验证效果,并保留自动回滚与手动撤销的方法。
一、确认配置范围
本文对应的验证环境为 Ubuntu 22.04、Linux 5.15、nftables 1.0.2,服务通过 Docker bridge 网络发布端口。
| 项目 | 本文配置 |
|---|---|
| 外部流量入口 | eth0 |
| 限制端口 | 5060、15060 |
| 限制协议 | TCP、UDP |
| IPv4 来源 | 仅允许指定中国地址集合 |
| IPv6 | 拒绝从该入口访问这两个端口 |
| SSH 与其他端口 | 保留原有规则和默认策略 |
| 容器内部、回环通信 | 不匹配这张表的入口条件 |
| 服务器主动连接的回包 | 保留 |
地址列表使用 IPdeny 的中国 IPv4 网段文件。它是 IPv4 CIDR 列表,因此本文不会把它当作 IPv6 白名单使用。
以下服务器命令在 root 的 Bash 会话中执行,可以先运行 sudo -i。测试客户端上的命令会单独标明。示例地址 203.0.113.10、198.51.100.20 是文档占位地址,执行测试前需要替换。
1. 检查网卡、监听端口和现有规则
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 发布的是哪些宿主机端口:
docker ps --format 'table {{.Names}}\t{{.Ports}}'本文匹配的是宿主机对外发布的端口。例如映射为 15060:5060 时,入口规则应该匹配 15060。
如果 UFW 的状态是 inactive,但配置文件中的默认入站策略为 DROP,不要为了添加这两个端口的限制而直接执行 ufw enable。另外,systemd 中的 UFW 单元显示 active,也不等于 UFW 已启用过滤,应以 ufw status 和实际规则为准。
2. 保存修改前的状态
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 地址集合
先确认所需工具存在:
command -v nft python3 curl tcpdump缺少工具时再安装对应软件包:
apt-get update
apt-get install -y nftables python3 curl tcpdump安装工具与启用通用防火墙服务是两件事。本文使用后面创建的独立服务加载规则,不需要另外启用 nftables.service。
1. 保存规则模板
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、检查数据规模,再生成候选文件:
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. 下载、检查,再安装候选文件
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.nextnft -c 只检查,不应用规则。如果下载、解析或检查任一步失败,就停在这里,不继续安装。全部成功后执行:
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 原子替换说明
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 服务
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. 先安排回滚,再启用规则
在应用之前,安排一个五分钟后执行的撤销操作:
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 后,再启用服务:
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 连接
保留旧会话,另外打开终端连接服务器:
ssh -o ControlMaster=no -o ControlPath=none root@203.0.113.10关闭 SSH 连接复用,是为了确认真正建立了新的连接。旧会话没有断开,并不足以证明新的 SSH 连接仍然可用。
然后复查原本已经开放的网站、管理接口和其他业务端口。对照测试只使用已有服务,不为测试额外开放端口。
2. 比较原有 nftables 规则
下面的脚本过滤掉本次新增表以及会自然变化的计数器和 handle,再与修改前的快照比较:
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-save、ip6tables-save 的前后输出。
七、验证白名单确实在过滤
验证分成三个层次:规则是否加载、包是否命中、命中后是否被转发。仅看配置文件,或者只看一次连接超时,都不完整。
1. 查看规则和计数器
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实时观察:
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_dropped 从 5025 增加到 5450,表示这段时间丢弃了 425 个包,平均约每秒 28 个。这里统计的是数据包,不能直接解释成独立连接数、来源 IP 数或攻击次数。
这些计数器合并统计两个端口及 TCP/UDP。要区分具体端口和来源,可以配合下一节的抓包。
2. 用名单内、名单外的来源做对照
准备来源位于名单内和名单外的测试环境,先在服务器上确认实际来源地址是否包含在集合中:
# 替换为服务器实际看到的测试来源地址。
TEST_SOURCE_IP=198.51.100.20
nft get element inet cn_sip_ports cn4 "{ $TEST_SOURCE_IP }"找到元素表示地址在集合中;没有找到则应按名单外来源测试。判定依据是服务器收到的数据包来源地址,不能只根据客户端的本地网卡地址或所在地推断。
在测试客户端上分别连接 TCP 端口:
SIP_SERVER_IP=203.0.113.10
for port in 22 443 5060 15060; do
nc -vz -w 5 "$SIP_SERVER_IP" "$port"
done其中 22、443 仅在服务器原本开放相应服务时用作对照。预期结果如下:
| 来源 | 5060/15060 TCP | 原本可用的 SSH、HTTPS |
|---|---|---|
| 中国名单内 | 不受本次规则阻止 | 保持原状 |
| 中国名单外 | 被丢弃,通常表现为超时 | 保持原状 |
白名单规则中的 return 不会替代后续规则或应用程序。名单内连接失败时,仍应检查原有防火墙、云端网络策略和服务是否正常监听。
3. UDP 必须结合服务端观察
UDP 没有 TCP 那样的连接握手,客户端“发送成功”只说明数据交给了本地网络栈,不说明对端应用收到。
在测试客户端发送少量探测包:
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这些包只用于观察网络路径,不发起注册或呼叫。应用也可能不回应这种探测内容,因此不能用“没有响应”单独判断防火墙结果。
发送前,在服务器上启动抓包:
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 改变容器侧端口后,把转发包排除在抓包范围之外。
对名单外来源,应同时确认:
- 目标流量确实到达
eth0。 noncn_ipv4_dropped增长。- 对应测试包没有继续出现在 Docker bridge 和容器接口上。
对名单内来源,则应看到放行计数增长,并在服务正常、原有规则允许的前提下继续进入容器。
本文实际验证中,向 5060/UDP、15060/UDP 各发送的 3 个名单外探测包都到达了外部网卡,但没有进入 Docker;名单内 TCP 流量可以继续进入容器。TCP/UDP 的名单匹配、IPv6、其他端口、内部接口和出站回包,也在独立网络命名空间中做过隔离验证。隔离验证与真实网络验证是两层证据,不能相互代替。
如果公网网卡根本没有收到测试包,只能说明问题发生在服务器之前,不能据此认定是本机 nftables 拦截。抓包结束时显示的 packets dropped by kernel 是抓包过程的丢包统计,也不是这张防火墙表的拦截数。
八、验证完成后取消临时回滚
确认新 SSH 连接正常、原有规则未变、目标端口过滤符合预期,再取消定时器:
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前两个回滚单元应处于非活动状态,防火墙服务应为 active、enabled。如果定时器已经触发,需先确认规则是否已撤销,再重新安排回滚和应用,不能仅停止定时器就认为规则仍在。
enabled 表示开机加载关系已经建立,不等于做过实际重启验证。需要验证重启行为时,应在合适的维护窗口重启,再重新检查服务、规则和新 SSH 连接。
九、更新名单与撤销规则
1. 手动更新名单
本文默认使用下载时的名单快照,不额外安排自动更新。更新时继续采用“下载候选文件、生成、原子重载”的过程:
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需要更新时执行:
/usr/local/sbin/update-cn-sip-firewall
/usr/local/sbin/cn-sip-firewall status管理脚本会先验证完整事务,再提交到内核。下载或 CIDR 校验失败时不会替换当前配置;nftables 校验或事务提交失败时,原有运行规则保持不变,更新脚本会恢复磁盘上的旧配置。
重载会重新创建这张表,计数器也会重新开始计数。 如果要保留长期统计,应在重载前记录计数,或者另外接入监控系统。
2. 撤销本次限制
正常撤销只需要:
systemctl disable --now cn-sip-firewall.service这会取消开机启动,并通过 ExecStop 删除 inet cn_sip_ports。若服务状态异常而规则仍在,可直接清理自己的表:
/usr/local/sbin/cn-sip-firewall remove最后检查:
nft list tables
systemctl is-enabled cn-sip-firewall.service无需恢复整份历史 Docker 规则,也无需重启 Docker。
结语
这套配置把影响范围限制在一张独立规则表、一张外部网卡和两个指定端口上。中国 IPv4 地址通过集合统一维护,名单外流量在 Docker 地址转换前被丢弃,SSH、其他服务与容器内部通信继续使用原有规则。
真正完成配置,需要同时回答三个问题:规则有没有加载、测试包有没有到达、到达后有没有继续进入容器。新 SSH 连接、原有规则对比、计数器和抓包共同构成了验证过程,自动回滚则为远程操作保留了恢复路径。
地址白名单只能按名单中的网络范围过滤,无法判断名单内请求是否正常,也不能替代 SIP 服务本身的认证。后续维护时保留这一边界,定期检查名单与拦截计数,就能知道规则仍在做什么,以及哪些流量仍需要由应用处理。