我推翻了上一篇的一半结论:NAT 后的虚拟机对外提供服务,答案是 Cloudflare Tunnel

上一篇我说”家用宽带封端口时代,Tailscale 一条命令搞定跨网络远程开发”。
那句话对我自己连进服务器依然成立;但这次我要让手机去访问服务器上的 Web 面板时,
Tailscale 彻底做不到,端口转发也做不到,最后是一根出站的 Cloudflare Tunnel 收的场。

顺带记录一次教科书级的 Hyper-V 虚拟机网络故障排查。

(本文已对账号、公网 IP、tailnet 名、域名等敏感信息脱敏,仅保留技术结构,见文末自查清单。)

这是”家用 Hyper-V 虚拟机”系列的第三篇

  1. 《在家用宽带”封端口”时代,我如何用 Tailscale 搞定跨网络远程开发》—— 解决 SSH 远程开发
  2. 《一台家用虚拟机,如何用最「丝滑」的方式装上一个完整的服务器面板》—— 解决 装 1Panel
  3. 本篇 —— 解决 把面板暴露给外部网络

需要特别说明的是:本篇修正了第 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
2
3
networkctl
# IDX LINK TYPE OPERATIONAL SETUP
# 2 eth0 ether routable unmanaged ← 关键:unmanaged

unmanaged 意味着没有任何网络管理器在管这块网卡。翻配置:

1
2
3
4
5
# /etc/netplan/00-installer-config.yaml
ethernets:
eth0:
match:
macaddress: 00:15:5d:02:10:04 # 配置里匹配这个 MAC

而实际网卡 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
2
3
4
5
6
7
8
宿主机 Windows
├─ vEthernet (Default Switch) 172.x.0.1/20 ← Hyper-V 自管的 NAT,每次开机自动重建
│ └─ VM eth0:DHCP,只要地址、不要默认路由(兜底 SSH 入口)
└─ vEthernet (VMIntNet) 192.168.100.1/24 ← 自建内部交换机(永久网段)
└─ VM eth1:静态 192.168.x.10,只负责入站,不配默认路由

出网:VM → eth0 → Default Switch 的 NAT → 互联网
入站:宿主 → eth1 的静态地址(永远不变,不受宿主重启影响)

对应 netplan 关键片段:

1
2
3
4
5
6
7
8
eth0:
match: { macaddress: 00:15:5d:02:10:05 } # 一定用实际 MAC
dhcp4: true
dhcp4-overrides: { use-routes: false, use-dns: false } # 只要地址,不抢路由
eth1:
match: { macaddress: 00:15:5d:02:10:07 }
addresses: [ 192.168.100.10/24 ]
# 故意不写 routes: —— 原因见下面的坑

这里踩过一个惨痛的坑:我一开始给 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
2
PV         VG        PSize    PFree
/dev/sda3 ubuntu-vg <36.95g 18.47g ← 18.47G 白躺在 VG 里

Ubuntu 安装器默认只把 VG 的 64% 切给根 LV。所以:

1
2
sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv
sudo resize2fs /dev/ubuntu-vg/ubuntu-lv # ext4 支持在线扩,不停机不提权不动 VHDX

19G → 37G,30 秒搞定。真要扩 VHDX 是另一回事
(停机 → Resize-VHDgrowpartpvresizelvextendresize2fs)。

教训: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\UsersEveryone 可读,就静默跳过密钥退化成密码认证。
而 git-bash 里那个 OpenSSH 10.x 不做这个检查——所以”我能连、VS Code 不能连”。

1
2
icacls $key /inheritance:r /remove "BUILTIN\Users" "Everyone"
icacls $key /grant:r "${env:USERNAME}:F" /grant "NT AUTHORITY\SYSTEM:F"

补充坑: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 都没收到

原因有两层:

  1. tailscale ping 是 disco 协议层应答,由 tailscaled 进程直接回,
    根本不经过内核网络栈、不产生 TCP 连接。ping 通 ≠ 业务数据能传。
  2. 方向不对称。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
2
pong from tailscale-termux (100.98.x.x) via DERP(sfo) in 507ms
direct connection not established

结论:这种拓扑下,别拿 Tailscale 做”别人来访问我的服务”。

3.2 尝试二:用 portproxy 桥宿主的 Tailscale IP —— 不接管 wintun

思路很漂亮:宿主机自己是正常的 tailscale 节点,而且有直达 VM 内网的路由,
那就让宿主替 VM 收连接:

1
2
netsh interface portproxy add v4tov4 listenaddress=<宿主100.x> listenport=8443 `
connectaddress=192.168.100.10 connectport=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
2
局域网主机:      192.168.x.7:8443    ← 错,这个字段只能填纯 IP
局域网主机端口: 45432 ← 错,服务实际在 8443

正确写法是”局域网主机”只填 IP、”局域网主机端口”填服务真实端口
“广域网端口”才填对外端口。填错之后转发到了本机一个没人监听的端口,当然超时。

也就是说:我一度得出的”运营商封端口”结论,其实根本没被真正验证过。
排查到这里我做了个决定——不再跟端口转发斗智斗勇,直接上 Cloudflare Tunnel。

3.4 终局:Cloudflare Tunnel

为什么它对症:隧道是VM 主动向外连 Cloudflare 边缘(QUIC / HTTP2 出站),
所以三个死结同时被解开——不需要入站端口(不怕运营商封)、
不需要打洞(不受 NAT 方向不对称影响)、不需要公网 IP。

VM 侧(Ubuntu 26.04)

1
2
3
4
5
6
7
8
9
# 下载二进制(GitHub 直连慢,走代理断点续传)
curl -fSL -C - -o /usr/local/bin/cloudflared `
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
sudo chmod 755 /usr/local/bin/cloudflared

# token 从 Cloudflare Zero Trust → Networks → Tunnels → 你的隧道 → Connector 里复制
sudo mkdir -p /etc/cloudflared
echo 'TUNNEL_TOKEN=<你的token>' | sudo tee /etc/cloudflared/cloudflared.env
sudo chmod 600 /etc/cloudflared/cloudflared.env

systemd 单元(关键是非 root 跑 + 开机自启):

1
2
3
4
5
6
7
8
9
10
11
12
# /etc/systemd/system/cloudflared.service
[Service]
Type=simple
User=kali
EnvironmentFile=/etc/cloudflared/cloudflared.env
ExecStart=/usr/local/bin/cloudflared tunnel run
Restart=always
RestartSec=5
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
1
sudo systemctl daemon-reload && sudo systemctl enable --now cloudflared

Cloudflare 控制台侧(Public Hostname):

1
2
3
Hostname:     devbox
Domain: example.com
Service URL: http://localhost:8443 ← 注意是 http

配置完成后,cloudflared 日志会打出它拉到的 ingress,可以核对:

1
2
3
4
Updated to new configuration config="{\"ingress\":[
{\"hostname\":\"devbox.example.com\",\"service\":\"http://localhost:8443\"},
{\"service\":\"http_status:404\"}]}"
Registered tunnel connection connIndex=0 location=lax01 protocol=quic ← 共 4 条

四、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
2
3
ERR Unable to reach the origin service:
tls: first record does not look like a TLS handshake
originService=https://localhost:8443

判据: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-activeisactive 别在控制台配系统,先搞出 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.7192.168.100.10 → 正文用 192.168.x.7192.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 换回你自己的域名即可,
其余占位符已覆盖所有可直接定位到人的标识符。