stun 打洞+cloudflare 回源规则,将本地服务放进公网 - V2EX
LongLights

stun 打洞+cloudflare 回源规则,将本地服务放进公网

  •  
  •   LongLights 4 days ago 1703 views

    原理及效果

    先说效果:本地路由器或 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 端口直连)

    13 replies    2026-05-30 22:29:14 +08:00
    109653VIP
        1
    109653VIP  
       4 days ago
    终于搓了个脚本出来
    bechtelarjoey1
        2
    bechtelarjoey1  
      &nsp;3 days ago
    natmap 不是早就有了一样的脚本吗,推送钉钉 server 酱也有 cf ddns+origin rules 的,还搞个 lucky 的纯多余了
    conky
        3
    conky  
       3 days ago   1
    这么麻烦吗?为什么不直接用 CF tunnel
    1018ji
        4
    1018ji  
       3 days ago
    高级,学不会,还是用公网 ipv4 吧
    eber
        5
    eber  
       3 days ago
    真费劲,直接 cloudflare tunnel + ip 优选就行了。
    kenX
        6
    kenX  
       3 days ago
    我不理解,这么麻烦?和 cloudflared 比有什么优势
    justmiho
        7
    justmiho  
       3 days ago
    @conky CF tunnel 肯定是不如打洞直连来得顺畅的
    justmiho
        8
    justmiho  
       3 days ago
    @kenX 相当于公网 IP 直连,在一个城市,延迟个位数
    kenX
        9
    kenX  
       3 days ago
    @justmiho 真的吗?这都 origin rules 回源了,还能直连?
    justmiho
        10
    justmiho  
       3 days ago
    @kenX 光用 stun 打洞可以实现直连,但是端口随机并且会变,可以再用 cf 重定向
    zhouhuade
        11
    zhouhuade  
       2 days ago
    这个方案用了蛮久的,挺好用的
    flynaj
        12
    flynaj  
       1 day ago via Android
    natmap 不是更简单,还是自动化的。
    sh1nyan
        13
    sh1nyan  
       1h 49m ago
    感谢分享,空了研究下,目前在用 tunnel
    About     Help     Advertise     Blog     API     FAQ     Solana     1664 Online   Highest 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 50ms UTC 16:18 PVG 00:18 LAX 09:18 JFK 12:18
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86