写在前面,全文由 ANY大善人的opus4.7主导
IPV4 NEWAPI
IPV6NEW直连
佬友们好。分享一下我这边的家宽双栈对外方案,脱敏整理出来给有同样需求的同学参考。
我家这边情况大概是:
- 广东移动运营商给了公网 IPv6,但 IPv4 是大内网 NAT1,没固定公网 IPv4
- 想把家里 5 个 Web 服务(api / cpa / yx / code / claw)和 1 个 VPN 挂到公网
- 要兼顾 公司 IPv4 (办公网、IPv4-only 环境)和 IPv6 用户
- 不想为这事单租 VPS
折腾完之后稳定跑了一阵,整理出来发上来。所有真实域名、公网 IP、动态端口、密钥都用 <...> 占位符替换了,自己用的时候换成真值即可。
整套方案的免费成本:Cloudflare DNS + 307 Redirect Rule 在免费计划够用,Lucky 是开源的。零 VPS 开支。
一、整体思路
核心三招:
- 双域名分离:入口域名(橙云)和落地域名(灰云)分开。入口只做 307 重定向,不直连后端;落地域名灰云直接 DNS 解析到家里。
- 双栈分流:IPv4 客户端被 307 重定向到
service.stun.<ROOT_DOMAIN>:<STUN_TCP4_PORT>(家宽 IPv4 不能直 443,只能走 Lucky STUN 拿到的动态端口);IPv6 客户端被 307 重定向到service.stun.<ROOT_DOMAIN>(默认 443,直连 OpenWrt 公网 IPv6)。 - VPN 独立:VPN 不走 307,因为 WireGuard 不理解 HTTP 重定向。VPN 用独立的灰云 AAAA 直连。
二、主链路图
┌────────────────────────────────────┐
│ Client / Browser / App │
│ service.<ROOT_DOMAIN> │
└──────────────────┬─────────────────┘
│
┌──────────────────▼─────────────────┐
│ Cloudflare │
│ proxied entry records │
│ Dynamic Redirect, HTTP 307 │
└──────────────────┬─────────────────┘
│
┌────────────────────────────────────────────────┼────────────────────────────────────────────────┐
│ │ │
┌───────▼────────────────────┐ ┌──────────▼──────────────────┐ ┌──────────▼──────────────────┐
│ IPv4 client │ │ IPv6 client │ │ VPN client │
│ target should include port │ │ target uses default 443 │ │ no HTTP redirect │
└───────┬────────────────────┘ └──────────┬──────────────────┘ └──────────┬──────────────────┘
│ 307 Location: │ 307 Location: │
│ https://service.stun.<ROOT_DOMAIN>:<STUN_PORT>/path │ https://service.stun.<ROOT_DOMAIN>/path │
│ │ │
┌───────▼────────────────────┐ ┌──────────▼──────────────────┐ ┌──────────▼──────────────────┐
│ DNS-only A │ │ DNS-only AAAA │ │ DNS-only AAAA │
│ service.stun.<ROOT_DOMAIN> │ │ service.stun.<ROOT_DOMAIN> │ │ vpn.<ROOT_DOMAIN> │
│ -> STUN public IPv4 │ │ -> OpenWrt public IPv6 │ │ -> OpenWrt public IPv6 │
└───────┬────────────────────┘ └──────────┬──────────────────┘ └──────────┬──────────────────┘
│ │ │
┌───────▼────────────────────┐ ┌──────────▼──────────────────┐ ┌──────────▼──────────────────┐
│ Lucky STUN tcp4 │ │ OpenWrt │ │ OpenWrt VPN service │
│ public IPv4:<STUN_PORT> │ │ Lucky HTTPS :443 │ │ WireGuard/OpenVPN/etc. │
│ -> Lucky HTTPS :18443 │ │ cert *.stun.<ROOT_DOMAIN> │ │ protocol-specific port │
└───────┬────────────────────┘ └──────────┬──────────────────┘ └─────────────────────────────┘
│ │
└───────────────────────────────┬────────────────┘
│
┌──────────────▼───────────────┐
│ OpenWrt Lucky reverse proxy │
│ same Host rules on 443/18443 │
│ service.stun.<ROOT_DOMAIN> │
│ -> backend │
└──────────────┬───────────────┘
│
┌───────────────────────────────┼───────────────────────────────┬───────────────────────────────┐
│ │ │ │
┌───────▼────────────────────┐ ┌────────▼───────────────────┐ ┌────────▼───────────────────┐ ┌────────▼───────────────────┐
│ api.stun.<ROOT_DOMAIN> │ │ cpa.stun.<ROOT_DOMAIN> │ │ yx.stun.<ROOT_DOMAIN> │ │ code.stun.<ROOT_DOMAIN> │
│ -> <APP_NODE_A>:8881 │ │ -> <APP_NODE_A>:8317 │ │ -> <APP_NODE_A>:5001 │ │ -> <APP_NODE_B>:19080 │
│ NewAPI │ │ CPA / Proxy API │ │ YX Web │ │ code-server │
└────────────────────────────┘ └────────────────────────────┘ └────────────────────────────┘ └────────────────────────────┘
│
┌──────────────▼───────────────┐
│ claw.stun.<ROOT_DOMAIN> │
│ -> <APP_NODE_B>:18789 │
│ OpenClaw Gateway │
└──────────────────────────────┘
解读:
- 客户端先打入口域名(橙云),被 Cloudflare 307 到对应的 stun 落地域名
- IPv4 客户端拿到
https://service.stun.<ROOT_DOMAIN>:<STUN_TCP4_PORT>/...,解析到 Lucky STUN 公网 IPv4:动态端口,进 Lucky 反代 - IPv6 客户端拿到
https://service.stun.<ROOT_DOMAIN>/...(443),解析到 OpenWrt 公网 IPv6,进 Lucky 反代 - 两条路最后都汇到 OpenWrt Lucky 反代,按 Host 把请求转给内网真正的后端服务
- VPN 走自己那条路,跟 HTTP 没关系
三、DNS 怎么设
入口域名(5 个)—— 橙云
api.<ROOT_DOMAIN> A proxied=true TTL=auto
cpa.<ROOT_DOMAIN> A proxied=true TTL=auto
yx.<ROOT_DOMAIN> A proxied=true TTL=auto
code.<ROOT_DOMAIN> A proxied=true TTL=auto
claw.<ROOT_DOMAIN> A proxied=true TTL=auto
为什么橙云:必须橙云,Cloudflare 只有收到 HTTP 请求后才能执行 Redirect Rule。灰云的话客户端绕过 CF 直接打到 A 记录,Redirect Rule 根本触发不了。入口的 A content 没那么重要(反正是 CF 边缘 IP 收的),不需要指向家里。
落地域名(5 个 + 1 个 VPN)—— 灰云
api.stun.<ROOT_DOMAIN> A=<STUN_PUBLIC_IPV4> AAAA=<OPENWRT_PUBLIC_IPV6> proxied=false TTL=60
cpa.stun.<ROOT_DOMAIN> A=<STUN_PUBLIC_IPV4> AAAA=<OPENWRT_PUBLIC_IPV6> proxied=false TTL=60
yx.stun.<ROOT_DOMAIN> A=<STUN_PUBLIC_IPV4> AAAA=<OPENWRT_PUBLIC_IPV6> proxied=false TTL=60
code.stun.<ROOT_DOMAIN> A=<STUN_PUBLIC_IPV4> AAAA=<OPENWRT_PUBLIC_IPV6> proxied=false TTL=60
claw.stun.<ROOT_DOMAIN> A=<STUN_PUBLIC_IPV4> AAAA=<OPENWRT_PUBLIC_IPV6> proxied=false TTL=60
vpn.<ROOT_DOMAIN> AAAA=<OPENWRT_PUBLIC_IPV6> proxied=false TTL=60
为什么灰云 + TTL 60:307 之后客户端要直连家里 Lucky,必须灰云让 DNS 真实解析过去。TTL 60 是为了 STUN 公网地址变化时尽快收敛(用 LuckyDDNS 自动更新 DNS A 记录)。5 个落地域名的 A 都指向 Lucky STUN 探出来的公网 IPv4,AAAA 都指向 OpenWrt 公网 IPv6,这样一张通配证书就能盖住。
四、Cloudflare Redirect Rule
Cloudflare 控制台 → Rules → Redirect Rules,建一个 Dynamic Redirect Ruleset,phase 是 http_request_dynamic_redirect。整体设置:
status_code: 307
preserve_query_string: true
为什么 307:307 会保留 HTTP 方法(POST/PUT/PATCH),API 调用、表单提交、code-server 的 PUT 都不会被吃掉。301/302 在某些客户端会把 POST 改成 GET,直接坑死。
为什么 preserve_query_string:不开的话 ?token=...、?folder=... 这种查询参数全丢,API 和 code-server 直接报错。
下面三条规则,按顺序放。
规则 1:cpa 根路径补全
match:
http.host == "cpa.<ROOT_DOMAIN>" and path == "/"
target:
https://cpa.<ROOT_DOMAIN>/management.html
为什么这么设:cpa 的根路径默认不会跳到管理页,先把 / 补成 /management.html,后面通用规则继续接管把它跳到 stun 落地域名。
规则 2:IPv4 入口加 STUN 端口
match:
http.host in {api.<ROOT_DOMAIN>, cpa.<ROOT_DOMAIN>, yx.<ROOT_DOMAIN>, code.<ROOT_DOMAIN>, claw.<ROOT_DOMAIN>}
and ip.src in 0.0.0.0/0
target expression:
wildcard_replace(
http.request.full_uri,
"*://*.<ROOT_DOMAIN>/*",
"https://${2}.stun.<ROOT_DOMAIN>:<STUN_TCP4_PORT>/${3}"
)
为什么这么设:ip.src in 0.0.0.0/0 是 IPv4-only 匹配。家宽 IPv4 走不了 443,必须把 Lucky STUN 当前公网端口写到 Location 里。<STUN_TCP4_PORT> 是动态值,要靠脚本或 Webhook 同步过来(见第九节坑 2)。
规则 3:IPv6 入口直接 443
match:
http.host in {api.<ROOT_DOMAIN>, cpa.<ROOT_DOMAIN>, yx.<ROOT_DOMAIN>, code.<ROOT_DOMAIN>, claw.<ROOT_DOMAIN>}
and not ip.src in 0.0.0.0/0
target expression:
wildcard_replace(
http.request.full_uri,
"*://*.<ROOT_DOMAIN>/*",
"https://${2}.stun.<ROOT_DOMAIN>/${3}"
)
为什么这么设:IPv6 客户端可以直达家里 OpenWrt 公网 IPv6 的 443,不需要动态端口,省事。
五、Lucky 配置
STUN tcp4
类型: tcp4
Target: <OPENWRT_LAN_IP>:18443 (指向 Lucky 自己的 HTTPS 监听口,不是 443)
PublicAddr (Lucky 探出来): <STUN_PUBLIC_IPV4>:<STUN_TCP4_PORT>
为什么 Target 是 18443 不是 443:家宽 IPv4/443 实测不通,IPv4 数据面只能走 Lucky 自己单独起的 HTTPS 监听口 18443。443 留给 IPv6 直连用。
端口同步:STUN 端口是运营商分配的,动态值。Lucky 探到新端口后必须同步到 Cloudflare Redirect Rule 的规则 2。两种方式:
- Lucky Webhook → 调用 Cloudflare API PATCH Ruleset
- 外部脚本轮询 Lucky API → PATCH Ruleset
同步完后必须用 curl -4 -I 从外部复测 Location(见第九节坑 3,API 和边缘可能短暂不一致)。
HTTPS 双监听
Lucky 反代要同时挂两个 HTTPS 监听口:
监听 1: <OPENWRT_PUBLIC_IPV6>:443 给 IPv6 客户端直连
监听 2: <OPENWRT_LAN_IP>:18443 给 STUN tcp4 把 IPv4 流量打进来
两个口共用同一组反代规则,因为 IPv4 和 IPv6 路径最后到 Lucky 时 Host 都是 *.stun.<ROOT_DOMAIN>。
通配证书
类型: 通配证书 *.stun.<ROOT_DOMAIN>
签发方式: DNS-01
DNS Provider: Cloudflare (用 API Token,只给 Zone DNS Edit 权限)
为什么 DNS-01:HTTP-01 要 80/443 开放给 ACME,家宽 80/443 路径本来就不全通,DNS-01 不依赖入站端口,能自动续签。一张通配盖住 5 个 stun 落地域名。
反代规则
每个 stun 落地 Host 对一个内网后端:
api.stun.<ROOT_DOMAIN> -> http://<APP_NODE_A>:8881 (NewAPI)
cpa.stun.<ROOT_DOMAIN> -> http://<APP_NODE_A>:8317 (CPA / Proxy API)
yx.stun.<ROOT_DOMAIN> -> http://<APP_NODE_A>:5001 (YX Web)
code.stun.<ROOT_DOMAIN> -> http://<APP_NODE_B>:19080 (code-server)
claw.stun.<ROOT_DOMAIN> -> http://<APP_NODE_B>:18789 (OpenClaw Gateway)
公共选项:
前端协议: https
后端协议: http (TLS 在 Lucky 终止,后端走明文省事,不用每个后端搞证书)
WebSocket: 开 (code-server 这种长连接工具必须开)
自动反代重定向: 关 (避免后端 Location 头被二次改写,排障痛苦)
访问日志: 开 (出问题方便看)
为什么 TLS 在 Lucky 终止:证书、SNI、续签集中维护一处,后端跑明文 HTTP 反而干净。
六、认证保护
公网第一道认证放在 Lucky,不放在 Cloudflare 入口层(CF 只做 307 不挡):
api.stun.<ROOT_DOMAIN> Lucky Basic Auth: 关 后端自己有登录页 (NewAPI)
cpa.stun.<ROOT_DOMAIN> Lucky Basic Auth: 开 后端通过 Basic Auth 后到 CPA API
yx.stun.<ROOT_DOMAIN> Lucky Basic Auth: 开 后端通过 Basic Auth 后跳 /login
code.stun.<ROOT_DOMAIN> Lucky Basic Auth: 开 code-server 后端 auth=none
claw.stun.<ROOT_DOMAIN> Lucky Basic Auth: 开 后端是 OpenClaw Gateway UI
为什么 api 不开:NewAPI 自带账号系统,再叠一层 Basic Auth 反而碍事。
为什么其他都开:cpa/yx 是私有管理类入口,code-server 直接是命令执行入口(裸露公网 = 送服务器),claw 是网关 UI。统一拿 Lucky Basic Auth 当公网第一道挡板,简单粗暴。
为什么 code-server 设 auth=none:code-server 自带密码方案不够灵活,统一交给前置 Basic Auth 处理。前提是 Lucky Basic Auth 必须开,否则 code-server 在公网完全没保护。
顺嘴提一下:我账号下还有 2 个 Cloudflare Access SaaS/OIDC App,但那是给 Lucky 自己做第三方登录用的回调端点,不是业务入口拦截页。业务入口前没有 CF Access。
七、VPN 为什么不能套 307
vpn.<ROOT_DOMAIN> AAAA -> <OPENWRT_PUBLIC_IPV6> 灰云
直接灰云 AAAA 指向 OpenWrt 公网 IPv6,按 VPN 自己协议端口(WireGuard / OpenVPN / IPsec)连。
为什么不能走 307:307 是 HTTP 状态码,只有浏览器/curl 会跟随 Location。WireGuard/OpenVPN 客户端根本不解析 HTTP,把 VPN 域名扔进 Cloudflare 橙云 → 客户端连不上 → 超时。VPN、SSH、其他非 HTTP 协议都不要套 Cloudflare 307。
八、验收 curl
每条命令独立可复制粘贴,把 <...> 换成真值即可。
# 入口域名是不是返回 307
curl -I https://api.<ROOT_DOMAIN>/some-path
# IPv4 Location 必须带 :<STUN_TCP4_PORT>
curl -4 -I https://api.<ROOT_DOMAIN>/some-path
# IPv6 Location 应该不带端口
curl -6 -I https://api.<ROOT_DOMAIN>/some-path
# IPv4 STUN 落地是否可用
curl -4 -k -I https://api.stun.<ROOT_DOMAIN>:<STUN_TCP4_PORT>/
# IPv6 443 落地是否可用
curl -6 -k -I https://api.stun.<ROOT_DOMAIN>/
# Lucky Host 规则是否命中 (LAN 内测)
curl -k -H "Host: api.stun.<ROOT_DOMAIN>" https://<OPENWRT_LAN_IP>:18443/
# Basic Auth 是否生效 (期望 401 + WWW-Authenticate)
curl -I https://code.stun.<ROOT_DOMAIN>:<STUN_TCP4_PORT>/
九、踩过的坑
坑 1:IPv4 走不了 443
家宽 IPv4 是大内网 NAT,公网 443 不通。一开始以为 IPv4 也能走 443,折腾半天 timeout。最后老老实实用 Lucky STUN tcp4 拿一个动态公网端口,把 Cloudflare Redirect 的 Location 写成 :<STUN_TCP4_PORT> 才通。
坑 2:STUN 端口是动态的
Lucky STUN PublicAddr 的端口运营商会换。如果 Cloudflare Redirect Rule 里写死旧端口,IPv4 用户会被重定向到失效端口,表现就是"网页打不开"。必须做端口同步:Lucky Webhook 或外部脚本 PATCH Cloudflare Ruleset,同步完用 curl -4 -I 验证 Location 是否真的带新端口。
坑 3:Cloudflare API 配置和边缘行为可能短暂不一致
PATCH 完 Ruleset,Cloudflare API 返回的 target_expression 显示带端口,但边缘 HTTPS 实际返回的 Location 没带端口,IPv4 用户继续 timeout。这种漂移可能持续几分钟。别只看 API 返回值,必须从外部 curl -4 -I 看最终 Location 才算数。
坑 4:Lucky 404 不一定是后端挂了
直接访问 Lucky 监听口或 Host 不匹配会返回 Lucky 自己的 404/Warning 页。第一反应别去重启后端,先确认 Host 头是不是命中了反代规则:
curl -k -H "Host: api.stun.<ROOT_DOMAIN>" https://<OPENWRT_LAN_IP>:18443/
加上正确 Host 才能命中规则。
坑 5:VPN 不能套 Cloudflare 307
前面讲过:VPN 客户端不理解 HTTP 重定向,必须独立灰云 AAAA 直连,按 VPN 自己协议处理。
坑 6:排障先分清 timeout / 404 / 401
timeout:网络层问题(端口没开、IPv4/443 路径、NAT 失效、防火墙)404:Host 没命中 Lucky 反代规则(多半是 Cloudflare Redirect 写错了 stun 域名,或者直接打了 Lucky 监听口没带 Host)401:规则命中了,Lucky Basic Auth 在工作,这是正常表现
别把 401 当成"挂了"去重启服务。
十、完整脱敏抽象架构图
┌────────────────────┐
│ client │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ Cloudflare orange │
│ HTTP 307 only │
└─────────┬──────────┘
│
┌───────────────────────┴───────────────────────┐
│ │
┌─────────▼──────────┐ ┌──────────▼─────────┐
│ IPv4 path │ │ IPv6 path │
│ stun host + port │ │ stun host + 443 │
└─────────┬──────────┘ └──────────┬─────────┘
│ │
┌─────────▼──────────┐ ┌──────────▼─────────┐
│ Lucky STUN tcp4 │ │ OpenWrt IPv6 │
│ public:<port> │ │ Lucky HTTPS 443 │
└─────────┬──────────┘ └──────────┬─────────┘
│ │
└───────────────────────┬───────────────────────┘
│
┌─────────▼──────────┐
│ Lucky reverse proxy│
│ Host based routing │
└─────────┬──────────┘
│
┌────────────────────────────┼────────────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼───────┐ ┌─────────▼──────┐
│ service A │ │ service B │ │ service C │
│ node A:port │ │ node A:port │ │ node B:port │
└────────────────┘ └────────────────┘ └────────────────┘
写在最后
整套方案核心就这几条:
- 入口必须橙云,落地必须灰云
- IPv4 走 STUN 端口,IPv6 直接 443,别假设 IPv4 能走 443
- 307 + preserve_query_string,API 和长连接才不挂
- VPN 独立,别套 307
- 认证放在 Lucky,不放在 CF 入口
- STUN 端口动态值,必须同步 + 外部复测
所有 <...> 占位符替换成真实值再用。 真实域名、公网 IP、STUN 端口、Cloudflare Zone ID、API Token、Lucky 管理凭据、Basic Auth 账号密码、TOTP 种子、origin 旁路密钥这类敏感信息绝对不要贴论坛,包括截图里也要打码。
有问题评论区交流,佬友们如果有更优雅的 STUN 端口同步方案也欢迎贴出来。
2 个帖子 - 2 位参与者