You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.24 22:24:13