简介面向.NET开发者的内网穿透源码方案基于.NET7构建融合C#、Vue、JavaScript及少量Rust/Tun2Socks组件解决无公网IP环境下的设备互联、端口映射与组网问题。源码共843个文件其中C#源文件298个、JavaScript273个、Vue组件67个、csproj工程文件38个另有Dockerfile、发布脚本、JSON配置、批处理及跨平台Tun2Socks可执行文件压缩包约120MB。实现TCP/UDP打洞、服务器中继、节点中继、服务器代理、TUN网卡组网及双向转发覆盖服务端、桌面客户端与TUN虚拟网卡等多种运行形态配套防火墙设置与多平台Docker构建脚本方便快速部署。已有121人学习适合希望研究P2P穿透、NAT打洞或搭建自建中继链路的开发者可直接参考完整工程结构、发布脚本与多协议通信设计思路节省从零搭建的时间。1. 为什么说.NET7下的P2P内网穿透是「自建远程访问」的最后一块拼图做远程桌面、NAS访问、摄像头回看这类需求时大多数人第一反应是部署frp或ngrok这类中心化转发方案。但真正跑过一段时间就会碰到三个绕不开的痛点中转服务器带宽吃紧、流量费用随并发上涨、以及数据在公网节点上过了一手带来的隐私顾虑。P2P内网穿透的思路刚好反过来——用打洞技术让两个内网设备直接建立点对点连接中转服务器只负责牵线不负责搬运数据。这套方案在.NET7下实现意味着可以用统一的托管代码同时覆盖Windows、Linux和macOS不需要为每个平台单独维护C或C的穿透模块。标题里「多协议打洞」这几个字是核心。常见的UDP打洞、TCP打洞、以及针对对称型NAT的端口预测和UPnP辅助各自适用的网络场景完全不同。一套只做UDP打洞的代码在家庭宽带的大多数场景能跑通但到了企业级对称NAT后面就大概率翻车。而设计源码要解决的正是把这些打洞策略做成可插拔的协议栈让程序根据本地NAT类型自动选择最优路径。这篇文章就围绕这套源码的设计思路展开把NAT类型探测、打洞握手、连接维护和跨平台部署的每一步讲清楚让你能直接照着实现或者二次改造。2. 先搞懂NAT类型和打洞原理UDP打洞为什么是首选TCP打洞又难在哪2.1 NAT的四种类型决定了你能不能打洞成功打洞能不能成完全取决于两端设备前面的NAT是什么行为模式。业界通常把NAT分成四类Full Cone、Restricted Cone、Port Restricted Cone和Symmetric NAT。前三种统称锥形NAT特点是同一个内网IP和端口映射到公网后对外映射关系在一定时间内保持不变。Symmetric NAT则每次发起新连接都用新的公网端口这让打洞变得极其困难。判断自己属于哪种NAT常见做法是让客户端向两个不同的公网服务器同时发UDP包然后对比服务器看到的源IP和源端口。两个服务器看到的映射完全一致基本可以判定是Full Cone一致但只有其中一个服务器能收到后续包说明是Restricted Cone两个端口都不一样那就是Symmetric NAT。在.NET7里实现这个探测逻辑并不复杂核心就是构造几个不同目标的UDP报文然后等待回包并解析源地址。public class NatProbeResult { public string PublicIp { get; set; } public int PublicPort1 { get; set; } public int PublicPort2 { get; set; } public bool SameIpAndPort PublicIp ! null PublicPort1 PublicPort2; } public async TaskNatProbeResult ProbeAsync(IPEndPoint server1, IPEndPoint server2, int localPort) { using var udp new UdpClient(new IPEndPoint(IPAddress.Any, localPort)); var result new NatProbeResult(); // 向两个不同的打洞协调服务器发送同一个负载触发NAT建立映射 var payload Guid.NewGuid().ToByteArray(); await udp.SendAsync(payload, server1); await udp.SendAsync(payload, server2); // 分别接收两个服务器的回包从回包中提取它们看到的公网地址 var cts new CancellationTokenSource(3000); var tasks new ListTask(); tasks.Add(ReceiveFromServerAsync(udp, server1, result, cts.Token)); tasks.Add(ReceiveFromServerAsync(udp, server2, result, cts.Token)); await Task.WhenAll(tasks); return result; }这段代码的关键在于同一时刻向两个服务器发包这样NAT会分别建立两条映射记录。如果两条记录的公网IP和端口一致说明NAT对目标地址不敏感属于锥形NAT如果不一致基本就是Symmetric NAT。超时时间设了3秒因为公网路径上UDP丢包率不低太短容易误判太长影响用户体验。2.2 UDP打洞的完整流程协调服务器只服务握手不碰业务数据UDP打洞的标准流程分三步注册、尝试、确认。两个客户端A和B分别连接到协调服务器上报自己的内网地址和NAT探测结果。协调服务器把A的公网端点告诉B把B的公网端点告诉A。然后A和B同时向对方的公网端点发UDP包——这里的关键是必须「同时」或者至少都在对方NAT映射还没过期的时间窗口内发。为什么必须要同时因为A向B的公网端点发包时A自己的NAT会建立一条到B地址的映射记录B的NAT此刻也在做同样的事。如果只有A在发B的NAT没有到A的映射记录B收到A的包后会直接丢弃。所以打洞协调服务器在交换完双方地址后会发送一个「开始打洞」的指令让两端在同几百毫秒内同时发起。实际实现中我一般会把时间窗口放宽到1秒避免两端时钟偏差导致错过。public async Taskbool PunchHoleAsync(EndPoint peerEndPoint, int durationMs 1000) { using var udp new UdpClient(); var holePunchPayload new byte[32]; Random.Shared.NextBytes(holePunchPayload); var cts new CancellationTokenSource(durationMs); try { // 在窗口期内高频发送UDP打洞本质是概率事件发得越密集成功率越高 while (!cts.IsCancellationRequested) { await udp.SendAsync(holePunchPayload, peerEndPoint); await Task.Delay(50, cts.Token); } } catch (OperationCanceledException) { // 窗口结束停止打洞 } // 尝试接收对端发来的打洞包收到即说明双向映射都已建立 var receiveTask udp.ReceiveAsync(); var completed await Task.WhenAny(receiveTask, Task.Delay(durationMs 500)); return completed receiveTask; }这里有个容易被新手忽略的参数发送间隔。50毫秒一个包1秒窗口能发20个包这个频率在大多数家用NAT上不会触发限速又能保证至少几个包能穿越双方NAT的映射表。如果把间隔缩短到10毫秒部分路由器的NAT表会认为是DoS攻击直接静默丢包反而降低成功率。2.3 TCP打洞为什么不能照搬UDP的流程TCP打洞和UDP相比麻烦得多。UDP是无连接的只要NAT映射存在数据就能双向流动。但TCP是状态机协议两端必须完成三次握手才能传输数据。问题是A向B的公网端点发SYN包时B的NAT还没有到A的映射记录这个SYN会被B的NAT丢弃。即便B也同时向A发SYN两边收到的SYN都不是自己期望的对端IP和端口TCP协议栈会直接回RST。常见的解决办法是TCP同时打开Simultaneous Open。A和B都使用本地端口作为源端口同时向对方发起connect。Linux内核天然支持这种场景但Windows的Winsock对同时打开的支持不完整尤其是当SYN包到达时对端还没进入listen状态就会出现连接建立失败。在.NET7的Socket层实现TCP打洞我一般建议用Socket.ConnectAsync配合端口复用选项var socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { ExclusiveAddressUse false }; socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); socket.Bind(new IPEndPoint(localAddress, localPort));Bind到固定端口是关键。打洞协调服务器在交换地址信息时会让双方约定使用同一个公网端口作为源端口这样SYN包从两边发出后各自NAT建立的映射恰好能匹配对方的期望。但即便如此微软的TCP栈在收到对端SYN后如果没有进入listen状态会返回ConnectionRefused。所以.NET7实现里需要先Bind并Listen再异步Connect让两个操作同时挂起。这种做法在Linux的.NET运行时上效果不错Windows上则看运气这也是为什么多协议打洞源码里UDP路径的成功率远高于TCP路径。3. 协调服务器的设计与信令协议用.NET7 Minimal API搭一个打洞协调服务3.1 信令协议的消息模型注册、打洞指令、候选地址交换协调服务器是整个P2P系统里唯一需要公网IP的节点但它的任务非常轻——只做身份登记和转达打洞指令不转发业务流量。一个设计良好的信令协议消息种类越少越不容易出错。我一般只保留四类消息Register、PeerList、HolePunchRequest和HolePunchResponse。Register消息由客户端在启动时发给协调服务器内容包含设备ID、内网地址、NAT类型探测结果和当前公网端点。协调服务器收到后把该客户端的信息存进内存字典并标记该客户端为「在线且可被穿透」。PeerList消息用于客户端查询目标设备的公网端点信息。HolePunchRequest是协调服务器主动下发的它告诉客户端「你现在需要和某个端点同时开始打洞」。{ action: hole_punch_request, peerId: device-b, peerPublicEndpoint: 203.0.113.20:45000, peerNatType: port_restricted_cone, strategy: udp_simultaneous, windowMs: 1000 }策略字段strategy很关键它由协调服务器根据双方NAT类型自动选择。两端都是锥形NAT选udp_simultaneous有一端是对称NAT就只能尝试端口预测或者退化为TCP中继。把策略判断放在协调服务器而不是客户端是为了避免客户端版本不一致导致策略算法分叉。3.2 用.NET7 Minimal API实现协调服务器的核心逻辑.NET7的Minimal API配合SignalR是搭建这类信令服务器的高效组合。Minimal API负责HTTP接口SignalR负责双向实时通信——协调服务器需要主动给客户端下发打洞指令用WebSocket轮询就太笨了。注册和查询走HTTP打洞指令走SignalR职责清晰。var builder WebApplication.CreateBuilder(args); builder.Services.AddSignalR(); builder.Services.AddSingletonPeerRegistry(); var app builder.Build(); app.MapHubPunchHub(/punch); app.MapPost(/register, async (RegisterRequest req, PeerRegistry registry) { var publicEndpoint GetPublicEndpoint(req.PublicIp, req.PublicPort); registry.Register(req.DeviceId, publicEndpoint, req.NatType); return Results.Ok(new { Devices registry.GetOnlineDevices() }); }); app.MapGet(/query, (string deviceId, PeerRegistry registry) { var peer registry.GetPeer(deviceId); return peer is null ? Results.NotFound() : Results.Ok(peer); }); app.Run();这里有个容易被忽略的点GetPublicEndpoint不能直接信任客户端上报的IP和端口必须从TCP连接的对端地址中提取。否则客户端在NAT后面随手填一个内网地址协调服务器拿到的全是不可路由的私网IP。所以RegisterRequest里的PublicIp和PublicPort仅作为兜底真实公网端点以SignalR或HTTP连接的RemoteIpEndPoint为准。3.3 协调服务器怎么决定打洞策略策略选择的核心原则是锥形NAT优先UDP打洞对称NAT尝试端口预测极端情况退化为中继。注意中继不是源码必须实现的部分但设计上要留接口。我通常会先比对双方NAT类型——两端都是Full Cone成功率接近100%一端是Restricted Cone成功率也不低遇到Symmetric NAT就要用到端口预测。端口预测的基本思路是对称NAT虽然每次映射端口不同但增量通常是固定数值。比如第一次映射是从45000转到45001第二次可能从45002转到45003增量一致。协调服务器可以在短时间内让对称NAT一端向公网服务器发起多次探测统计端口增量均值然后把预测出的端口告知对端。这个方案在真实网络里的成功率只有六成左右所以我的做法是同时启动UDP打洞和端口预测两条路径谁先成功用谁。public static PunchStrategy DecideStrategy(NatType peerA, NatType peerB) { if (peerA NatType.Symmetric || peerB NatType.Symmetric) { var other peerA NatType.Symmetric ? peerB : peerA; return other NatType.FullCone ? PunchStrategy.PortPrediction : PunchStrategy.TcpRelayFallback; } return PunchStrategy.UdpSimultaneous; }这段决策逻辑看起来简单但实际部署时还需要考虑两端所在网络的运营商差异。比如移动宽带大多是大端口段NAT联通和电信家庭宽带的映射规律也不一致。策略算法需要不断用探测结果修正不能一锤定音。4. P2P连接建立之后的维护保活机制、NAT映射刷新和连接从UDP升级到TCP4.1 NAT映射的存活时间为什么保活包不能让服务器中转打洞成功后A和B之间建立了一条UDP通路。但这条通路是脆弱的——NAT映射表条目有超时时间大多数家用路由器的UDP超时在30秒到2分钟之间。如果通路上一段时间没有数据流动NAT会悄悄删除这条映射连接就断了。解决办法是定期发送保活包。常见做法是双方每隔15到20秒交换一个很小的KeepAlive消息消息内容可以是时间戳加序号。但这里有个很多人会犯的错保活包不能发给协调服务器再转发。一旦保活流量经过协调服务器服务器的公网IP就成了双方NAT映射的目标地址两端的NAT会认为「我只和服务器通信」从而删掉到对方端点的映射记录。正确的保活包必须直接发给对方的公网端点。public class P2PChannel { private readonly UdpClient _udp; private readonly IPEndPoint _remoteEndPoint; private readonly Timer _keepAliveTimer; public void StartKeepAlive(int intervalMs 15000) { _keepAliveTimer new Timer(KeepAliveCallback, null, intervalMs, intervalMs); } private void KeepAliveCallback(object state) { // 负载里带上序列号接收端可以据此判断链路是否出现乱序或丢包 var payload BitConverter.GetBytes(Interlocked.Increment(ref _sequence)); _udp.Send(payload, _remoteEndPoint); } }序列号带上是有意义的。如果接收端发现序号跳变说明保活包有丢失链路质量在下降这时候可以主动提高发送频率或者触发一次重新打洞。如果连续多次序号不递增就得宣告连接断线并走重建流程。4.2 UDP通路如何平滑升级为TCP避免跨运营商UDP被QoS限速国内公网环境下UDP流量跨运营商时经常被限速甚至丢包尤其是长时间占用的UDP流。所以打洞成功只是第一步后续还要考虑把业务流量从UDP迁移到TCP。这里的挑战在于业务数据流迁移期间不能断TCP连接必须和UDP通路并行跑一段才能切换。常见做法是「UDP验证TCP可行性」双方先通过现有UDP通路交换TCP连接参数然后同时向对方的公网TCP端口发起连接。TCP连接建立后UDP通路继续跑保活业务数据逐步切到TCP。切换时机要看TCP通道的RTT和丢包率——如果TCP连接的RTT比UDP高太多说明TCP打洞走的路径可能经过了中继这时候就不应该切。public async Taskbool TryUpgradeToTcpAsync(P2PChannel udpChannel, IPEndPoint tcpEndPoint) { var tcpClient new TcpClient(); tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); tcpClient.Client.Bind(udpChannel.LocalEndPoint); // 复用UDP的本地端口发起TCP连接NAT会认为这是同一主机的流量 var connectTask tcpClient.ConnectAsync(tcpEndPoint); var timeoutTask Task.Delay(3000); var completed await Task.WhenAny(connectTask, timeoutTask); if (completed ! connectTask || !tcpClient.Connected) { return false; } // 测量TCP路径质量RTT超过500ms就不切换 var rtt await MeasureRttAsync(tcpClient); return rtt TimeSpan.FromMilliseconds(500); }Bind到UDP通道的本地端口这一步很关键但又不能总是成功。Windows上UDP套接字和TCP套接字虽然都依赖端口分配表但同时绑定同一个端口需要开启ReuseAddress。如果失败退回策略是使用一个新端口发TCP打洞让协调服务器再协调一次。4.3 断线重连和映射刷新当NAT重启或运营商更换IP时的自救现实网络环境远没有仿真测试那么稳定。路由器断电重启、光猫重新拨号、运营商周期性更换公网IP都会让既有的P2P通路瞬间失效。好的设计必须把「连接状态感知」做成主动检测而不是被动等待。我一般会让P2PChannel每30秒做一次穿透检测通过当前通路发一个随机数请求对端回包时带上自己看到的当前公网IP和端口。如果两边看到的公网端点发生了偏移说明NAT映射已经改变需要立即向协调服务器重新注册并启动新一轮打洞。这个过程中要注意的是重新打洞期间业务数据不能停——所以P2P层的缓存队列必须足够深。这里顺带说一句很多人在UDP通道上踩的坑UDP本身不保证有序但P2P穿透之后还有一层业务协议比如远程桌面协议或文件传输协议。如果直接把业务数据塞进UDP裸流不做分帧和确认丢包重传的复杂度会完全失控。所以设计里到底要不要在P2P链路上再跑一层QUIC是值得提前想清楚的。5. 避坑手册NAT探测误判、Windows防火墙拦截和打洞成功后连不上的经典案例5.1 NAT探测结果不稳定同一个路由器下两次探测结果不同现象客户端每次启动时探测NAT类型得到的类型不一样有时是Full Cone有时是Port Restricted Cone导致协调服务器做出了错误的打洞策略选择。原因很多家用路由器的NAT映射行为依赖于源端口和目标端口的组合。第一次探测时客户端使用随机源端口路由器华为、小米这类设备对随机源端口的映射策略不稳定另外如果探测服务器的IP刚好在路由器的端口触发式映射列表里行为也接近Full Cone。解决NAT探测不能只做一次。我在实现里会让客户端连续探测三到五次取相同结果最多的作为最终判定。同时探测时必须固定源端口而不是每次都用新端口。源端口固定后NAT的映射行为会更一致误判率能降到非常低。5.2 Windows防火墙把打洞包静默丢弃UDP发送正常但就是收不到回包现象在Windows Server上部署客户端打洞协调服务器显示两端的公网端点都正确打洞窗口也执行了但两端UDP收不到任何穿透包。关闭Windows防火墙后立即恢复正常。原因Windows防火墙默认对入站的UDP和TCP包进行严格过滤。打洞包的目标端口是随机高端口没有在防火墙允许列表中被防火墙按「未入站规则匹配」处理并丢弃。这不是.NET代码的错是部署环境层面的拦截。解决在部署阶段提前在防火墙入站规则里开放程序监听端口段或者直接把穿透程序加为防火墙允许的程序。注意Remote Desktop这类需要被穿透的业务端口也必须一并开放入站规则。我见过有人只开放了P2P UDP端口打洞成功但远程桌面还是连不上就是这个原因。提示用netsh advfirewall命令批量添加规则时端口段建议控制在100个以内过大的端口段会让防火墙规则的查效率下降在高并发连接时产生额外的延迟。5.3 打洞成功但业务数据传不动MTU过大导致UDP分片被路由器丢弃现象UDP打洞成功保活正常但一传大文件就断流传小文件正常。抓包看到本地发出的UDP包是1472字节以上的超包且收不到ACK。原因PPPoE拨号的MTU通常是1492但很多P2P穿透工具默认的UDP缓冲区不感知MTU发送超过1500字节的数据报时IP层分片。两个NAT设备在转发分片包时如果中间某个路由器禁止分片转发很多安全策略默认丢弃分片整个数据报就丢了。解决把P2P数据报的大小限制在1200字节以下或者实现基于探测的MTU发现。简单粗暴一点直接在UDP发送前用UdpClient.Client.SendBufferSize配合1200字节的分片逻辑。但更好的方案是引入类似QUIC的数据封装层让P2P链路上的数据包由上层协议重新分段这一层在打洞源码框架里必须有预留接口。5.4 协调服务器重启后客户端失联内存状态管理vs持久化现象协调服务器进程一旦重启所有在线客户端状态全部丢失已经建立的P2P连接因为保活逻辑还活着但一旦断线重连就再也找不到协调服务器。原因用ConcurrentDictionary存客户端状态重启即清零这是内存态注册中心的天然缺陷。客户端侧如果没有处理协调服务器不可达的情况会一直盲目重试但不会重新注册。解决设计上要做「注册心跳」机制。客户端每隔30秒向协调服务器发送一次心跳服务器在收到心跳时刷新注册时间戳。协调服务器重启后客户端在下一次心跳发送时发现没有注册确认就会自动触发完整注册流程。这个心跳机制和P2P保活是两套独立逻辑不能混用。6. 进阶技巧用.NET7的Socket原语实现「穿透检测」和「路径质量自动切换」6.1 穿透检测不只是连上就行要能判断链路是否真的可用很多实现把「UDP端口能互通」当作连接成功这其实是偷懒的判定方式。端口能互通只代表NAT映射存在不代表链路质量能满足业务需求。我通常在打洞成功后先跑一轮穿透检测发送一组指定大小的数据包并统计丢包率和延迟抖动然后把结果回传给上层业务模块由它决定走UDP、TCP还是直接退回手动配置。public async TaskPunchQuality ProbeLinkQualityAsync(IPEndPoint peer, int sampleCount 30) { var sent 0; var received 0; var totalRtt TimeSpan.Zero; var sw Stopwatch.StartNew(); for (var i 0; i sampleCount; i) { var payload new byte[256]; BitConverter.GetBytes(i).CopyTo(payload, 0); var sentTime DateTime.UtcNow; await _udp.SendAsync(payload, peer); sent; var receiveTask _udp.ReceiveAsync(); if (await Task.WhenAny(receiveTask, Task.Delay(500)) receiveTask) { received; totalRtt DateTime.UtcNow - sentTime; } } sw.Stop(); return new PunchQuality { LossRate 1.0 - (double)received / sent, AverageRttMs received 0 ? 0 : totalRtt.TotalMilliseconds / received }; }这30个样本不要连续发中间要隔10到20毫秒否则瞬时突发流量会挤占NAT表项的处理能力。判定标准丢包率低于5%且RTT小于200毫秒可以直接使用UDP丢包率5%到15%建议切TCP丢包率超过15%这条P2P路径基本不可用应该走中继。6.2 路径质量自动切换UDP、TCP、中继三档跳跃把上面的穿透检测结果喂给一个状态机就能实现自动切换。我这里分享一个简单的实现思路维护三个档位——UDP直连、TCP直连、中继转发。默认从UDP开始一旦检测到丢包率超标就尝试TCP升级TCP也失败才进入中继。但中继阶段不能放弃UDP——每30秒再探测一次链路质量一旦恢复就自动切回去。public enum LinkPath { UdpDirect, TcpDirect, Relay } public async TaskLinkPath DecidePathAsync(LinkPath current, PunchQuality quality) { return current switch { LinkPath.UdpDirect when quality.LossRate 0.1 await UpgradeToTcpAsync() ? LinkPath.TcpDirect : LinkPath.Relay, LinkPath.TcpDirect when quality.LossRate 0.05 LinkPath.Relay, LinkPath.Relay when quality.LossRate 0.02 await TryRestoreDirectAsync() ? LinkPath.UdpDirect : LinkPath.TcpDirect, _ current }; }这个状态机的核心价值在于避免频繁抖动。如果只在LossRate的上下阈值之间反复穿越连接会在UDP和TCP之间来回跳反而造成业务中断。所以我在上面加了一点点滞回策略——从UDP降到TCP的阈值是10%但从TCP恢复UDP的阈值是2%中间留出足够的滞后空间。6.3 多协议打洞的最终体会折腾了这些年后我最深刻的教训是P2P打洞不像HTTP那样有标准的成功状态码它是一个概率事件是一个需要持续观察和修正的动态过程。真正可用的打洞源码不是为了证明「能打通」而是为了在不通的时候快速降级、在通了之后保持稳定。.NET7这套方案的优势在于跨平台一致性和托管内存带来的可靠析构语义让我不再需要为每个平台的隧道组件分别维护代码。如果你要往这个方向投入先把NAT探测和保活机制的测试用例写好那些才是坑最多的地方。希望这篇笔记能帮你少走一段弯路。本文还有配套的精品资源点击获取