原理及效果
先说效果:本地路由器或NAS上部署的如vaultwarden这类服务,即使没有公网ipv4,只要有nat1(也就是fullcone全锥形网络),即可通过cloudflare的动态dns及回源规则,配合stun打洞实现近似公网使用的效果
原理:通过stun打洞将本地服务暴露至公网(本方案使用lucky工具,通过触发脚本实现动态dns更新及回源规则更新,将打洞获得的公网端口更新至cloudflare回源规则)
流量路径:用户访问 -> cloudflare -> origin rules的动态端口 -> lucky主机的ip:穿透通道本地端口 -> 部署服务的主机ip:本地服务端口
准备工作
-
确保lucky运行的终端已安装curl及jq
opkg update opkg install curl opkg install jq -
安装lucky,iStoreOS可以在软件商城一件安装,其余请参考lucky安装文档
操作步骤
- 获得cloudflare的zone_id并创建一个api令牌
api令牌创建:https://dash.cloudflare.com/profile/api-tokens
权限需要两个:DNS和Origin Rules

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

务必全部按图设置,不要使用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
补充说明
使用本方案进行stun穿透,必须保证本地网络连接stun服务器是通过直连(保证3478端口直连)



