如何用Node.js实现TCP打洞?跨网络连接失败排查
关于Node.js TCP打洞的连接问题
正在尝试用Node.js实现TCP打洞功能,同一局域网下客户端可以正常连接,但跨不同网络环境时连接失败,不确定是NAT类型限制还是代码存在错误。
设计目标
- 两个客户端向服务器注册自身四元组(本地地址、本地端口、服务器所见地址、服务器所见端口)
- 当两个客户端都就绪后,服务器向双方发送对方的四元组(
tryConnectToPeer指令) - 客户端复用与服务器通信的本地地址和端口启动监听服务
- 监听服务启动后,同时尝试连接对方的本地地址端口(局域网场景)和服务器所见的公网地址端口(跨网场景)
客户端代码(疑似问题所在)
import { createConnection, createServer } from 'net'; const serverPort = 9999; const serverHost = '192.168.1.19' const socket = createConnection(serverPort, serverHost); socket.setEncoding('utf8'); socket.on('data', (data) => { console.log('data', data); let parsedData = null; try { parsedData = JSON.parse(data); } catch (e) { if (e instanceof Error) { console.log(e.message); } else { throw e; } } if (parsedData?.command === 'tryConnectToPeer') { console.log('Will try to connect with peer:', parsedData); const server = createServer(c => { console.log('client connected'); c.setEncoding('utf8'); c.on('data', (data) => { console.log('received:', data); c.write('hi!'); }); }); server.listen(socket.localPort, socket.localAddress, () => { console.log('server bound to ', socket.localAddress, socket.localPort); }); server.on('listening', () => { console.log('Attempting local connection', parsedData.localAddress, parsedData.localPort); const localSocket = createConnection({ port: parsedData.localPort, host: parsedData.localAddress }); localSocket.on('error', (e) => { console.error('Failed to connect with peer locally'); console.error(e); }); localSocket.setEncoding('utf8'); localSocket.on('data', (data) => { console.log(data); localSocket.write('ho! on local') }) console.log('Attempting external connection', parsedData.externalAddress, parsedData.externalPort); const externalSocket = createConnection({ port: parsedData.externalPort, host: parsedData.externalAddress}); externalSocket.on('error', (e) => { console.error('Failed to connect with peer externally'); console.error(e); }); externalSocket.setEncoding('utf8'); externalSocket.on('data', (data) => { console.log(data); externalSocket.write('ho! on external') }) localSocket.on('connect', () => { externalSocket.end(); localSocket.write('start from localsocket'); console.log('connected to peer locally!'); }) externalSocket.on('connect', () => { // localSocket.end(); externalSocket.write('start from externalSocket'); console.log('connected to peer externally!'); }) }) } }); socket.on('connect', () => { socket.write(JSON.stringify( { command: 'register', localPort: socket.localPort, localAddress: socket.localAddress } )); });
服务端代码
import { createServer, Socket } from 'net'; type AddressAndPort = { address: string | undefined, port: number | undefined } class ConnectionDescriptor { socket: Socket; addressAndPortOnClient: AddressAndPort; addressAndPortSeenByServer: AddressAndPort; constructor({ socket, addressAndPortOnClient, addressAndPortSeenByServer } : {socket: Socket, addressAndPortOnClient: AddressAndPort, addressAndPortSeenByServer: AddressAndPort}) { this.socket = socket; this.addressAndPortOnClient = addressAndPortOnClient; this.addressAndPortSeenByServer = addressAndPortSeenByServer; } toString() { return JSON.stringify({ addressAndPortOnClient: this.addressAndPortOnClient, addressAndPortSeenByServer: this.addressAndPortSeenByServer }); } } class ConnectionDescriptorSet { connectionDescriptors: ConnectionDescriptor[] constructor() { this.connectionDescriptors = []; } get full() { return this.connectionDescriptors.length === 2; } add(descriptor: ConnectionDescriptor) { if (!descriptor.addressAndPortOnClient.address || !descriptor.addressAndPortOnClient.port || !descriptor.addressAndPortSeenByServer.address || !descriptor.addressAndPortSeenByServer.port) { throw new Error(`Cannot register incomplete connection descriptor: ${JSON.stringify(descriptor)}`); } const index = this.connectionDescriptors.findIndex(c => c.addressAndPortSeenByServer.address === descriptor.addressAndPortSeenByServer.address && c.addressAndPortSeenByServer.port === descriptor.addressAndPortSeenByServer.port); if (index === -1) { console.log('Registering new client:'); console.log(descriptor.toString()); if (this.connectionDescriptors.length === 2) { throw new Error('Only two clients can be registered at a time!'); } this.connectionDescriptors.push(descriptor) } else { console.log('Client already registered:'); console.log(descriptor.toString()); } } remove(addressAndPortSeenByServer: AddressAndPort) { const index = this.connectionDescriptors.findIndex(c => c.addressAndPortSeenByServer.address === addressAndPortSeenByServer.address && c.addressAndPortSeenByServer.port === addressAndPortSeenByServer.port); if (index === -1) { console.log('Client with following connectionDescriptors was not found for removal:'); console.log(JSON.stringify(addressAndPortSeenByServer)); } else if (index === 0) { console.log('Removing client:'); console.log(this.connectionDescriptors[0].toString()); this.connectionDescriptors.shift(); } else if (index === 1) { console.log('Removing client:'); console.log(this.connectionDescriptors[1].toString()); this.connectionDescriptors.pop(); } else { throw new Error('No more than 2 clients should have been registered.'); } } } const connectionDescriptorSet = new ConnectionDescriptorSet(); const server = createServer((c) => { console.log('client connected'); // Optional - useful when logging data c.setEncoding('utf8'); c.on('end', () => { connectionDescriptorSet.remove({ address: c.remoteAddress, port: c.remotePort }) console.log('client disconnected'); }); c.on('data', (data) => { console.log('I received:', data); try { const parsedData = JSON.parse(data); if (parsedData.command === 'register') { connectionDescriptorSet.add(new ConnectionDescriptor({ socket: c, addressAndPortOnClient: { address: parsedData.localAddress, port: parsedData.localPort }, addressAndPortSeenByServer: { address: c.remoteAddress, port: c.remotePort } })); if (connectionDescriptorSet.full) { console.log('connectionDescriptorSet full, broadcasting tryConnectToPeer command'); connectionDescriptorSet.connectionDescriptors[0].socket.write( JSON.stringify({ command: 'tryConnectToPeer', localPort: connectionDescriptorSet.connectionDescriptors[1].addressAndPortOnClient.port, localAddress: connectionDescriptorSet.connectionDescriptors[1].addressAndPortOnClient.address, externalAddress: connectionDescriptorSet.connectionDescriptors[1].addressAndPortSeenByServer.address, externalPort: connectionDescriptorSet.connectionDescriptors[1].addressAndPortSeenByServer.port, }) ); connectionDescriptorSet.connectionDescriptors[1].socket.write( JSON.stringify({ command: 'tryConnectToPeer', localPort: connectionDescriptorSet.connectionDescriptors[0].addressAndPortOnClient.port, localAddress: connectionDescriptorSet.connectionDescriptors[0].addressAndPortOnClient.address, externalAddress: connectionDescriptorSet.connectionDescriptors[0].addressAndPortSeenByServer.address, externalPort: connectionDescriptorSet.connectionDescriptors[0].addressAndPortSeenByServer.port, }) ); } } } catch (e) { if (e instanceof Error) { console.error(e); c.write(e.message); } else { throw e; } } }) }); server.on('error', (err) => { throw err; }); server.listen(9999, () => { console.log('server bound'); });
问题排查与修复建议
1. 代码层面的问题
- 端口复用配置缺失:原代码中创建
net.Server时未开启端口复用,导致客户端无法在与服务器通信的同一端口上启动监听,会触发EADDRINUSE错误。修改时需在createServer选项中添加reusePort: true和allowHalfOpen: true,确保端口可以被复用。 - 连接重试逻辑缺失:TCP打洞可能需要多次尝试才能成功,尤其是NAT规则更新存在延迟,建议为公网连接添加重试机制,比如失败后间隔1-2秒重试3-5次。
- 本地连接的无效尝试:跨网络时对方的本地地址(如
192.168.x.x)对当前客户端不可达,建议先判断对方公网地址与自身公网地址是否属于同一局域网,再决定是否发起本地连接,避免无意义的错误日志。
2. NAT类型限制
TCP打洞仅对部分NAT类型有效:
- 支持打洞的NAT:全锥型NAT、地址限制锥型NAT、端口限制锥型NAT,这类NAT会为客户端的同一本地端口固定映射一个公网端口,且允许其他地址访问该映射端口。
- 无法直接打洞的NAT:对称NAT,这类NAT会为客户端访问的不同目标地址分配不同的公网端口,服务器记录的公网端口仅对服务器有效,无法用于与其他客户端建立连接。这种场景下需要借助中继服务器完成数据转发。
内容的提问来源于stack exchange,提问作者qnilab
相关产品推荐
相关产品推荐

