家里的宽带公网 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 方法POSTGET
是否改白名单会写只读
用途加白查看槽位占用
当前 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 进白名单,就把它装在离那台设备最近的地方。

各平台实现方式一览,后面按这个顺序写:

平台调度机制配置/脚本位置触发方式
临时用无(手动)无敲命令
Linuxsystemd timer/usr/local/bin/whitelist.sh开机 1 分钟后,每 10 分钟
macOS / 无 systemdcron/usr/local/bin/whitelist.sh每 10 分钟
OpenWrtcron + hotplug/usr/bin/whitelist.sh + /etc/hotplug.d/iface/每 10 分钟 + WAN 重拨
Windows计划任务C:\ProgramData\whitelist\每 10 分钟 + 登录/网络事件
Termuxcronie~/bin/whitelist.sh每 10 分钟(不保证准时)
iOS 代理客户端客户端内置 cron模块/脚本内网络切换 + 10 分钟
iOS 快捷指令个人自动化快捷指令 AppWi-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 或代理客户端模块都行,手动敲一次也够用。

推荐阅读


本文是个人笔记,讲的是通用原理和自写实现。文中节点地址、token 均为占位符。