惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
阮一峰的网络日志
阮一峰的网络日志
T
Tailwind CSS Blog
博客园 - 【当耐特】
量子位
博客园 - 叶小钗
有赞技术团队
有赞技术团队
Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - Franky
博客园 - 司徒正美
爱范儿
爱范儿
美团技术团队
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
V
V2EX
罗磊的独立博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Last Week in AI
Last Week in AI
Hugging Face - Blog
Hugging Face - Blog
I
InfoQ
D
DataBreaches.Net
宝玉的分享
宝玉的分享

V2EX

我用 AI 写代码,但终端管理反而成了累赘——于是我做了 codux [调研] 各位在公司都用什么 ide 和 agent 写代码? 老运维 share 一个运维平台 看到有公司考核 token 指标,很好奇大家上个月的 AI 账单是多少 GLM-Coding 调用持续报错: z.ai 的 Lite 套餐几乎无法使用,官方 Pro/Max 是否稳定? 现在还有什么渠道可以稳定安全地使用 Claude 吗? 上海漕河泾内推,本组有 2 个 hc,一个后端,一个前端,预算都是 20k 左右,不打卡,氛围好 如果 V2EX 上有一组不永久保存聊天记录(比如只保存 7 天或者 24 小时)的聊天室,那么会开启哪些有用或者有趣的可能? gemini cli 貌似挂了,一直返回 403 第一次在自媒体上赚到钱 收集了最近在使用的低价 GPT, Gemini,邮箱等 AI 会员的小店合集 讨论个大实话:现在企业还在说 AI 编程提效 20%, 30%的,真的太落后,没用懂 AI。因为包括很多前沿公司,已经狂奔到提效 200%-500%的情况 [招聘][远程][币安] 前端/后端/QA/iOS/Android 至少 3 年以上经验 目前有大量 HC 欢迎投递 Chatgpt Pro 用量用不完的可以开这些设置 面试的时候好像遇到钓鱼了,给各位避个坑 cursor 年续费 22 号到期, 自动续费是否还是老的计次套餐呢 被两件破事毁掉的一下午,琐碎的内耗消磨人的精力 使用 Planet 存储 Codex 的会话或者重要信息 如果业务部门领导不要你开发功能,而是要求你教会它用 claude code 开发功能,你会怎么做? 分享一个 MacOS 接绿联 CM818 USB 转 DP 转接器使用感受 我的 HR 朋友 10 年老 Java ,非全大专,大家帮忙看看简历 开源了一个 AI 口语练习工具,音素级发音评分,完全免费可自部署 V2EX 上有哪些你觉得很有趣、印象深刻的妹纸? 字节为啥不出个国内版 Vercel? 有在大马的朋友吗? 问个运营商问题 你们在有领导的公司大群发过的最大胆的消息是什么 公司裁员,目前没有工作。想试试摆摊,做一个移动鲜啤打酒车 我的硬盘 Memblaze Pblaze 5 Linux 下不识别,给 Linux 内核提交了补丁, AI 说有望被合并 只有我一个人觉得 codex 不好用?
stun 打洞+cloudflare 回源规则,将本地服务放进公网
LongLights · 2026-05-26 · via V2EX

原理及效果

先说效果:本地路由器或 NAS 上部署的如 vaultwarden 这类服务,即使没有公网 ipv4 ,只要有 nat1 (也就是 fullcone 全锥形网络),即可通过 cloudflare 的动态 dns 及回源规则,配合 stun 打洞实现近似公网使用的效果

原理:通过 stun 打洞将本地服务暴露至公网(本方案使用 lucky 工具,通过触发脚本实现动态 dns 更新及回源规则更新,将打洞获得的公网端口更新至 cloudflare 回源规则)

流量路径:用户访问 -> cloudflare -> origin rules 的动态端口 -> lucky 主机的 ip:穿透通道本地端口 -> 部署服务的主机 ip:本地服务端口

如果本文对您有帮助,希望能支持一下我的个人博客: https://ugediao.com/

准备工作

  1. 本地网络开启 fullcone ,iStoreOS 及大多数 openwrt 固件、iKuai 均可一键开启

  2. 部署好本地服务后,在路由器的防火墙规则添加对应的端口转发规则(这里是局域网的固定端口)

  3. 确保 lucky 运行的终端已安装 curl 及 jq

opkg update

opkg install curl

opkg install jq

最后:安装 lucky ,iStoreOS 可以在软件商城一件安装,其余请参考lucky 安装文档

操作步骤

zone_id 在进入 cloudflare 的域名管理页面右下角

  • 确认需要暴露的服务已参考准备工作 2 添加好防火墙转发规则

  • 通过 lucky 开启 stun 打洞并填写触发脚本

务必全部按图设置,不要使用 lucky 内置端口转发,而必须通过路由器的防火墙端口转发,触发脚本需要填写 4 个位置,分别是服务名称(随意)、域名、zoneid 及 api 令牌,以下是完整脚本:

SERVICE_NAME="<填写你的服务名称>"
DOMAIN_NAME="<填写你的域名如 aa.bb.com>"
CF_ZONE_ID="<cloudflare 的 zone_id>"
CF_TOKEN="<刚才获取的 api 令牌>"

# 最大重试次数
MAX_RETRIES=10
RETRY_DELAY=5

# =======================================================
# 2. 接收 Lucky 变量并更新“最新状态”
# =======================================================
# 接收 Lucky 传入的原始变量
INPUT_IP="${ip}"
INPUT_PORT="${port}"

# 定义状态文件路径 (每个服务独立)
STATE_FILE="/tmp/lucky_state_${SERVICE_NAME}.info"

#  [核心修复步骤 1 ] 
# 脚本一启动,立即将最新收到的参数写入状态文件。
# 无论后续排队多久,所有排队的脚本最终读取的都是最后一次写入的文件内容。
echo "${INPUT_IP} ${INPUT_PORT}" > "$STATE_FILE"

# =======================================================
# 3. 全局锁与日志
# =======================================================
GLOBAL_LOCK_FILE="/tmp/lucky_cloudflare_global_update.lock"
LOG_FILE="/tmp/lucky_cf_update.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')][${SERVICE_NAME}] $1" >> "$LOG_FILE"
}

safe_curl() {
    local method="$1"
    local url="$2"
    local data="$3"
    local count=0
    local response=""

    while [ $count -lt $MAX_RETRIES ]; do
        if [ -n "$data" ]; then
            response=$(curl -s -X "$method" "$url" \
                -H "Authorization: Bearer $CF_TOKEN" \
                -H "Content-Type: application/json" \
                --data "$data")
        else
            response=$(curl -s -X "$method" "$url" \
                -H "Authorization: Bearer $CF_TOKEN" \
                -H "Content-Type: application/json")
        fi

        if echo "$response" | grep -q "success"; then
            echo "$response"
            return 0
        fi

        count=$((count + 1))
        # 如果是连接被拒绝等严重网络错误,稍微多等一会
        sleep $RETRY_DELAY
    done
    
    log "错误: API 请求失败 ($url)"
    return 1
}

# =======================================================
# 4. 后台执行逻辑
# =======================================================
(
    # 随机延时 (保留原有逻辑,缓解并发)
    RANDOM_DELAY=$(awk 'BEGIN{srand(); print int(rand()*3)}')
    sleep $RANDOM_DELAY

    # --- 获取全局锁 ---
    LOCK_WAIT_COUNT=0
    while [ -f "$GLOBAL_LOCK_FILE" ]; do
        LOCK_TIME=$(date -r "$GLOBAL_LOCK_FILE" +%s)
        NOW_TIME=$(date +%s)
        # 锁超时检查 (120 秒)
        if [ $((NOW_TIME - LOCK_TIME)) -gt 120 ]; then
            log "检测到死锁,强制释放"
            rm -f "$GLOBAL_LOCK_FILE"
            break
        fi
        
        if [ $LOCK_WAIT_COUNT -gt 60 ]; then
             log "排队超时,放弃本次执行"
             exit 0
        fi
        
        sleep 2
        LOCK_WAIT_COUNT=$((LOCK_WAIT_COUNT + 1))
    done
    
    touch "$GLOBAL_LOCK_FILE"
    trap "rm -f '$GLOBAL_LOCK_FILE'; exit" EXIT TERM INT

    # 日志轮转
    [ -f "$LOG_FILE" ] && [ $(wc -c < "$LOG_FILE") -gt 100000 ] && echo "" > "$LOG_FILE"

    #  [核心修复步骤 2 ] 
    # 拿到锁之后,不使用自己的变量,而是从状态文件读取“真正的最新值”
    if [ -f "$STATE_FILE" ]; then
        read TARGET_IP TARGET_PORT < "$STATE_FILE"
    else
        log "错误: 状态文件丢失"
        rm -f "$GLOBAL_LOCK_FILE"
        exit 1
    fi

    if [ -z "$TARGET_IP" ] || [ -z "$TARGET_PORT" ]; then
        log "错误: 状态文件内容为空"
        rm -f "$GLOBAL_LOCK_FILE"
        exit 1
    fi

    log "开始处理 (最新目标): $DOMAIN_NAME -> $TARGET_IP:$TARGET_PORT"

    # --- A. 更新 DNS A 记录 ---
    DNS_RES=$(safe_curl "GET" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?type=A&name=$DOMAIN_NAME" "")
    if [ $? -ne 0 ]; then rm -f "$GLOBAL_LOCK_FILE"; exit 1; fi

    DNS_ID=$(echo "$DNS_RES" | jq -r '.result[0].id')
    CURRENT_DNS_IP=$(echo "$DNS_RES" | jq -r '.result[0].content')

    if [ "$DNS_ID" = "null" ]; then
        log "DNS 记录不存在,创建中..."
        safe_curl "POST" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
            "{\"type\":\"A\",\"name\":\"$DOMAIN_NAME\",\"content\":\"$TARGET_IP\",\"ttl\":60,\"proxied\":true}" > /dev/null
    elif [ "$CURRENT_DNS_IP" != "$TARGET_IP" ]; then
        log "更新 DNS IP ($CURRENT_DNS_IP -> $TARGET_IP)..."
        safe_curl "PATCH" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$DNS_ID" \
            "{\"content\":\"$TARGET_IP\"}" > /dev/null
    else
        # log "DNS IP 无需更新" # 减少日志噪音
        :
    fi

    # --- B. 更新 Origin Rules ---
    PHASE_RES=$(safe_curl "GET" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/rulesets/phases/http_request_origin/entrypoint" "")
    if [ $? -ne 0 ]; then rm -f "$GLOBAL_LOCK_FILE"; exit 1; fi
    RULESET_ID=$(echo "$PHASE_RES" | jq -r '.result.id')

    if [ "$RULESET_ID" != "null" ]; then
        RULES_RES=$(safe_curl "GET" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/rulesets/$RULESET_ID" "")
        if [ $? -ne 0 ]; then rm -f "$GLOBAL_LOCK_FILE"; exit 1; fi

        # 查找同名规则
        # 获取 Rule ID 和当前规则中设定的端口
        TARGET_RULE_DATA=$(echo "$RULES_RES" | jq -r --arg name "$SERVICE_NAME" '(.result.rules // [])[] | select(.description == $name) | "\(.id)|\(.action_parameters.origin.port // 0)"')
        
        # 处理多条规则重复的情况,只取最后一条,其他的并在后面逻辑清理
        TARGET_RULE_ID=$(echo "$TARGET_RULE_DATA" | tail -n 1 | cut -d "|" -f 1)
        CURRENT_RULE_PORT=$(echo "$TARGET_RULE_DATA" | tail -n 1 | cut -d "|" -f 2)
        
        # 构造 Payload
        PAYLOAD=$(jq -n \
                    --arg desc "$SERVICE_NAME" \
                    --arg domain "$DOMAIN_NAME" \
                    --argjson port "$TARGET_PORT" \
                    '{
                        description: $desc,
                        expression: ("( http.host eq \"" + $domain + "\")"),
                        action: "route",
                        action_parameters: {origin: {port: $port}}
                    }')

        if [ -z "$TARGET_RULE_ID" ]; then
            log "规则不存在,新建规则..."
            safe_curl "POST" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/rulesets/$RULESET_ID/rules" "$PAYLOAD" > /dev/null
        
        else
            #  [优化] 只有当 CF 里的端口 和 目标端口 不一致时才调用 API
            if [ "$CURRENT_RULE_PORT" != "$TARGET_PORT" ]; then
                log "端口变更 ($CURRENT_RULE_PORT -> $TARGET_PORT),更新规则..."
                safe_curl "PATCH" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/rulesets/$RULESET_ID/rules/$TARGET_RULE_ID" "$PAYLOAD" > /dev/null
            else
                log "规则端口 ($CURRENT_RULE_PORT) 已是最新,跳过更新。"
            fi

            # 清理重复规则 (如果有多个同名规则)
            ALL_IDS=$(echo "$RULES_RES" | jq -r --arg name "$SERVICE_NAME" '(.result.rules // [])[] | select(.description == $name) | .id')
            for id in $ALL_IDS; do
                if [ "$id" != "$TARGET_RULE_ID" ]; then
                     log "发现冗余规则,删除 ID: $id"
                     safe_curl "DELETE" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/rulesets/$RULESET_ID/rules/$id" "" > /dev/null
                fi
            done
        fi
    else
        log "错误: 无法获取 Ruleset ID"
    fi
    
    rm -f "$GLOBAL_LOCK_FILE"

) >/dev/null 2>&1 &

echo "后台更新任务已排队触发 (State: $INPUT_PORT)"
exit 0
  • lucky 穿透成功后,可以去 cloudflare 后台确认 dns 解析和 origin rules 是否生效

  • 确认是否可以通过域名直接访问你的本地服务

补充说明

使用本方案进行 stun 穿透,必须保证本地网络连接 stun 服务器是通过直连(保证 3478 端口直连)