家里的宽带公网 IP 每隔几天就换一次,服务器防火墙白名单还停在旧段,人又不在家。这种时候要么远程改配置,要么干脆把白名单关掉,两种都别扭。
后来我想通了一件事:白名单这东西,与其我盯着 IP 变没变,不如让设备自己定期报到。设备每次报到,服务端就知道它现在从哪个 IP 来,顺手把那个网段记下就行。
整套机制的核心只有一条 HTTP 请求,不需要任何第三方工具,几十行脚本就能自己实现。下面讲原理,再给各平台的手写实现。
一、先说清楚这套机制是什么
很多服务器面板的防火墙支持白名单模式:只允许白名单里的 IP 访问。这是最安全的方式,但有个麻烦——家宽 IP 会变。
白名单的运维方式通常是这两种,都别扭:
- IP 变了就手工去面板改
- 嫌麻烦干脆关掉白名单
自动加白解决的就是这个断层。它要求服务端提供一个接口,接收客户端上报,然后按请求的来源 IP 自动写入白名单。
客户端要做的事情非常少:定期发一条请求。剩下的判断全在服务端。
一个这样的接口长这样:
POST https://你的节点地址/api/firewall/<token>/add
URL 里的 <token> 是你的加白凭证,/add 表示这是一个写入操作。
二、三个关键机制
这三个机制决定了整套方案怎么用、有什么坑。
2.1 服务端按「来源 IP」识别 /24 网段
请求发出去的时候,TCP 包的源地址就是你的出口 IP。服务端拿到这个 IP,取前 24 位(也就是 /24 网段),写进白名单。
举个例子:你的手机在 4G 下出口是 223.104.213.87,服务端记入的就是 223.104.213.0/24。同一网段内的地址一并放行。
由此可以推出一个很重要的结论:加白请求必须从你想要加白的那台设备直连发出。
因为服务端看的是来源 IP。请求一旦被代理或 VPN 中转,服务端看到的是代理的 IP,加白的就是代理而不是你的设备。这是整篇文章里最容易踩的坑,后面单独讲。
2.2 幂等:重复请求不占新槽位
同一个网段重复加白,服务端会识别出来,不占新的坑位,也不推进 FIFO 淘汰顺序。
所以定时任务跑得勤一点没有副作用。每 10 分钟跑一次,或者手动多跑几次,结果一样。这一点让脚本可以写得很粗糙——不需要去记录「上次加白成功没有」,无脑重复发就行。
2.3 FIFO 淘汰:白名单满了怎么办
白名单有槽位上限,常见是 5 个。槽位满了之后,服务端按写入时间先进先出淘汰最旧的那条记录。
旧设备被挤出去不用慌,它下一次定时任务跑起来,最迟 10 分钟就自动补回来了。整个方案是自愈的。
如果想彻底避开淘汰,有些实现支持把设备钉死在固定槽位:
POST https://你的节点地址/api/firewall/<token>/add?slot=0
slot=0 表示固定占 0 号槽位,不参与 FIFO 淘汰。换槽之前记得先在面板里删掉旧槽,否则会一直占着。
三、加白凭证与两个端点
3.1 token 拿到手就要当密码看待
token 在服务器面板的防火墙卡片里拿,形如 pgnfw_ 加一串十六进制。
它的性质是裸能力凭证:谁拿到它,谁就能把自己的 IP 加进白名单。所以:
- 别贴到公开仓库、论坛、截图里
- 别用命令行参数传,会留在 shell history 里,写配置文件或环境变量更稳妥
- 配置文件权限设成 600
本文所有示例都用 pgnfw_你的token 占位。
3.2 写用 POST,读用 GET,别混
一个容易踩的坑:别拿写入端点当查询端点用。
| 维度 | /add(写入) | 不带 /add(只读) |
|---|---|---|
| HTTP 方法 | POST | GET |
| 是否改白名单 | 会写 | 只读 |
| 用途 | 加白 | 查看槽位占用 |
| 当前 IP 不在白名单时 | 写入并占位 | 仅报告「不在白名单」 |
| 是否推进 FIFO 淘汰 | 是(新 IP 时) | 否 |
查状态的时候用 GET:
curl -sS -m 20 "https://你的节点地址/api/firewall/pgnfw_你的token"
如果偷懒拿 /add 当查询用,每次查询都可能真的写一条进去,把别的设备挤出 FIFO。我第一次写的时候就干过这事。
四、最小实现:30 行自己写
原理讲完,实现其实很简单。下面是我自己用的脚本,不依赖任何第三方仓库,POSIX sh 就能跑。
#!/bin/sh
# 动态白名单自动加白
set -u
# 配置:改成你自己的
API="https://你的节点地址/api/firewall"
TOKEN_FILE="/etc/whitelist.conf"
TOKEN="$(cat "$TOKEN_FILE" 2>/dev/null)"
[ -z "$TOKEN" ] && { echo "未配置 token" >&2; exit 1; }
# 强制 IPv4,20 秒超时,失败重试 2 次
resp=$(curl -4sS -m 20 --retry 2 --retry-delay 2 -X POST "$API/$TOKEN/add" 2>&1)
# 检查响应里有没有白名单字段
if ! echo "$resp" | grep -q '"whitelist"'; then
echo "请求失败: $resp" >&2
exit 1
fi
# 提取服务端看到的来源网段,便于核对
current=$(echo "$resp" | sed -n 's/.*"currentIp":"\([^"]*\)".*/\1/p' | sed 's|\\/|/|g')
echo "加白成功,当前出口段: ${current:-未知}"
几个实现要点:
-4强制 IPv4。白名单按 IPv4 的 /24 加白,走 IPv6 不会被识别。- 不做本地 IP 探测。服务端自己会看来源 IP,客户端不需要知道自己的出口是什么。
- 不解析复杂 JSON。只用 sed 抓
currentIp字段,够用且零依赖。 - 用响应内容判断成功,而不是只看 HTTP 状态码。请求通了不代表加白生效,得看返回里有没有白名单字段。
配置单独放一个文件,权限收紧:
echo "pgnfw_你的token" | sudo tee /etc/whitelist.conf >/dev/null
sudo chmod 600 /etc/whitelist.conf
五、各平台怎么用
同一个脚本,装的位置不同,加白的对象就不同。先想清楚要加白谁:
| 你的场景 | 装在哪 | 加白的 IP | 空窗期 |
|---|---|---|---|
| 全家设备都要走这台服务器 | 软路由 OpenWrt | 家宽出口 | 秒级(重拨触发) |
| 只有服务器自己需要 | 该服务器 | 该服务器出口 | 10 分钟 |
| 手机在外面随时要用 | 手机 Termux | 手机当前出口 | 手动或切换触发 |
| 笔记本到处换网络 | 笔记本 | 笔记本当前出口 | 10 分钟或网络事件 |
| 已在用 Surge 等客户端 | 代理客户端模块 | 手机当前出口 | 切换即时 |
判断原则:你想让哪台设备的 IP 进白名单,就把它装在离那台设备最近的地方。
各平台实现方式一览,后面按这个顺序写:
| 平台 | 调度机制 | 配置/脚本位置 | 触发方式 |
|---|---|---|---|
| 临时用 | 无(手动) | 无 | 敲命令 |
| Linux | systemd timer | /usr/local/bin/whitelist.sh | 开机 1 分钟后,每 10 分钟 |
| macOS / 无 systemd | cron | /usr/local/bin/whitelist.sh | 每 10 分钟 |
| OpenWrt | cron + hotplug | /usr/bin/whitelist.sh + /etc/hotplug.d/iface/ | 每 10 分钟 + WAN 重拨 |
| Windows | 计划任务 | C:\ProgramData\whitelist\ | 每 10 分钟 + 登录/网络事件 |
| Termux | cronie | ~/bin/whitelist.sh | 每 10 分钟(不保证准时) |
| iOS 代理客户端 | 客户端内置 cron | 模块/脚本内 | 网络切换 + 10 分钟 |
| iOS 快捷指令 | 个人自动化 | 快捷指令 App | Wi-Fi 切换 |
5.1 临时用:一条 curl
出门在外不方便装东西,手机随便开个终端就能加白:
curl -sS -X POST "https://你的节点地址/api/firewall/pgnfw_你的token/add"
看到的返回长这样:
{"code":0,"msg":"success","data":{"currentIp":"223.104.213.0/24","whitelist":[...]}}
返回里的 currentIp 就是被加白的网段,应该等于你当时的真实出口。每次加白完都核一下这个值,这是判断有没有走代理的最直接办法。
5.2 Linux:systemd timer
Linux 上用 systemd timer 比 cron 好,因为能限制网络就绪后再跑,不会开机抢跑失败。
服务单元 /etc/systemd/system/whitelist.service:
[Unit]
Description=dynamic whitelist updater
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/whitelist.sh
定时器 /etc/systemd/system/whitelist.timer:
[Unit]
Description=run whitelist updater every 10 min
[Timer]
OnBootSec=1min
OnUnitActiveSec=10min
[Install]
WantedBy=timers.target
启用:
sudo chmod +x /usr/local/bin/whitelist.sh
sudo systemctl daemon-reload
sudo systemctl enable --now whitelist.timer
用 oneshot 加 timer 而非常驻服务,好处是不占内存,崩了下次触发自愈。
日常运维命令:
systemctl list-timers whitelist.timer # 下次触发时间
journalctl -u whitelist.service -n 20 # 执行日志
sudo systemctl start whitelist.service # 手动立刻跑一次
5.3 macOS 与无 systemd 的 Linux:cron
没有 systemd 就退回 cron。注意 cron 的环境变量很干净,脚本里的路径要用绝对路径:
*/10 * * * * /usr/local/bin/whitelist.sh >/dev/null 2>&1
crontab -e 写进去。macOS 第一次用 cron 需要给终端完全磁盘访问权限,否则静默失败。
5.4 OpenWrt 软路由:cron + hotplug 双触发
这是体验最好的方案。路由器出口就是全家出口,而且 WAN 重拨能被 hotplug 捕获,加白几乎无空窗。
cron 兜底(每 10 分钟):
*/10 * * * * /usr/bin/whitelist.sh >/dev/null 2>&1
hotplug 即时触发,文件放 /etc/hotplug.d/iface/99-whitelist:
#!/bin/sh
# WAN 接口状态变化时触发加白
[ "$ACTION" = "ifup" ] && [ "$INTERFACE" = "wan" ] && {
/usr/bin/whitelist.sh >/dev/null 2>&1 &
}
记得 chmod +x。这样 PPPoE 一重拨拿到新 IP,脚本立刻上报,空窗从 10 分钟压到几秒。
OpenWrt 默认的 curl 可能是精简版,如果缺 --retry 参数就换成 wget -q -O -:
wget -q -O - --post-data="" "https://你的节点地址/api/firewall/$TOKEN/add"
5.5 Windows:计划任务 + PowerShell
PowerShell 版脚本 whitelist.ps1:
$api = "https://你的节点地址/api/firewall"
$token = Get-Content "$env:ProgramData\whitelist\token.txt" -Raw
$token = $token.Trim()
try {
$resp = Invoke-RestMethod -Uri "$api/$token/add" -Method Post -TimeoutSec 20
$current = $resp.data.currentIp
Write-Output "加白成功: $current"
} catch {
Write-Output "加白失败: $_"
exit 1
}
Invoke-RestMethod 会自动解析 JSON,比在 PowerShell 里正则抓字段干净。
注册计划任务,双触发(每 10 分钟 + 网络连接事件):
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -WindowStyle Hidden -File C:\ProgramData\whitelist\whitelist.ps1"
$t1 = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 10)
$t2 = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -TaskName "WhitelistUpdater" `
-Action $action -Trigger $t1,$t2 -RunLevel Highest
Windows 上有个坑:连接 Wi-Fi 或插网线时会触发网络事件,这时候出口 IP 可能还没稳定,加白容易失败。所以双触发比单定时更稳。
5.6 安卓 Termux:crontab
Termux 里装 curl 和 cronie 就能跑同一个脚本:
pkg install curl cronie
mkdir -p ~/bin
# 把 whitelist.sh 放进去,路径改成 $PREFIX
chmod +x ~/bin/whitelist.sh
crontab 写:
*/10 * * * * /data/data/com.termux/files/usr/bin/sh ~/bin/whitelist.sh >/dev/null 2>&1
Termux 上的 cron 有几个坑:
- 装完 cronie 不等于 cron 在跑,需要
sv-enable crond(要装 termux-services) - Android 会杀后台进程,定时任务不保证准时
- 手机切 Wi-Fi / 切 4G 时 IP 就变了,10 分钟的间隔可能太长
我在 Termux 上的实际做法是手动加白——出门前敲一次,或者在 ~/.bashrc 里加个别名:
alias po0='curl -sS -m 20 -X POST "https://你的节点地址/api/firewall/pgnfw_你的token/add"'
需要的时候一句 po0 就行,比等定时任务可靠。
5.7 iOS:代理客户端模块
手机常年挂代理客户端的话,直接在客户端里写脚本最方便,不用额外开 App。
Surge 的模块写法(script 段落):
[Script]
whitelist = type=cron,cronexp="*/10 * * * *",script-path=whitelist.js,wake-system=true
whitelist.js 用 Surge 的 HTTP API 发请求:
const API = "https://你的节点地址/api/firewall";
const TOKEN = $persistentStore.read("whitelist_token");
$httpClient.post({ url: `${API}/${TOKEN}/add` }, function (err, resp, data) {
if (err) {
console.log("加白失败: " + err);
return;
}
// 核对服务端看到的来源网段
const m = data.match(/"currentIp":"([^"]+)"/);
console.log("加白成功: " + (m ? m[1] : "未知"));
});
$done();
token 放持久存储里($persistentStore.write("pgnfw_xxx", "whitelist_token")),别硬编码在脚本里。
Loon 的插件写法类似,用 [Script] 段的 cron 类型;Quantumult X 用 [task_local] 段;Shadowrocket 用模块的脚本功能。
各客户端的写法对照:
| 客户端 | 脚本段名 | 定时语法 | token 存放 |
|---|---|---|---|
| Surge | [Script] | type=cron,cronexp="*/10 * * * *" | $persistentStore |
| Loon | [Script] | type=cron,cronexp="*/10 * * * *" | 插件设置项 |
| Quantumult X | [task_local] | 0,10,20,30,40,50 * * * * | BoxJs 或脚本内变量 |
| Shadowrocket | 模块脚本 | 模块参数配置 | 模块参数 |
| Stash | [Script] | type=cron,cronexp="*/10 * * * *" | 覆写内 argument |
共同点是都要在客户端里配一条直连规则,否则加白请求会被自己代理掉:
IP-CIDR,你的节点IP/32,DIRECT,no-resolve
5.8 iOS 没有代理客户端:快捷指令
没装代理客户端就用快捷指令的自动化。建一个「个人自动化」,触发条件选 Wi-Fi 切换,动作里加「获取 URL 内容」:
URL: https://你的节点地址/api/firewall/pgnfw_你的token/add
方法: POST
注意别在快捷指令里挂 VPN 配置,否则请求可能走 VPN 出去。
六、踩坑记录
6.1 用 /add 查状态
前面说过,再强调一次。有人图省事拿写入端点当查询用,结果每次查询都在推进 FIFO,把别的设备挤出去了。查状态用 GET 不带 /add。
6.2 TUN / 透明代理劫持加白请求
这是最容易踩的坑。
普通域名分流代理不会影响加白请求,因为请求打的是裸 IP,不经 DNS,域名规则匹配不到它。
但 TUN 模式或全局透明代理会接管所有流量,包括这个 IP。结果就是加白请求走代理出去了,服务端看到代理节点的 IP,把你的节点自己加进了白名单,真实设备反而没进去。
判断方法:看返回的 currentIp 是不是你的真实出口。不是就是被劫持了。
解决办法:加一条直连规则:
IP-CIDR,你的节点IP/32,DIRECT,no-resolve
这个格式在 Surge、Loon、Stash、Clash 里都通用。no-resolve 表示不要对这个 IP 做 DNS 解析。
顺便说个建议:如果你的节点有域名,能用域名就用域名。这样规则可以写成:
DOMAIN,你的节点域名,DIRECT
域名直连规则在域名分流代理和 TUN 模式下都更容易生效,而且换 IP 时不用改规则。
6.3 家宽环境:透明代理会劫持路由器自己的流量
我家的网关跑着透明代理,用 iptables 做 TPROXY。查过规则,OUTPUT 链确实会劫持本机自己发起的流量:
-A OUTPUT -j TP_OUT
也就是说路由器自己 curl 出去的加白请求也在劫持范围内。
实测最后能通,是因为路由规则里 geoip:cn → direct 命中了我的节点 IP(属于国内段),直连出站带了一个内部标记,而 TP_OUT 链里有一条针对该标记的放行规则。
两件事值得记下来:
- 路由规则里如果没有国内直连(比如全局走代理),必须显式给节点 IP 加直连规则
- 验证永远看
currentIp,不要凭感觉
6.4 IPv6 不支持
白名单按 IPv4 的 /24 加白,脚本里要强制带 -4。出口走 IPv6 的请求不会被识别。
6.5 换 IP 后的空窗期
| 方式 | 空窗期 |
|---|---|
| 定时任务(10 分钟) | 最长 10 分钟 |
| 定时任务(改 5 分钟) | 最长 5 分钟 |
| OpenWrt hotplug | 秒级 |
| 手动敲一次 | 立刻 |
请求是幂等的,所以把间隔改短没有副作用。IP 换得勤就改到 5 分钟。
七、FAQ
Q: 需要自己在客户端探测出口 IP 吗?
不需要。服务端按请求来源自动识别,客户端只要发请求。这也是这套机制设计得好的地方——客户端不用关心自己的网络环境。
Q: 加白请求会被代理劫持吗?
普通域名分流代理不会,因为请求目标是裸 IP,不走 DNS。TUN 或全局透明代理会接管,需要加直连规则。
Q: 白名单满了,我的设备会被踢出去吗?
会被 FIFO 淘汰,但最迟一个定时周期就自动补回。想彻底避免,用 ?slot=N 参数钉死槽位。
Q: 多久跑一次比较合适?
默认 10 分钟够用。IP 变化频繁可以改到 5 分钟,幂等所以多跑没成本。
Q: 为什么建议用 -4 强制 IPv4?
白名单按 IPv4 的 /24 加白。不加 -4 的话,双栈环境下可能走 IPv6 出去,那样加白识别不到,白跑一趟。
Q: token 能不能放在命令行参数里?
技术上可以,但会留在 ~/.bash_history 里。写配置文件并设 600 权限更稳妥。
八、常用命令速查
# 手动加白一次(任意平台)
curl -sS -X POST "https://你的节点地址/api/firewall/pgnfw_你的token/add"
# 只读查看白名单(注意:GET,不带 /add)
curl -sS -m 20 "https://你的节点地址/api/firewall/pgnfw_你的token"
# 固定槽位加白
curl -sS -X POST "https://你的节点地址/api/firewall/pgnfw_你的token/add?slot=0"
# systemd 环境
systemctl list-timers whitelist.timer
journalctl -u whitelist.service -n 20
sudo systemctl start whitelist.service
# 核对服务端看到的来源段
curl -sS -m 20 -X POST "https://你的节点地址/api/firewall/pgnfw_你的token/add" \
| sed -n 's/.*"currentIp":"\([^"]*\)".*/\1/p'
九、总结
这套机制的设计思路可以概括成一句话:把「判断来源 IP」交给服务端,客户端只负责定期报到。所以客户端实现可以非常简单,三十行脚本、零依赖就能跑。
用的时候记住三件事:加白请求必须直连发出,查状态用 GET 不带 /add,TUN 或透明代理环境给节点地址加一条直连规则。
平台选择上,软路由体验最好,全家出口统一且重拨能即时触发。手机在外面用 Termux 或代理客户端模块都行,手动敲一次也够用。
推荐阅读
- 📄 零依赖 Python 文件服务器部署 — 用标准库搭生产级文件服务器
- 🔧 Hugo + COS + EdgeOne 部署实战 — 本站的部署方式
- 🛠 Jira NFS 存储迁移 — 存储迁移的踩坑记录
- 📝 Hermes + 钉钉机器人教程 — 把 AI Agent 接到钉钉
本文是个人笔记,讲的是通用原理和自写实现。文中节点地址、token 均为占位符。