上一篇我说”家用宽带封端口时代,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 匹配失效
unmanaged 意味着没有任何网络管理器在管这块网卡 。翻配置:
1 2 3 4 5 ethernets: eth0: match: macaddress: 00 :15:5d:02:10:04
而实际网卡 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 } 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 ]
这里踩过一个惨痛的坑 :我一开始给 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-lvsudo resize2fs /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 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 都没收到 。
原因有两层:
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 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 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/cloudflaredsudo mkdir -p /etc/cloudflaredecho 'TUNNEL_TOKEN=<你的token>' | sudo tee /etc/cloudflared/cloudflared.envsudo chmod 600 /etc/cloudflared/cloudflared.env
systemd 单元(关键是非 root 跑 + 开机自启 ):
1 2 3 4 5 6 7 8 9 10 11 12 [Service] Type =simpleUser =kaliEnvironmentFile =/etc/cloudflared/cloudflared.envExecStart =/usr/local/bin/cloudflared tunnel runRestart =alwaysRestartSec =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-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 换回你自己的域名即可, 其余占位符已覆盖所有可直接定位到人的标识符。