之前使用的端口敲门技术来限制未知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写这些小脚本真是厉害,完全不会编程也没关系,告诉它需求就行