我推翻了上一篇的一半结论:NAT 后的虚拟机对外提供服务,答案是 Cloudflare Tunnel
上一篇我说”家用宽带封端口时代,Tailscale 一条命令搞定跨网络远程开发”。
那句话对我自己连进服务器依然成立;但这次我要让手机去访问服务器上的 Web 面板时,
Tailscale 彻底做不到,端口转发也做不到,最后是一根出站的 Cloudflare Tunnel 收的场。顺带记录一次教科书级的 Hyper-V 虚拟机网络故障排查。
(本文已对账号、公网 IP、tailnet 名、域名等敏感信息脱敏,仅保留技术结构,见文末自查清单。)
这是”家用 Hyper-V 虚拟机”系列的第三篇:
- 《在家用宽带”封端口”时代,我如何用 Tailscale 搞定跨网络远程开发》—— 解决 SSH 远程开发
- 《一台家用虚拟机,如何用最「丝滑」的方式装上一个完整的服务器面板》—— 解决 装 1Panel
- 本篇 —— 解决 把面板暴露给外部网络
需要特别说明的是:本篇修正了第 1 篇结论的适用边界。那篇说”Tailscale 是个人远程开发的默认最优解”,
在”我用自己的设备连进服务器“这个方向上完全成立;但当需求变成
“任意客户端访问服务器上的一个服务“时,Tailscale 在 NAT 后的虚拟机上做不到。
第 2 篇里”无公网入站,靠宿主 portproxy + Tailscale 访问”那句,也一并被本篇更新。
TL;DR
一台跑在 Windows Hyper-V 里的 Ubuntu 虚拟机,装了 1Panel(Web 管理面板,监听 8443)。
目标:手机在任何网络都能打开这个面板。
我依次试了四条路,前三条都撞墙:
| 方案 | 结果 | 为什么 |
|---|---|---|
① Tailscale 直连 VM 的 100.x 地址 |
❌ 超时 | VM 藏在 Hyper-V NAT 后,外部节点无法主动连入 |
② 用 netsh portproxy 桥到宿主的 Tailscale IP |
❌ 超时 | portproxy 不接管 Tailscale 的 wintun 网卡 |
| ③ 公网域名 + 路由器端口转发 | ❌ 超时 | 转发规则填错 + 家宽不支持 NAT 回环,误判成”运营商封端口” |
| ④ Cloudflare Tunnel | ✅ 1.4 秒打开 | 服务从 VM 主动出站连 Cloudflare,不需要任何入站 |
核心认知修正:Tailscale 解决的是”我控制设备去连服务器”,Cloudflare Tunnel 解决的是”任意客户端访问一个服务”。这两件事在 NAT 后的虚拟机上完全不等价。
一、先把地基修好:VM 连 IP 都拿不到
在谈对外服务之前,先处理了一个更基础的问题——虚拟机重启后彻底失联。
1.1 症状与我的第一次误判
Windows 宿主机重启后,SSH 连不上 VM。VM 控制台里 ip a 显示:
1 | 2: eth0: <BROADCAST,MULTICAST> mtu 1500 state DOWN ← 注意没有 UP 标志 |
第一反应是”Hyper-V 的 Default Switch 网段随重启漂移,静态地址失效了”。
这个判断对了一半——网段确实每次重启都换(我机器上历史出现过 5 个不同网段),
但它不是主因,因为那个静态地址压根就没生效过。
1.2 真凶:netplan 的 MAC 匹配失效
1 | networkctl |
unmanaged 意味着没有任何网络管理器在管这块网卡。翻配置:
1 | # /etc/netplan/00-installer-config.yaml |
而实际网卡 MAC 是 00:15:5d:02:10:05。连安装器写的原始备份也是 :04
——说明这块虚拟网卡在装完系统后被重建过(迁移/重建 VM 时 Hyper-V 重新分配了动态 MAC)。
MAC 匹配不上 → 整份配置对这块网卡无效 → 既没静态地址也没 DHCP 兜底。
教训:
ip a看不到地址时,先看networkctl的 SETUP 列而不是急着改地址。unmanaged= 配置没匹配上;configuring/failed才是配置本身有问题。
1.3 顺手挖出三个必炸的隐藏雷
| 雷 | 后果 | 修法 |
|---|---|---|
sshd 状态 active 但 enabled=disabled |
下次重启 SSH 直接死 | systemctl enable ssh |
AutomaticStopAction = Save |
宿主机关机时 VM 被冻成休眠,带着旧网段配置”复活” | 改成 ShutDown(只能在 VM 关机时改,运行中 Set-VM 报成功但读回不变) |
AutomaticCheckpointsEnabled = True |
每次开机长一个 avhdx 差分盘,越用越占空间 | 关掉 + 手删已生成的检查点 |
1.4 网络定型:双网卡分工
最终架构,也是这次所有事的基础:
1 | 宿主机 Windows |
对应 netplan 关键片段:
1 | eth0: |
这里踩过一个惨痛的坑:我一开始给 eth1 配了 routes: [to: default, via: 192.168.100.1],
出网走自建内部交换机的 New-NetNat。结果宿主重启一次就全网瘫痪——New-NetNat 建的 NAT 对象不会在宿主重启后存活
(portproxy、宿主侧静态 IP、防火墙规则全都持久,唯独 NAT 不持久)。
而 eth0 被我设成 use-routes: false,于是 VM 一条默认路由都没有,
DNS 最先死,Tailscale 表现为 “logged out”,很容易误判成 Tailscale 故障。
正确做法:出网交给 Hyper-V 自己托管的 Default Switch NAT(它每次开机自动重建),
内部交换机那条静态网卡只当”永久入站入口”用,不配默认路由。
二、两个值得单独记的坑
2.1 磁盘”只剩 19G”是假象
df -h / 显示根分区 19G、剩 8.5G,正准备停机扩 VHDX,结果 pvs 一看出问题了:
1 | PV VG PSize PFree |
Ubuntu 安装器默认只把 VG 的 64% 切给根 LV。所以:
1 | sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv |
19G → 37G,30 秒搞定。真要扩 VHDX 是另一回事
(停机 → Resize-VHD → growpart → pvresize → lvextend → resize2fs)。
教训:Linux 虚拟机磁盘不够,先
pvs看 PFree,别一上来就动虚拟磁盘。
2.2 VS Code 反复要密码,而命令行 SSH 免密正常
这个现象非常迷惑:命令行 ssh devbox 秒进,VS Code Remote-SSH 每次弹密码框。
两个原因叠加。
原因一,陈旧 .pub 造成的假指纹。ssh-keygen -l -f id_ed25519 报了个指纹,我拿它去装公钥,服务端拒绝。
真相是 ssh-keygen -l 读私钥时会去拿同名的 .pub 文件,
而那个 .pub 是上一把密钥的残留(私钥重新生成过,.pub 没更新)。
客户端拿 .pub 里的 blob 去 offer、却用不匹配的私钥签名 → 必然被拒。
1 | ssh-keygen -yf id_ed25519 > id_ed25519.pub # 从私钥反导出真正的公钥 |
原因二,私钥文件的 NTFS ACL 太松。
Windows 自带的 OpenSSH(VS Code 用的就是它)会检查私钥权限,
发现 BUILTIN\Users 和 Everyone 可读,就静默跳过密钥退化成密码认证。
而 git-bash 里那个 OpenSSH 10.x 不做这个检查——所以”我能连、VS Code 不能连”。
1 | icacls $key /inheritance:r /remove "BUILTIN\Users" "Everyone" |
补充坑:PowerShell 里
"$env:USERNAME:F"会被解析成作用域变量而变空,
必须写成"${env:USERNAME}:F"。
三、主线:让手机从任何网络打开 1Panel
地基修好,进入正题。1Panel 在 VM 里监听 0.0.0.0:8443,本机 curl 200 正常。
3.1 尝试一:Tailscale 直连 VM —— 架构上不可行
手机和 VM 都在同一个 tailnet,VM 有 100.x.x.x,理论上手机直接访问http://100.x.x.x:8443 就行。结果超时。
这里有个极容易骗过自己的现象:在手机上 tailscale ping <VM的100.x> 是有 pong 的!
但抓包一看,VM 侧一个 SYN 都没收到。
原因有两层:
tailscale ping是 disco 协议层应答,由tailscaled进程直接回,
根本不经过内核网络栈、不产生 TCP 连接。ping 通 ≠ 业务数据能传。- 方向不对称。VM 藏在 Hyper-V 的 Default Switch NAT 后面:
- VM 主动去 ping 对端 → 包能出去,NAT 记住回程映射 → 有 pong ✅
- 对端主动连入 VM 的
100.x→ 需要宿主上有对应的入站映射,Hyper-V 不会给 → 丢弃 ❌ - 本该退化成 DERP 中继兜底,但国内访问 Tailscale 官方 DERP 基本连不上 ❌
更离谱的是:同一个 WiFi 下的手机和宿主机,tailscale 也打洞失败,
只能绕旧金山中继,RTT 500ms+(而 NAT 后面的 VM 反而能直连手机)。
1 | pong from tailscale-termux (100.98.x.x) via DERP(sfo) in 507ms |
结论:这种拓扑下,别拿 Tailscale 做”别人来访问我的服务”。
3.2 尝试二:用 portproxy 桥宿主的 Tailscale IP —— 不接管 wintun
思路很漂亮:宿主机自己是正常的 tailscale 节点,而且有直达 VM 内网的路由,
那就让宿主替 VM 收连接:
1 | netsh interface portproxy add v4tov4 listenaddress=<宿主100.x> listenport=8443 ` |
而且我差点被自己的测试骗了:在宿主机上 curl http://<宿主100.x>:8443 返回 200,
我当场宣布”桥通了”。错——本机访问自己的 IP 走回环,完全不经过入站防火墙和真实收包路径。
换手机测,照样超时。
最终定位:netsh portproxy(iphlpsvc)不接管 Tailscale 那张 wintun 网卡上的入站连接。
这条路放弃。
铁律:验证”外部能否连入”,必须换一台机器测。
本机自测 200 只能证明监听和回源是好的。
3.3 尝试三:公网域名 + 路由器端口转发 —— 两个假象叠加
域名在 Cloudflare 做**灰云(DNS only)**直指家宽公网 IP,路由器加端口转发。
逻辑上完全成立,实测超时。这里叠了两个假象。
假象一:NAT 回环(hairpin)。
在家里 WiFi 上访问自己的公网 IP,绝大多数家用路由器不支持回环,必然超时。
判别方法:宿主机自己 curl http://<公网IP>:8443 超时、但 curl http://192.168.x.7:8443 是 200。
所以测公网链路必须关 WiFi 用移动数据测,否则你会把”回环不支持”误判成”公网不通”。
假象二:端口转发规则填错。 后来发现规则实际是:
1 | 局域网主机: 192.168.x.7:8443 ← 错,这个字段只能填纯 IP |
正确写法是”局域网主机”只填 IP、”局域网主机端口”填服务真实端口、
“广域网端口”才填对外端口。填错之后转发到了本机一个没人监听的端口,当然超时。
也就是说:我一度得出的”运营商封端口”结论,其实根本没被真正验证过。
排查到这里我做了个决定——不再跟端口转发斗智斗勇,直接上 Cloudflare Tunnel。
3.4 终局:Cloudflare Tunnel
为什么它对症:隧道是VM 主动向外连 Cloudflare 边缘(QUIC / HTTP2 出站),
所以三个死结同时被解开——不需要入站端口(不怕运营商封)、
不需要打洞(不受 NAT 方向不对称影响)、不需要公网 IP。
VM 侧(Ubuntu 26.04):
1 | # 下载二进制(GitHub 直连慢,走代理断点续传) |
systemd 单元(关键是非 root 跑 + 开机自启):
1 | # /etc/systemd/system/cloudflared.service |
1 | sudo systemctl daemon-reload && sudo systemctl enable --now cloudflared |
Cloudflare 控制台侧(Public Hostname):
1 | Hostname: devbox |
配置完成后,cloudflared 日志会打出它拉到的 ingress,可以核对:
1 | Updated to new configuration config="{\"ingress\":[ |
四、Cloudflare Tunnel 的三级错误码阶梯
配置过程不是一次到位,三个错误码恰好对应三个不同层次,值得记下来:
| 现象 | 含义 | 我的实际问题 |
|---|---|---|
| 522 + 约 20 秒超时 | 流量没进隧道 | DNS 还是灰云 A 记录,指向了路由器 IP |
| 522 + 已解析到 CF 边缘 IP | 进了 CF,但源站不可达 | 把 A 记录直接点成橙云,源站仍写着公网 IP;隧道要求的是 CNAME → <隧道ID>.cfargotunnel.com |
| 530 / 502(重启后 10 秒内) | 假故障 | cloudflared 重启后 QUIC 还在拨号,等日志出现 4 条 Registered tunnel connection 再测 |
| 502 + 1.4 秒返回 | 进了隧道但回源失败 | Service URL 写成 https://localhost:8443,而面板是明文 HTTP,TLS 握手炸 |
最后一条在 cloudflared 日志里一目了然:
1 | ERR Unable to reach the origin service: |
判据:522 慢 → 查 DNS 是不是橙云 CNAME;502 快 → 查 cloudflared 日志的 originService。
另外新版控制台建 Public Hostname 已经没有 Type 下拉框了,协议直接写在Service URL 字段里,明文源站要手动把 https:// 改成 http://。
五、修正上一篇的结论
上一篇我说 Tailscale 是”个人远程开发的默认最优解”。现在补一个边界:
| 需求 | 该用什么 | 原因 |
|---|---|---|
| 我用自己的设备连进服务器(SSH、开发) | Tailscale | 服务器主动出站打洞即可,方向天然正确 |
| 任意客户端访问服务器上的服务(Web 面板、API) | Cloudflare Tunnel | 不需要入站端口、不受 NAT 方向限制、自带 HTTPS |
| 同一局域网内的日常访问 | 直接内网 IP | 最快,零依赖 |
| 服务器藏在 Hyper-V / 容器 NAT 后 | 前两者都不能直连入 | 必须靠”服务主动出站”那一层 |
一句话:Tailscale 是”拉”,Cloudflare Tunnel 是”挂”。
NAT 后的机器做”拉”很擅长,做”被访问”就只能靠一根主动出站的隧道。
两者不冲突,我的最终形态是并存:SSH 走 Tailscale + 内网静态地址,Web 面板走 Cloudflare Tunnel。
六、最终访问矩阵
| 你在哪 | 用什么 | 状态 |
|---|---|---|
| 宿主机 / VS Code 开发 | ssh devbox(内网静态地址) |
✅ 免密,宿主重启不变 |
| 家里任何设备 | http://192.168.x.7:8443/入口码 |
✅ portproxy + 防火墙放行 |
| 外面(移动数据 / 公司网) | https://devbox.example.com/入口码 |
✅ Cloudflare Tunnel,约 1.4s |
| 应急兜底 | Tailscale 100.x(仅同 WiFi 直连时可用) |
⚠️ 不作为主力 |
配好之后把路由器的端口转发全关了:公网入口只剩 Cloudflare 一条,
不再有任何端口暴露在被全网扫描的位置。
七、踩坑清单(可复用)
| 坑 | 症状 | 解法 |
|---|---|---|
netplan match.macaddress 与实际 MAC 不符 |
接口 unmanaged,连 UP 都没有 |
用 ip a 的真实 MAC 重写;先看 networkctl 的 SETUP 列 |
AutomaticStopAction=Save |
宿主重启后 VM 带旧网络配置”复活” | 改 ShutDown,且只能在 VM 关机时改 |
New-NetNat 不跨宿主重启 |
VM 出网全断、DNS 先死、Tailscale 显示 logged out | 出网走 Default Switch 自管 NAT;内部交换机网卡不配默认路由 |
| Ubuntu 装机默认只切 VG 的 64% | df 显示根分区很小 |
pvs 看 PFree,有闲就 lvextend + resize2fs 在线扩 |
.pub 陈旧而私钥已重建 |
公钥装上仍被拒 | ssh-keygen -l 会读 .pub 报假指纹;用 -yf 反导真公钥 |
| 私钥 NTFS ACL 含 Users/Everyone | VS Code 要密码但命令行免密正常 | icacls /inheritance:r + 只留本人和 SYSTEM |
| 本机 curl 自己 IP 得 200 | 误判”外部也能连入” | 必须换一台机器测;本机走回环不过入站防火墙 |
tailscale ping 有 pong |
误判”隧道能传业务数据” | 它是 disco 层应答,要抓包看 SYN / SYN-ACK |
| 在家里 WiFi 测自己公网 IP | 超时,误判公网不通 | 家宽多不支持 NAT 回环,改用移动数据测 |
路由器”局域网主机”字段填了 IP:端口 |
端口转发全不通 | 主机字段只填 IP,内网端口填服务真实端口 |
Tunnel 的 Service URL 写 https:// |
502,日志 TLS handshake 失败 | 明文源站填 http:// |
| 改完 Public Hostname 立刻测 | 530 / 502 假故障 | 等 cloudflared 日志出现 4 条 Registered 再测 |
| Hyper-V 文本控制台手敲命令 | 字符被吞,命令变形(is-active → isactive) |
别在控制台配系统,先搞出 IP 走 SSH |
echo 密码 | sudo -S cmd 又用管道写文件 |
文件内容变成密码本身 | 先 echo 密码 | sudo -S -v 缓存凭证 |
提权脚本用 Tee-Object 写日志 |
Bash 里 cat 报 Binary output |
日志是 UTF-16,用 Get-Content 读或管道 tr -d '\000' |
八、一句话总结
NAT 后面的虚拟机,”被访问”和”去访问”是两件难度完全不同的事。
Tailscale 让后者变得极其舒服,前者则要靠”服务主动出站到 Cloudflare”这类反向隧道。
我把三条看似合理的路都走了一遍才承认这一点——
而最贵的不是配置,是那些”看起来成功了”的假信号:
有 pong 的tailscale ping、返回 200 的本机自测、
以及”所有设备都超时”其实只是路由器不支持 NAT 回环。
配图建议:① 双网卡分工拓扑图(出网走 Default Switch NAT / 入站走内部交换机静态 IP)
② networkctl 显示 unmanaged 的截图 ③ 522 → 530 → 502 → 200 四次响应对照表
④ cloudflared 的 4 条 Registered tunnel connection 日志 ⑤ 最终访问矩阵表
脱敏说明(发文前自查)
本文正文已做如下脱敏,发布前请再自查一遍:
- 真实公网出口 IP → 正文未出现(仅以”家宽公网 IP”表述)
- 真实 tailnet 名 → 未出现,Tailscale IP 一律写成
100.x.x.x/100.98.x.x - 真实面板域名 → 正文用
devbox.example.com占位 - 真实主机名与 SSH 别名 → 正文用
devbox占位 - 真实内网
192.168.2.7、192.168.100.10→ 正文用192.168.x.7、192.168.x.10占位 - 1Panel 安全入口码 → 正文用”入口码”表述
- Cloudflare Tunnel token、Tunnel ID → 正文用
<你的token>/<隧道ID>占位 - 网卡 MAC → 保留了
00:15:5d:02:10:04/05/07,这是 Hyper-V 动态分配的本机局部值,
不具公网可定位性;若介意可一并改成00:15:5d:02:10:0x
如需保留真实域名作为示例,把 devbox.example.com 换回你自己的域名即可,
其余占位符已覆盖所有可直接定位到人的标识符。