之前使用的端口敲门技术来限制未知IP访问家里的设备,或者使用VPN隧道连回内网,但都有些局限,某些时候不是很方便。这次使用Google Gemini协助写了套脚本,客户端通过HTTPS协议访问部署在CloudFlare Worker上的程序,通过认证后将当前的IP上报记录。OpenWrt路由器端通过长连接轮询Worker获得IP列表,并加入白名单允许入站访问特定的端口。
相比端口敲门的优势
相比于传统的端口敲门技术Port Knocking,这套脚本的优势在于路由器不需要对外暴露任何端口。OpenWrt防火墙默认drop掉所有的入站连接请求,对于恶意端口扫描和ISP主动探测都可以免疫,安全性高
一些简单功能介绍
- 用户通过浏览器等方式访问托管在CloudFlare Worker上的接口,验证密码后服务端自动记录当前访问者的IP
- IP默认有效期10分钟,10分钟内不继续上报相同IP的话,会被移除白名单
- 上报页面可选择浏览器自动刷新,始终保持当前设备的IP在白名单内
- 路由器端脚本采用HTTP长连接轮询白名单IP列表,用户侧更新IP后路由器端基本能在两三秒内将其加入到白名单。比简单的cronjob每分钟检查一次更及时
- 路由器端完全基于sh脚本,轻量化不需要安装任何软件
- 支持IPv4和IPv6
CloudFlare Worker脚本
由Google Gemini生成
// --- Configuration ---
// 用于路由器和手机访问的共享密钥,请务必修改为一个复杂的值!
const SHARED_ACCESS_SECRET = "password";
// 路由器用于认证的HTTP头部名称
const WORKER_TO_ROUTER_AUTH_HEADER = "X-Router-Secret";
// 手机/浏览器访问时用于认证的URL查询参数名
const PHONE_AUTH_QUERY_PARAM = "key";
// Durable Object (DO) 相关配置
// 长轮询超时时间,略低于Cloudflare边缘的60秒超时
const LONG_POLLING_TIMEOUT_MS = 55 * 1000;
// IP有效性和清理间隔
// IP过期时间:10分钟 (10 * 60 秒 * 1000 毫秒)
const IP_EXPIRATION_TIME_MS = 10 * 60 * 1000;
// DO内部清理过期IP的检查间隔:5分钟
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
// --- Worker 主入口 ---
export default {
async fetch(request, env) {
const url = new URL(request.url);
// 路由器长轮询接口:用于路由器实时获取IP列表更新
if (url.pathname === '/long_poll_for_ip_updates' && request.method === 'GET') {
const routerSecret = request.headers.get(WORKER_TO_ROUTER_AUTH_HEADER);
if (routerSecret !== SHARED_ACCESS_SECRET) {
return new Response("Unauthorized: Invalid router secret.", { status: 401 });
}
const stub = env.IP_LIST_MANAGER.get(env.IP_LIST_MANAGER.idFromName("global-ip-manager"));
return stub.fetch(request);
}
// 路由器快速查询接口:用于路由器主动查询当前IP列表
if (url.pathname === '/get_current_ips' && request.method === 'GET') {
const routerSecret = request.headers.get(WORKER_TO_ROUTER_AUTH_HEADER);
if (routerSecret !== SHARED_ACCESS_SECRET) {
return new Response("Unauthorized: Invalid router secret.", { status: 401 });
}
const stub = env.IP_LIST_MANAGER.get(env.IP_LIST_MANAGER.idFromName("global-ip-manager"));
// 调用DO的fetch方法,DO会根据路径处理
return stub.fetch(new Request("http://dummy-host/get_current_ips_internal"));
}
// 手机/客户端IP上报及显示接口
if (url.pathname === '/' && request.method === 'GET') {
const phoneSecret = url.searchParams.get(PHONE_AUTH_QUERY_PARAM);
if (phoneSecret !== SHARED_ACCESS_SECRET) {
return new Response("Unauthorized: Invalid or missing 'key' parameter.", { status: 401 });
}
const newIp = request.headers.get('CF-Connecting-IP');
if (!newIp) {
return new Response("Error: Could not determine client IP from CF-Connecting-IP header.", { status: 400 });
}
// 从Cloudflare的请求对象中提取地理位置和ISP信息
let location = "Unknown Location";
if (request.cf) {
const parts = [];
if (request.cf.city) {
parts.push(request.cf.city);
}
if (request.cf.country) {
parts.push(request.cf.country);
}
if (parts.length > 0) {
location = parts.join(', ');
}
// 使用request.cf.asOrganization作为ISP信息
if (request.cf.asOrganization) {
if (location !== "Unknown Location") {
location += ` (${request.cf.asOrganization})`;
} else {
location = request.cf.asOrganization;
}
} else if (location === "Unknown Location" && request.cf.longitude && request.cf.latitude) {
// 如果城市/国家/ISP不可用,则尝试使用经纬度作为备用位置信息
location = `Lat: ${request.cf.latitude}, Lon: ${request.cf.longitude}`;
}
}
const action = url.searchParams.get('action') || 'add'; // 默认为'add'
// 获取Durable Object实例
const stub = env.IP_LIST_MANAGER.get(env.IP_LIST_MANAGER.idFromName("global-ip-manager"));
// 调用DO的内部IP更新接口
const response = await stub.fetch(new Request("http://dummy-host/update_single_ip", {
method: "POST",
body: JSON.stringify({ ip: newIp, action: action, location: location }),
headers: { 'Content-Type': 'application/json' }
}));
if (!response.ok) {
console.error("Failed to update IP in Durable Object:", await response.text());
return new Response("Failed to update IP.", { status: 500 });
}
const responseData = await response.json();
let ipListHtml = "";
if (responseData.ips_with_ttl && responseData.ips_with_ttl.length > 0) {
ipListHtml = "<h2>Current Allowed IP List:</h2><ul>";
for (const item of responseData.ips_with_ttl) {
// 这里的 ${...} 是由 Worker 自身执行,直接将变量值插入到 HTML 字符串中
ipListHtml += `<li><span class="ip-display">${item.ip}</span> <span class="location">(${item.location})</span><span class="ttl" data-ttl-ms="${item.ttl_ms}">(Expires in: ${item.ttl_formatted})</span></li>`;
}
ipListHtml += "</ul>";
} else {
ipListHtml = "<p>No allowed IPs currently.</p>";
}
// 返回HTML响应给客户端浏览器
const htmlResponse = `
<!DOCTYPE html>
<html>
<head>
<title>IP Reporting Result</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; background-color: #f0f2f5; margin: 0; padding: 20px; color: #333; }
.container { max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 30px; }
h1, h2 { color: #0056b3; margin-top: 0; }
p { line-height: 1.6; }
ul { list-style: none; padding: 0; }
li {
background-color: #e9ecef;
border-left: 5px solid #007bff;
margin-bottom: 10px;
padding: 10px 15px;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
align-items: center;
word-break: break-all;
overflow-wrap: break-word;
}
.ip-display {
color: #0056b3;
font-weight: bold;
min-width: 0;
flex-shrink: 0;
margin-right: 10px;
}
.location {
color: #777;
font-size: 0.9em;
flex-grow: 1;
min-width: 0;
word-break: break-word;
margin-right: 10px;
}
.ttl {
font-size: 0.9em;
color: #555;
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
}
.auto-refresh-control {
margin-top: 20px;
display: flex;
align-items: center;
font-size: 0.9em;
color: #555;
}
.auto-refresh-control input {
margin-right: 8px;
width: 18px;
height: 18px;
cursor: pointer;
}
@media (max-width: 480px) {
li {
flex-direction: column;
align-items: flex-start;
}
.ip-display {
margin-bottom: 5px;
margin-right: 0;
}
.location {
margin-left: 0;
margin-bottom: 3px;
margin-right: 0;
width: 100%;
}
.ttl {
margin-left: 0;
align-self: flex-end;
width: 100%;
text-align: right;
}
.location:last-child {
margin-bottom: 0;
}
}
.status-message { font-weight: bold; color: #28a745; margin-bottom: 20px; }
.status-error { color: #dc3545; }
.footer { margin-top: 30px; font-size: 0.8em; text-align: center; color: #777; }
.footer a { color: #007bff; text-decoration: none; }
.footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>IP Reported Successfully!</h1>
<p class="status-message">Your IP <strong>${newIp}</strong> has been ${responseData.status === 'added_or_refreshed' ? 'added or refreshed' : 'updated' }.</p>
${ipListHtml}
<div class="auto-refresh-control">
<input type="checkbox" id="autoRefreshCheckbox">
<label for="autoRefreshCheckbox">Enable auto-refresh (every 5 minutes)</label>
</div>
<p class="footer">
Note: IPs will automatically expire ${IP_EXPIRATION_TIME_MS / (60 * 1000)} minutes after their last refresh.
<br>
This service is designed for router access and also supports mobile browser reporting.
</p>
</div>
<script>
const checkbox = document.getElementById('autoRefreshCheckbox');
const REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes in milliseconds
let refreshTimer;
let countdownTimer; // 倒计时定时器
// 辅助函数:将毫秒格式化为 "Xm Ys"
function formatMillisecondsToMMSS(milliseconds) {
if (milliseconds <= 0) return "Expired";
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
// FIX: 改用字符串拼接,避免在 Worker 端误解析客户端 JS 的模板字符串
return minutes + "m " + seconds + "s";
}
// 更新所有倒计时的函数
function updateCountdowns() {
const ttlElements = document.querySelectorAll('.ttl');
// 不再需要 anyExpired 标志,因为自动刷新会处理完整列表更新
ttlElements.forEach(element => {
let timeLeftMs = parseInt(element.dataset.ttlMs); // 获取剩余毫秒数
if (timeLeftMs > 0) {
timeLeftMs -= 1000; // 递减1秒
element.dataset.ttlMs = timeLeftMs; // 更新data属性
// FIX: 改用字符串拼接
element.textContent = "(Expires in: " + formatMillisecondsToMMSS(timeLeftMs) + ")";
} else if (element.textContent !== "(Expired)") {
element.textContent = "(Expired)"; // 如果刚过期,则标记为“Expired”
}
});
}
// 从localStorage加载自动刷新偏好设置
const autoRefreshEnabled = localStorage.getItem('autoRefreshEnabled') === 'true';
checkbox.checked = autoRefreshEnabled;
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
window.location.reload(); // 重新加载页面以发送IP
}, REFRESH_INTERVAL_MS);
console.log('Auto-refresh started.');
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
console.log('Auto-refresh stopped.');
}
// 页面加载时根据偏好设置启动自动刷新
if (checkbox.checked) {
startAutoRefresh();
}
// 监听复选框变化事件
checkbox.addEventListener('change', function() {
if (this.checked) {
localStorage.setItem('autoRefreshEnabled', 'true');
startAutoRefresh();
} else {
localStorage.setItem('autoRefreshEnabled', 'false');
stopAutoRefresh();
}
});
// 立即启动倒计时定时器
countdownTimer = setInterval(updateCountdowns, 1000); // 每1秒更新一次
// 页面加载时也执行一次初始更新,确保显示准确时间
updateCountdowns();
</script>
</body>
</html>
`;
return new Response(htmlResponse, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
// 默认响应:如果没有匹配到路径或认证失败,显示欢迎信息
return new Response("Welcome to the IP Update Worker! Use GET /?key=password to report your current IP.<br>Routers: Use GET /long_poll_for_ip_updates or GET /get_current_ips.", { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
}
// --- Durable Object 类 ---
export class MyDurableObject {
constructor(state, env) {
this.state = state;
this.env = env;
this.pendingRequests = new Set(); // 用于长轮询的待处理请求
this.currentIpsMap = new Map(); // 存储当前有效的IP及其数据
// 从Durable Object存储中加载状态(IP列表和上次更新时间)
this.state.storage.get('currentIpsMap').then(mapAsArray => {
if (mapAsArray) {
this.currentIpsMap = new Map(mapAsArray);
}
});
this.state.storage.get('lastUpdatedTimestamp').then(ts => {
this.lastUpdatedTimestamp = ts || 0;
});
// 设置Durable Object的定时闹钟,用于定期清理过期IP
this.setupAlarm();
}
async setupAlarm() {
const currentAlarm = await this.state.storage.getAlarm();
const now = Date.now();
// 如果没有设置闹钟或闹钟已过期,则重新设置
if (currentAlarm === null || currentAlarm < now) {
await this.state.storage.setAlarm(now + CLEANUP_INTERVAL_MS);
console.log(`DO Alarm set for: ${new Date(now + CLEANUP_INTERVAL_MS).toISOString()}`);
} else {
console.log(`DO Alarm already set for: ${new Date(currentAlarm).toISOString()}`);
}
}
async alarm() {
console.log("DO Alarm triggered: Checking for expired IPs...");
const now = Date.now();
let ipChanged = false;
const ipsToRemove = [];
// 识别过期的IP
for (const [ip, data] of this.currentIpsMap.entries()) {
if (now - data.timestamp > IP_EXPIRATION_TIME_MS) {
ipsToRemove.push(ip);
}
}
// 移除过期的IP
if (ipsToRemove.length > 0) {
console.log(`Removing expired IPs: ${ipsToRemove.join(', ')}`);
for (const ip of ipsToRemove) {
this.currentIpsMap.delete(ip);
}
ipChanged = true;
}
// 如果IP列表发生变化,则更新时间戳并通知所有长轮询的路由器
if (ipChanged) {
this.lastUpdatedTimestamp = now;
// 使用waitUntil确保数据在响应发送后仍然被写入存储
this.state.waitUntil(this.state.storage.put('currentIpsMap', Array.from(this.currentIpsMap.entries())));
this.state.waitUntil(this.state.storage.put('lastUpdatedTimestamp', this.lastUpdatedTimestamp));
this.notifyRouters('cleaned'); // 通知路由器列表因清理而更新
}
// 总是重新设置闹钟以进行下一次清理
await this.setupAlarm();
}
// 格式化剩余时间为“Xm Ys”
formatTTL(milliseconds) {
if (milliseconds < 0) return "Expired";
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`; // 这里是Durable Object内部,不是HTML字符串,可以使用模板字符串
}
// 获取非过期IP的纯数组(供路由器使用)
getNonExpiredIpsArray() {
const now = Date.now();
const nonExpiredIps = [];
for (const [ip, data] of this.currentIpsMap.entries()) {
const timeLeft = data.timestamp + IP_EXPIRATION_TIME_MS - now;
if (timeLeft > 0) { // 只包含未过期的IP
nonExpiredIps.push(ip);
}
}
return nonExpiredIps;
}
// 获取包含TTL和位置信息的非过期IP列表(供手机页面使用)
getNonExpiredIpsWithFormattedTTL() {
const now = Date.now();
let nonExpiredIpsWithTtl = [];
for (const [ip, data] of this.currentIpsMap.entries()) {
const timeLeft = data.timestamp + IP_EXPIRATION_TIME_MS - now;
if (timeLeft > 0) {
nonExpiredIpsWithTtl.push({
ip: ip,
ttl_ms: timeLeft, // 返回原始毫秒数,供客户端倒计时使用
ttl_formatted: this.formatTTL(timeLeft),
location: data.location || "Unknown"
});
}
}
// 按剩余时间(ttl_ms)降序排序,即将过期的IP会排在列表底部
nonExpiredIpsWithTtl.sort((a, b) => b.ttl_ms - a.ttl_ms);
return nonExpiredIpsWithTtl;
}
// 通知等待长轮询的客户端(路由器)IP列表已更新
notifyRouters(status) {
const currentIpsArray = this.getNonExpiredIpsArray();
const responseData = JSON.stringify({
ips: currentIpsArray,
last_updated: this.lastUpdatedTimestamp,
status: status
});
// 解决所有待处理的请求,发送最新IP列表和状态
for (const promise of this.pendingRequests) {
promise.resolve(new Response(responseData, { headers: { 'Content-Type': 'application/json' } }));
}
this.pendingRequests.clear(); // 清空待处理请求
}
async fetch(request) {
const url = new URL(request.url);
// Durable Object处理路由器长轮询的逻辑
if (url.pathname === '/long_poll_for_ip_updates') {
const responsePromise = new Promise(resolve => {
const clientRequest = { resolve: resolve };
this.pendingRequests.add(clientRequest);
// 设置长轮询请求的超时时间
const timeoutId = setTimeout(() => {
if (this.pendingRequests.has(clientRequest)) {
this.pendingRequests.delete(clientRequest);
const nonExpiredIps = this.getNonExpiredIpsArray();
resolve(new Response(JSON.stringify({
ips: nonExpiredIps,
last_updated: this.lastUpdatedTimestamp,
status: 'timeout'
}), { headers: { 'Content-Type': 'application/json' } }));
}
}, LONG_POLLING_TIMEOUT_MS);
// 添加清理函数,如果Promise提前解决则清除超时
clientRequest.cleanup = () => clearTimeout(timeoutId);
});
return responsePromise;
}
// Durable Object内部快速查询IP列表的逻辑(供路由器使用)
if (url.pathname === '/get_current_ips_internal') {
const nonExpiredIps = this.getNonExpiredIpsArray();
return new Response(JSON.stringify({
ips: nonExpiredIps,
last_updated: this.lastUpdatedTimestamp,
status: 'current'
}), { headers: { 'Content-Type': 'application/json' } });
}
// 处理单个IP更新的逻辑(供手机/浏览器使用)
if (url.pathname === '/update_single_ip' && request.method === 'POST') {
const body = await request.json();
const newIp = body.ip;
const action = body.action || 'add';
const location = body.location || "Unknown";
if (!newIp || typeof newIp !== 'string') {
return new Response(JSON.stringify({ status: 'error', message: "Invalid IP for internal update." }), { status: 400 });
}
let updateStatus = '';
let ipChanged = false;
const now = Date.now();
if (action === 'add') {
if (!this.currentIpsMap.has(newIp)) {
updateStatus = 'added_or_refreshed';
} else {
updateStatus = 'updated';
}
// 存储IP及其数据(时间戳、位置)
this.currentIpsMap.set(newIp, { timestamp: now, location: location });
ipChanged = true;
} else if (action === 'remove') {
if (this.currentIpsMap.has(newIp)) {
this.currentIpsMap.delete(newIp);
ipChanged = true;
updateStatus = 'removed';
} else {
updateStatus = 'not_found';
}
} else {
return new Response(JSON.stringify({ status: 'error', message: "Invalid action." }), { status: 400 });
}
if (ipChanged) {
this.lastUpdatedTimestamp = now;
this.state.waitUntil(this.state.storage.put('currentIpsMap', Array.from(this.currentIpsMap.entries())));
this.state.waitUntil(this.state.storage.put('lastUpdatedTimestamp', this.lastUpdatedTimestamp));
this.notifyRouters('updated'); // 通知路由器IP列表已更新
}
// 返回响应给手机客户端,包括IP列表和剩余时间
return new Response(JSON.stringify({
ips: this.getNonExpiredIpsArray(),
ips_with_ttl: this.getNonExpiredIpsWithFormattedTTL(),
last_updated: this.lastUpdatedTimestamp,
status: updateStatus
}), { headers: { 'Content-Type': 'application/json' } });
}
// 默认的DO响应:如果请求路径未匹配,则返回404
return new Response("Durable Object: Not Found.", { status: 404 });
}
}
OpenWrt端脚本
由Google Gemini生成
#!/bin/ash
# 路由器动态防火墙管理脚本
# --- 配置 ---
# 定义路由器访问 Worker 的共享密钥
SHARED_SECRET_ROUTER="password" # 必须与 Cloudflare Worker 配置中的密钥匹配
# 定义 Cloudflare Worker 用于快速同步 IP 的 URL
# 通常用于首次启动或长轮询中断后尝试快速恢复。
# 确保你的 Worker 存在此接口或将其指向长轮询接口。
CLOUDFLARE_WORKER_SYNC_URL="https://worker_address/get_current_ips"
# 定义 Cloudflare Worker 用于长轮询获取 IP 更新的 URL
# 确保你的 Worker 实现了此长轮询接口,它应该在没有更新时保持连接。
CLOUDFLARE_WORKER_LONG_POLL_URL="https://worker_address/long_poll_for_ip_updates"
# 定义允许访问的服务端口,多个端口用逗号分隔 (例如: "80,443,22")
SERVICE_PORTS="12345,23456"
# 定义 ipset 名称以及此脚本管理的防火墙规则的唯一注释
IPSET_V4_NAME="allowed_wan_ips_v4"
IPSET_V6_NAME="allowed_wan_ips_v6"
IPTABLES_RULE_COMMENT="dynamic_access_to_router"
# --- 你的 WAN 口接口名称 ---
# 重要: 请将 'pppoe-wan' 替换为你路由器实际的 WAN 口接口名称!
# 例如: 'eth0.2', 'pppoe-wan', 'wan' 等。
WAN_INTERFACE="pppoe-wan"
# --- 配置结束 ---
# 日志记录函数,将消息输出到控制台和文件
# 日期格式和日志消息文本都已改为英文
log_message() {
echo "$(date "+%Y-%m-%d %H:%M:%S %Z") [router-firewall] $1" | tee -a "/var/log/manage_firewall_dynamic_access.log"
}
# --- 防火墙规则管理函数 ---
# 清理所有由本脚本添加的现有 iptables/ip6tables 规则
# 它通过注释可靠地查找规则,并生成删除命令。
cleanup_existing_rules() {
log_message "Cleaning up old firewall rules with comment \"$IPTABLES_RULE_COMMENT\"."
# --- 清理 IPv4 INPUT 链规则 ---
log_message "Attempting to delete existing IPv4 INPUT rules."
# 导出规则,按注释过滤,将 -A (添加) 转换为 -D (删除),然后执行删除命令
# 注意: 即使原规则是用 -I 添加的,iptables -S 也会显示为 -A,所以这里使用 sed 's/^-A/-D/' 是安全的。
iptables -S INPUT | grep "$IPTABLES_RULE_COMMENT" | sed 's/^-A/-D/' | while read -r rule_cmd; do
log_message "Executing delete operation: iptables $rule_cmd"
if ! iptables $rule_cmd; then
log_message "Warning: Failed to delete IPv4 rule: $rule_cmd"
fi
done
# --- 清理 IPv6 INPUT 链规则 ---
if command -v ip6tables >/dev/null; then
log_message "Attempting to delete existing IPv6 INPUT rules."
# 导出规则,按注释过滤,将 -A (添加) 转换为 -D (删除),然后执行删除命令
ip6tables -S INPUT | grep "$IPTABLES_RULE_COMMENT" | sed 's/^-A/-D/' | while read -r rule_cmd; do
log_message "Executing delete operation: ip6tables $rule_cmd"
if ! ip6tables $rule_cmd; then
log_message "Warning: Failed to delete IPv6 rule: $rule_cmd"
fi
done # 修复:在这里添加了 'done' 来闭合 while 循环
else
log_message "ip6tables command not found, skipping IPv6 rule cleanup."
fi
log_message "Existing firewall rules cleanup completed."
}
# 确保 ipset 存在,如果不存在则创建它们
create_ipsets_if_not_exists() {
if ! ipset list "$IPSET_V4_NAME" &>/dev/null; then
log_message "Creating ipset $IPSET_V4_NAME (IPv4)."
if ! ipset create "$IPSET_V4_NAME" hash:ip maxelem 1024; then
log_message "Error: Failed to create ipset $IPSET_V4_NAME."
return 1
fi
fi
if ! ipset list "$IPSET_V6_NAME" &>/dev/null; then
log_message "Creating ipset $IPSET_V6_NAME (IPv6 - compatible hash:net family inet6)."
if ! ipset create "$IPSET_V6_NAME" hash:net family inet6 maxelem 1024; then
log_message "Error: Failed to create ipset $IPSET_V6_NAME."
return 1
fi
fi
return 0
}
# 根据当前配置变量添加新的防火墙规则 (IPv4 和 IPv6)
# IMPORTANT: 使用 -I 1 将规则插入到链的顶端 (位置 1)
add_firewall_rules() {
log_message "Adding new firewall rules for dynamic access (comment: '$IPTABLES_RULE_COMMENT')."
# 添加 IPv4 INPUT 规则 - 插入到最顶端
log_message "Inserting IPv4 iptables rule to ipset $IPSET_V4_NAME (top of INPUT chain)."
iptables -I INPUT 1 -i "$WAN_INTERFACE" -m set --match-set "$IPSET_V4_NAME" src -p tcp -m multiport --dports "$SERVICE_PORTS" -j ACCEPT -m comment --comment "$IPTABLES_RULE_COMMENT"
if [ $? -ne 0 ]; then
log_message "Error: Failed to add IPv4 INPUT rule. Check WAN_INTERFACE, SERVICE_PORTS values or existing iptables setup."
return 1
fi
log_message "IPv4 IPTables INPUT rule added successfully."
# 添加 IPv6 ip6tables 规则 - 插入到最顶端
if command -v ip6tables >/dev/null; then
log_message "Inserting IPv6 ip6tables rule to ipset $IPSET_V6_NAME (top of INPUT chain)."
ip6tables -I INPUT 1 -i "$WAN_INTERFACE" -m set --match-set "$IPSET_V6_NAME" src -p tcp -m multiport --dports "$SERVICE_PORTS" -j ACCEPT -m comment --comment "$IPTABLES_RULE_COMMENT"
if [ $? -ne 0 ]; then
log_message "Error: Failed to add IPv6 INPUT rule. Check WAN_INTERFACE, SERVICE_PORTS values or existing ip6tables setup."
return 1
fi
log_message "IPv6 IP6Tables INPUT rule added successfully."
else
log_message "ip6tables command not found, skipping IPv6 rule creation."
fi
log_message "Firewall rules addition completed."
return 0
}
# 通过清空 ipset 并从获取到的 JSON 响应中重新添加 IP 来更新 ipset 内容
update_ipsets_from_fetched_ips() {
local FULL_JSON_RESPONSE="$1"
# 使用 jsonfilter 提取 IP
local ALL_FETCHED_IPS=$(echo "$FULL_JSON_RESPONSE" | jsonfilter -e '@.ips[*]' -q 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$ALL_FETCHED_IPS" ]; then
log_message "Warning: Failed to extract IPs from JSON or 'ips' field not found. Response: '$FULL_JSON_RESPONSE'"
# 如果 IP 提取失败,清空 ipset 以防止允许过期的 IP
ipset flush "$IPSET_V4_NAME"
ipset flush "$IPSET_V6_NAME"
log_message "IP sets flushed due to invalid or empty IP list from Worker."
return 1
fi
log_message "Debug: All IPs extracted for update: '$ALL_FETCHED_IPS'"
local FETCHED_V4_IPS_STRING=""
local FETCHED_V6_IPS_STRING=""
for ip in $ALL_FETCHED_IPS; do
if echo "$ip" | grep -q ":"; then # 检查是否为 IPv6 地址
FETCHED_V6_IPS_STRING="$FETCHED_V6_IPS_STRING $ip"
else # 否则,假定为 IPv4 地址
FETCHED_V4_IPS_STRING="$FETCHED_V4_IPS_STRING $ip"
fi
done
log_message "Debug: Processed IPv4 IPs: $FETCHED_V4_IPS_STRING"
log_message "Debug: Processed IPv6 IPs: $FETCHED_V6_IPS_STRING"
# 清空并重新填充 IPv4 ipset
log_message "Flushing and repopulating $IPSET_V4_NAME with new IPv4 IPs."
ipset flush "$IPSET_V4_NAME"
if [ -n "$FETCHED_V4_IPS_STRING" ]; then
for ip in $FETCHED_V4_IPS_STRING; do
ipset add "$IPSET_V4_NAME" "$ip" &>/dev/null || log_message "Warning: Failed to add IPv4 IP $ip to $IPSET_V4_NAME."
done
fi
# 清空并重新填充 IPv6 ipset
log_message "Flushing and repopulating $IPSET_V6_NAME with new IPv6 IPs."
ipset flush "$IPSET_V6_NAME"
if [ -n "$FETCHED_V6_IPS_STRING" ]; then
for ip in $FETCHED_V6_IPS_STRING; do
ipset add "$IPSET_V6_NAME" "$ip" &>/dev/null || log_message "Warning: Failed to add IPv6 IP $ip to $IPSET_V6_NAME."
done
fi
log_message "IP sets updated. Firewall rules are immediately effective due to ipset binding."
# --- 日志输出: 显示当前所有被允许的 IP ---
local CURRENT_V4_IPS=$(ipset list "$IPSET_V4_NAME" 2>/dev/null | grep -E '^[0-9]{1,3}\.' | awk '{print $1}')
local CURRENT_V6_IPS=$(ipset list "$IPSET_V6_NAME" 2>/dev/null | grep -E '^([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{1,4}' | awk '{print $1}')
local ALL_CURRENT_IPS=""
if [ -n "$CURRENT_V4_IPS" ]; then
ALL_CURRENT_IPS="$CURRENT_V4_IPS"
fi
if [ -n "$CURRENT_V6_IPS" ]; then
if [ -n "$ALL_CURRENT_IPS" ]; then
ALL_CURRENT_IPS="$ALL_CURRENT_IPS $CURRENT_V6_IPS"
else
ALL_CURRENT_IPS="$CURRENT_V6_IPS"
fi
fi
if [ -n "$ALL_CURRENT_IPS" ]; then
log_message "Info: Currently allowed IPs in ipset: $ALL_CURRENT_IPS"
else
log_message "Info: No IPs currently allowed in ipset."
fi
return 0
}
# --- 辅助函数:主动同步一次最新的IP列表 ---
# 用于首次启动或长轮询中断后尝试快速恢复。
sync_latest_ips() {
log_message "Info: Initiating a quick sync to fetch the latest IP list."
local RETRIES=3 # 尝试3次
local ATTEMPT=1
local SYNC_RESPONSE=""
local SYNC_CURL_STATUS=-1
while [ "$ATTEMPT" -le "$RETRIES" ]; do
log_message "Attempting to sync IPs from Worker (attempt $ATTEMPT/$RETRIES): $CLOUDFLARE_WORKER_SYNC_URL"
SYNC_RESPONSE=$(curl -s -m 10 -H "X-Router-Secret: $SHARED_SECRET_ROUTER" "$CLOUDFLARE_WORKER_SYNC_URL")
SYNC_CURL_STATUS=$?
if [ "$SYNC_CURL_STATUS" -eq 0 ] && [ -n "$SYNC_RESPONSE" ]; then
log_message "Info: Quick sync curl successful on attempt $ATTEMPT."
break
else
log_message "Warning: Quick sync curl failed on attempt $ATTEMPT (status: $SYNC_CURL_STATUS). Retrying in 2 seconds..."
sleep 2
ATTEMPT=$((ATTEMPT + 1))
fi
done
if [ "$SYNC_CURL_STATUS" -eq 0 ] && [ -n "$SYNC_RESPONSE" ]; then
# 任何非空响应都被认为是可能的 IP 列表更新
update_ipsets_from_fetched_ips "$SYNC_RESPONSE"
return $? # 返回 update_ipsets_from_fetched_ips 的状态
else
log_message "Error: Quick sync curl failed after $RETRIES attempts. Status: $SYNC_CURL_STATUS. Response: '$SYNC_RESPONSE'"
# 即使无法同步,也要清空 ipset,避免使用过期 IP
ipset flush "$IPSET_V4_NAME"
ipset flush "$IPSET_V6_NAME"
log_message "IP sets flushed as latest sync data could not be retrieved."
return 1
fi
}
# --- 核心长轮询函数 ---
long_poll_loop() {
log_message "Entering long-poll loop, connecting to Worker: $CLOUDFLARE_WORKER_LONG_POLL_URL"
while true; do
RESPONSE=$(curl -s -m 60 -H "X-Router-Secret: $SHARED_SECRET_ROUTER" "$CLOUDFLARE_WORKER_LONG_POLL_URL")
CURL_STATUS=$?
if [ "$CURL_STATUS" -ne 0 ]; then
log_message "Error: Long-poll curl failed. Retrying in 10 seconds. Curl status: $CURL_STATUS. Response: '$RESPONSE'"
# 在重试前,尝试快速同步一次最新 IP,确保即使长轮询断开,IP 也能得到更新
sync_latest_ips
sleep 10
continue
fi
if ! command -v jsonfilter >/dev/null; then
log_message "Error: 'jsonfilter' command not found. Please install it (opkg update; opkg install jsonfilter)."
log_message "Error: Cannot parse JSON response. Exiting long-poll."
return 1
fi
local FETCHED_STATUS=$(echo "$RESPONSE" | jsonfilter -e '@.status' -q 2>/dev/null)
if [ -z "$FETCHED_STATUS" ]; then
log_message "Error: Could not parse status from JSON response. Response: '$RESPONSE'"
log_message "Warning: Due to malformed response, retrying long-poll in 5 seconds."
sleep 5
continue
fi
if [ "$FETCHED_STATUS" = "updated" ]; then
log_message "Info: IP update received via long-poll. Updating ipset."
update_ipsets_from_fetched_ips "$RESPONSE"
elif [ "$FETCHED_STATUS" = "timeout" ]; then
log_message "Info: Long-poll timed out. No new IP updates. Re-polling..."
else
log_message "Error: Unexpected status '$FETCHED_STATUS' received from Worker. Response: '$RESPONSE'"
log_message "Warning: Due to unexpected response, retrying long-poll in 5 seconds."
sleep 5
fi
done
}
# --- 脚本主入口点 ---
# 'init' 模式: 用于路由器启动设置或首次部署。
# 此模式确保 ipset 和防火墙规则被正确设置一次。
if [ "$1" = "init" ]; then
log_message "Initializing ipset and firewall rules (init mode)."
# 1. 清理本脚本管理的所有现有规则
cleanup_existing_rules
# 2. 确保 ipset 存在 (并在必要时创建)
if ! create_ipsets_if_not_exists; then
log_message "Fatal Error: Failed to initialize ipset in init mode. Exiting init mode."
exit 1
fi
# 3. 清空 ipset 以确保初始 IP 填充时状态干净
log_message "Flushing ipset for a clean initialization."
ipset flush "$IPSET_V4_NAME"
ipset flush "$IPSET_V6_NAME"
# 4. 根据当前脚本配置添加防火墙规则
if ! add_firewall_rules; then
log_message "Fatal Error: Failed to add firewall rules in init mode. Exiting init mode."
exit 1
fi
# 5. 重新加载防火墙服务,以确保所有规则在 OpenWrt 配置中完全应用并持久化
log_message "Triggering firewall reload to ensure all rules are fully applied."
uci commit firewall && /etc/init.d/firewall reload
log_message "Init setup completed. Router is ready for dynamic IP updates."
# 'run' 模式: 启动动态 IP 更新过程。
# 此模式应作为后台守护进程运行,以持续通过长轮询获取 IP 更新。
elif [ "$1" = "run" ]; then
log_message "Starting dynamic firewall IP update process (run mode)."
# 1. 在启动 run 模式时,先进行一次快速同步,确保规则和 IP 都是最新的
# 这在路由器启动后,长轮询循环开始前,提供一个初始状态。
log_message "Run mode started, performing initial quick sync."
sync_latest_ips
# 2. 确保 ipsets 和防火墙规则存在 (这在 init 之后可能不是严格必需的,但增加了健壮性)
if ! create_ipsets_if_not_exists; then
log_message "Fatal Error: Failed to initialize ipset in run mode. Exiting."
exit 1
fi
# 确保规则也存在且是最新(如果配置有变动)
cleanup_existing_rules
add_firewall_rules
# 3. 进入长轮询循环,持续获取 IP 更新
long_poll_loop
log_message "Dynamic IP update process terminated unexpectedly."
else
log_message "Usage: $0 [init|run]"
log_message " init: Initializes ipset and manages firewall rules (run once on boot or deployment)."
log_message " run: Executes the main dynamic IP update (for background daemon)."
fi
配合iOS快捷指令
不幸的是iOS缺少在系统后台自动访问Web API的功能,所以无法实现手机IP实时自动上报。我部署这套系统的本意是为了方便老婆使用MT Photos的客户端,发现可以设置一个快捷指令自动化,在打开MT Photos App时自动后台访问Worker脚本。
实测完全无感,打开MT Photos再定位到搜索框,输入想要搜索的照片关键词,在按回车键前路由器已经将手机的IP加入到白名单了
结语
感概一下,如今的AI写这些小脚本真是厉害,完全不会编程也没关系,告诉它需求就行