WebRTC之P2P

扩展阅读,WebRTC如何获取自身默认网卡IP地址(LocalAddress)

SDP/STUN/TURN/ICE

对这几种名称进行简单介绍如下:

  • SDP是一种用于描述媒体信息的标准协议,例如分辨率、编码器、加密等
  • Offer/Answer,我们要和对端交换的描述信息就称为Offer,对端发给我们的描述信息就称为Answer,不同客户端支持的编解码类型是不一样的,所以需要协商
  • STUN是一种获取NAT公网IP,以及NAT类型的协议
  • TURN是在STUN基础上增加转发功能的协议
  • ICE就是把STUN和TURN的结合

SDP

以下SDP内容完全来自维基-Session Description Protocol,阅读原文获取更全面的信息。

SDP是用于描述流媒体通信参数的格式。IETF在1998年4月发布了原始规范作为拟议标,随后在2006年7月发布了修订的规范RFC4566。
SDP用于会话描述通告,会话邀请和参数协商等多媒体通信会话。SDP本身并不传递任何媒体,而是在端点之间用于协商媒体类型,格式和所有相关属性。属性和参数的集合通常称为会话配置文件。
SDP被设计为可扩展的,以支持新的媒体类型和格式。SDP最初是作为会话公告协议(SAP)的组成部分,但发现它与实时传输协议(RTP),实时流协议(RTSP),会话发起协议(SIP)和即使是用于描述多播会话的独立格式。

STUN

以下STUN内容完全来自维基-STUN,阅读原文获取更全面的信息。

STUN(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。该协议由RFC 5389定义。

一旦客户端得知了Internet端的UDP端口,通信就可以开始了。如果NAT是完全圆锥型的,那么双方中的任何一方都可以发起通信。如果NAT是受限圆锥型或端口受限圆锥型,双方必须一起开始传输。

STUN使用下列的算法(取自RFC 3489)来发现NAT gateways类型以及防火墙(firewalls)
在这里插入图片描述

一旦路经通过红色箱子的终点时,UDP的沟通是没有可能性的。一旦通过黄色或是绿色的箱子,就有连线的可能。

  • 1.STUN客户端向STUN服务器发送请求,要求得到自身经NAT映射后的地址:

    • a. 收不到服务器回复,则认为UDP被防火墙阻断,不能通信,网络类型:Blocked.
    • b. 收到服务器回复,对比本地地址,如果相同,则认为无NAT设备,进入第2步,否则认为有NAT设备,进入3步.
  • 2.(已确认无NAT设备)STUN客户端向STUN服务器发送请求,要求服务器从其他IP和PORT向客户端回复包:

    • a. 收不到服务器从其他IP地址的回复,认为包被前置防火墙阻断,网络类型:Symmetric UDP Firewall.
    • b. 收到则认为客户端处在一个开放的网络上,网络类型:Opened.
  • 3.(已确认存在NAT设备)STUN客户端向STUN服务器发送请求,要求服务器从其他IP和PORT向客户端回复包:

    • a. 收不到服务器从其他IP地址的回复,认为包被前置NAT设备阻断,进入第4步.
    • b. 收到则认为NAT设备类型为Full Cone,即网络类型:Full Cone NAT.
  • 4.STUN客户端向STUN服务器的另外一个IP地址发送请求,要求得到自身经NAT映射后的地址,并对比之:

    • a. 地址不相同,则网络类型:Symmetric NAT.
    • b. 相同则认为是Restricted NAT,进入第5步,进一步确认类型.
  • 5.(已确认Restricted NAT设备)STUN客户端向STUN服务器发送请求,要求服务器从相同IP的其他PORT向客户端回复包:

    • a. 收不到服务器从其他PORT地址的回复,认为包被前置NAT设备阻断,网络类型:Port Restricted cone NAT.
    • b. 收到则认为网络类型: Restricted cone NAT.

有了上面的理论以后,我们来看看WebRTC的代码。WebRTC实现了STUN的功能,包括了客户端和服务端以及NAT探测,它使用的是RFC 5389协议。
在这里插入图片描述

服务端实现

WebRTC的STUN实现stunserver.cc很简单,收到stun客户端的请求,然后把客户端的最外层地址返回给用户。

// 判断是不是stun格式,如果是拍判断消息类型,目前仅仅支持STUN_BINDING_REQUEST消息
void StunServer::OnPacket(
    rtc::AsyncPacketSocket* socket, const char* buf, size_t size,
    const rtc::SocketAddress& remote_addr,
    const rtc::PacketTime& packet_time) {
  // Parse the STUN message; eat any messages that fail to parse.
  rtc::ByteBufferReader bbuf(buf, size);
  StunMessage msg;
  if (!msg.Read(&bbuf)) {
    return;
  }
  // Send the message to the appropriate handler function.
  switch (msg.type()) {
    case STUN_BINDING_REQUEST:
      OnBindingRequest(&msg, remote_addr);
      break;
    default:
      SendErrorResponse(msg, remote_addr, 600, "Operation Not Supported");
  }
}
// 构建一个stun的response消息,并写入stun客户的外网地址
void StunServer::OnBindingRequest(
    StunMessage* msg, const rtc::SocketAddress& remote_addr) {
  StunMessage response;
  GetStunBindReqponse(msg, remote_addr, &response);
  SendResponse(response, remote_addr);
}
// 返回response数据给客户端
void StunServer::SendResponse(
    const StunMessage& msg, const rtc::SocketAddress& addr) {
  rtc::ByteBufferWriter buf;
  msg.Write(&buf);
  rtc::PacketOptions options;
  if (socket_->SendTo(buf.Data(), buf.Length(), addr, options) < 0)
    LOG_ERR(LS_ERROR) << "sendto";
}
客户端实现

客户端的实现简单来说就是构建一个标准的STUN消息请求并发送给不同的STUN服务器(或许存在多个不同的STUN服务器,我还不能明白存在多个STUN服务器的意义),并收集STUN服务器返回的自身的外网IP,并放到candidates中。虽然代码看着不少,因为代码要考虑健壮性。
CreateStunPorts -> StunPort::Create -> new StunPort -> new UDPPort -> UDPPort::Init -> UDPPort::OnLocalAddressReady -> StunRequestManager::SendDelayed -> UDPPort::OnSendPacket -> UDPPort::OnReadPacket -> StunBindingRequest::OnResponse -> StunRequestManager::CheckResponse -> UDPPort::OnStunBindingRequestSucceeded

// 构建一个StunPort,StunPort是对见Port的简单封装
void AllocationSequence::CreateStunPorts() {
  ...
  StunPort* port = StunPort::Create(
      session_->network_thread(), session_->socket_factory(), network_,
      session_->allocator()->min_port(), session_->allocator()->max_port(),
      session_->username(), session_->password(), config_->StunServers(),
      session_->allocator()->origin());
   ...
}
// 初始化StunPort
static StunPort* Create(rtc::Thread* thread, rtc::PacketSocketFactory* factory,  rtc::Network* network, ...) {
    StunPort* port = new StunPort(thread, factory, network, min_port, max_port, username, password, servers, origin);
    if (!port->Init()) { delete port; port = NULL;  }
}
// StunPort是对UDPPort的子类
StunPort(rtc::Thread* thread, rtc::PacketSocketFactory* factory,  rtc::Network* network, uint16_t min_port,  uint16_t max_port,  ...)
      : UDPPort(thread, factory, network, min_port, max_port, username, password, origin, false) {
    // UDPPort will set these to local udp, updating these to STUN.
    set_type(STUN_PORT_TYPE);
    set_server_addresses(servers);
}
// 创建一个UDP Socket,这个Socket也是WebRTC的封装,不细说,当Socket的状态发生变化的时候会通过Socket的`Signal`信号槽回调出来
bool UDPPort::Init() {
     ...
    socket_ = socket_factory()->CreateUdpSocket(rtc::SocketAddress(Network()->GetBestIP(), 0), min_port(), max_port());
    socket_->SignalReadPacket.connect(this, &UDPPort::OnReadPacket);
    socket_->SignalSentPacket.connect(this, &UDPPort::OnSentPacket);
    socket_->SignalReadyToSend.connect(this, &UDPPort::OnReadyToSend);
    socket_->SignalAddressReady.connect(this, &UDPPort::OnLocalAddressReady);
    requests_.SignalSendPacket.connect(this, &UDPPort::OnSendPacket);
   return true;
}
// Socket地址可用回调此函数,在这里会先收集local candiate,然后调用MaybePrepareStunCandidate获取本机的外网地址
void UDPPort::OnLocalAddressReady(rtc::AsyncPacketSocket* socket, const rtc::SocketAddress& address) {
  rtc::SocketAddress addr = address;
  MaybeSetDefaultLocalAddress(&addr);
  AddAddress(addr, addr, rtc::SocketAddress(), UDP_PROTOCOL_NAME, "", "", LOCAL_PORT_TYPE, ICE_TYPE_PREFERENCE_HOST, 0, "", false);
  MaybePrepareStunCandidate();
}
// 判断是否需要向STUN服务器请求本机外网地址或者判断是否完成了获取外网地址的请求
void UDPPort::MaybePrepareStunCandidate() {
  if (!server_addresses_.empty()) {
    SendStunBindingRequests();
  } else {
    MaybeSetPortCompleteOrError();
  }
}
// 依次向不同的服务器请求外网地址
void UDPPort::SendStunBindingRequests() {
  for (ServerAddresses::const_iterator it = server_addresses_.begin(); it != server_addresses_.end(); ++it) {
    SendStunBindingRequest(*it);
  }
}
// 如果STUN服务器地址可用,那么向此服务器发送一个binding请求
void UDPPort::SendStunBindingRequest(const rtc::SocketAddress& stun_addr) {
  if (stun_addr.IsUnresolvedIP()) {
    ResolveStunAddress(stun_addr);
  } else if (socket_->GetState() == rtc::AsyncPacketSocket::STATE_BOUND) {
    if (IsCompatibleAddress(stun_addr)) {
      requests_.Send(new StunBindingRequest(this, stun_addr, rtc::TimeMillis()));
    } else {
      OnStunBindingOrResolveRequestFailed(stun_addr);
    }
  }
}
// 进一步完成StunRequest的设定,并把此数据发送给stun服务器
void StunRequestManager::SendDelayed(StunRequest* request, int delay) {
  request->set_manager(this);
  RTC_DCHECK(requests_.find(request->id()) == requests_.end());
  request->set_origin(origin_);
  request->Construct();
  requests_[request->id()] = request;
  if (delay > 0) {
    thread_->PostDelayed(RTC_FROM_HERE, delay, request, MSG_STUN_SEND, NULL);
  } else {
    thread_->Send(RTC_FROM_HERE, request, MSG_STUN_SEND, NULL);
  }
}
// 通过SignalSendPacket发送数据,紧接着判断已经发送的次数,以及超时情况
void StunRequest::OnMessage(rtc::Message* pmsg) {
  tstamp_ = rtc::TimeMillis();
  rtc::ByteBufferWriter buf;
  msg_->Write(&buf);
  manager_->SignalSendPacket(buf.Data(), buf.Length(), this);
  OnSent();
  manager_->thread_->PostDelayed(RTC_FROM_HERE, resend_delay(), this, MSG_STUN_SEND, NULL);
}
// 调用udp socket把数据发出去
void UDPPort::OnSendPacket(const void* data, size_t size, StunRequest* req) {
  StunBindingRequest* sreq = static_cast<StunBindingRequest*>(req);
  rtc::PacketOptions options(DefaultDscpValue());
  if (socket_->SendTo(data, size, sreq->server_addr(), options) < 0)
    PLOG(LERROR, socket_->GetError()) << "sendto";
}
// 如果是stun服务器返回的消息,则调用CheckResponse检查是啥消息
void UDPPort::OnReadPacket(rtc::AsyncPacketSocket* socket, const char* data,  size_t size, const rtc::SocketAddress& remote_addr,  const rtc::PacketTime& packet_time) {
  if (server_addresses_.find(remote_addr) != server_addresses_.end()) {
    requests_.CheckResponse(data, size);
    return;
  }

  if (Connection* conn = GetConnection(remote_addr)) {
    conn->OnReadPacket(data, size, packet_time);
  } else {
    Port::OnReadPacket(data, size, remote_addr, PROTO_UDP);
  }
}

virtual void StunBindingRequest::OnResponse(StunMessage* response) override {
  const StunAddressAttribute* addr_attr = response->GetAddress(STUN_ATTR_MAPPED_ADDRESS);
  rtc::SocketAddress addr(addr_attr->ipaddr(), addr_attr->port());
  port_->OnStunBindingRequestSucceeded(server_addr_, addr);
  if (WithinLifetime(rtc::TimeMillis())) {
    port_->requests_.SendDelayed(new StunBindingRequest(port_, server_addr_, start_time_), port_->stun_keepalive_delay());
  }
}

bool StunRequestManager::CheckResponse(StunMessage* msg) {
   ...
  if (msg->type() == GetStunSuccessResponseType(request->type())) {
    request->OnResponse(msg);
  } else if (msg->type() == GetStunErrorResponseType(request->type())) {
    request->OnErrorResponse(msg);
  }
}

void UDPPort::OnStunBindingRequestSucceeded(const rtc::SocketAddress& stun_server_addr, const rtc::SocketAddress& stun_reflected_addr) {
    ...
    std::ostringstream url;
    AddAddress(stun_reflected_addr, socket_->GetLocalAddress(), related_address,  UDP_PROTOCOL_NAME, "", "", STUN_PORT_TYPE,  ICE_TYPE_PREFERENCE_SRFLX, 0, url.str(), false);
  }
  MaybeSetPortCompleteOrError();
}
NAT类型判断

通过刚刚的代码,我们已经拿到了Peer在NAT内外的ip地址了,那么此时我们就应该把这些candidates发送给对端。

//...

TURN

以下TURN内容完全来自维基-TURN,阅读原文获取更全面的信息。

TURN(全名Traversal Using Relay NAT),是一种数据传输协议(data-transfer protocol)。允许在TCP或UDP的连在线跨越NAT或防火墙。
TURN是一个client-server协议。TURN的NAT穿透方法与STUN类似,都是通过获取应用层中的公有地址达到NAT穿透。但实现TURN client的终端必须在通信开始前与TURN server进行交互,并要求TURN server产生"relay port",也就是relayed-transport-address。这时TURN server会创建peer,即远程端点(remote endpoints),开始进行中继(relay)的动作,TURN client利用relay port将数据发送至peer,再由peer转传到另一方的TURN client。

名词说明
realm域名,例如boyaa.com
SOFTWARE代理所使用的软件的文本描述,例如boyaa media, version 1.01
TurnServer(TURN服务端)

简单来说TURN服务要做的事情就是把Peer A的数据通过服务器转发给Peer B。这里我们要引入TURN协议RFC5766,协议存在的意义是为了标准化,要不只能自己玩了。协议规定了通信的格式以及他们交互流程。

  • TurnServer的工作流程如下:
    • TurnClient向TurnServer发送一个STUN_ALLOCATE_REQUEST请求,TurnServer会在服务端上生成一个对应的TurnServerAllocation,并返回TurnClient的外网地址和TurnServerAllocation的转发地址,TurnServerAllocation包含了一个UDP Socket,用于转发TurnClient的非STUN/TURN数据给Peer,也用于监听Peer发过来的数据
    • TurnClient向TurnServer发送一个TURN_CHANNEL_BIND_REQUEST请求,会在TurnServerAllocation上生成一个Channel,此Channel保护了TurnClient的channel_id和Peer的IP Address
    • TurnClient向TurnServer发送channel message时,TurnServerAllocation会找到对应的Peer地址,并通过TurnServerAllocation内部的UDP Socket转发给Peer
    • Peer向TurnServer发送数据,TurnServerAllocation会通过Peer地址找到对应的TurnClient,并通过TurnClient的Socket发送给TurnClient
  • TurnServer支持TCP/UDP协议的数据输入,但是内部都会通过UDP转发给Peer;同样的Peer只能通过UDP发送给TurnServer,然后TurnServer会通过TurnClient原本的协议转发给TurnClient
  • TurnClient如果要同时发送数据给Peer A和Peer B,TurnClient需要向TurnServerAllocation请求两次TURN_CHANNEL_BIND_REQUEST,用不同的channel_id分别对应Peer A和Peer B的IP AddreTurnClient需要向TurnServer发送两次数据,一份用channel_id_for_peer_a发送给Peer A,一份用channel_id_for_peer_b发送给Peer B,也就是说TurnServer真的只是负责转发而已
  • Channel和Indication的区别:发送Channel是TurnClient和Peer存在隐射关系,TurnClient可以和Peer可以相互发送数据;Indication是只有Peer发送数据给TurnClient。因为TurnClient发送的数据格式只有ChannelMessage一种。
  • TurnServer存在一个比较严重的问题:每一个TurnClient都需要一个在TurnServer对应一个Relay Transport(UDP Socket)
    在这里插入图片描述
int main(int argc, char **argv) {
  rtc::SocketAddress int_addr;
  if (!int_addr.FromString(argv[1])) {
    return 1;
  }

  rtc::IPAddress ext_addr;
  if (!IPFromString(argv[2], &ext_addr)) {
    return 1;
  }
  rtc::Thread* main = rtc::Thread::Current();
  rtc::AsyncUDPSocket* int_socket = rtc::AsyncUDPSocket::Create(main->socketserver(), int_addr);
  if (!int_socket) {
    return 1;
  }

  cricket::TurnServer server(main);
  TurnFileAuth auth(argv[4]);
  server.set_realm(argv[3]);
  server.set_software(kSoftware);
  server.set_auth_hook(&auth);
  // 这是一个UDP Socket用于接收所有TurnClient发送过来的数据
  server.AddInternalSocket(int_socket, cricket::PROTO_UDP);
  //  这是一个UDP SocketFactory,每一个TurnServerAllocation都需要一个新的UDP Socket,用于转发数据给Peer和接收Peer发送过来的数据
  server.SetExternalSocketFactory(new rtc::BasicPacketSocketFactory(), rtc::SocketAddress(ext_addr, 0));
  main->Run();
  return 0;
}
  • 通信第一步:TURN Client需要先向TURN Server请求分配一个Allocate
void TurnServer::HandleAllocateRequest(TurnServerConnection* conn, const TurnMessage* msg, const std::string& key) {
  TurnServerAllocation* alloc = CreateAllocation(conn, proto, key);
}

TurnServerAllocation* TurnServer::CreateAllocation(TurnServerConnection* conn, int proto, const std::string& key) {
  rtc::AsyncPacketSocket* external_socket = (external_socket_factory_) ? external_socket_factory_->CreateUdpSocket(external_addr_, 0, 0) : NULL;
  // The Allocation takes ownership of the socket.
  TurnServerAllocation* allocation = new TurnServerAllocation(this, thread_, *conn, external_socket, key);
  allocation->SignalDestroyed.connect(this, &TurnServer::OnAllocationDestroyed);
  allocations_[*conn].reset(allocation);
  return allocation;
}
  • 通信第二步:TurnClient向TurnServer发送一个TURN_CHANNEL_BIND_REQUEST请求
void TurnServerAllocation::HandleChannelBindRequest(const TurnMessage* msg) {
  // Check mandatory attributes.
  const StunUInt32Attribute* channel_attr = msg->GetUInt32(STUN_ATTR_CHANNEL_NUMBER);
  const StunAddressAttribute* peer_attr = msg->GetAddress(STUN_ATTR_XOR_PEER_ADDRESS);
  // Check that channel id is valid.
  int channel_id = channel_attr->value() >> 16;
  Channel* channel1 = new Channel(thread_, channel_id, peer_attr->GetAddress());
  channel1->SignalDestroyed.connect(this, &TurnServerAllocation::OnChannelDestroyed);
  channels_.push_back(channel1);

  // Channel binds also refresh permissions.
  AddPermission(peer_attr->GetAddress().ipaddr());
}
  • 通信第三步:TurnClient和Peer进行数据传输
// TURN Client发送数据给Peer
void TurnServerAllocation::HandleChannelData(const char* data, size_t size) {
  // Extract the channel number from the data.
  uint16_t channel_id = rtc::GetBE16(data);
  Channel* channel = FindChannel(channel_id);
  SendExternal(data + TURN_CHANNEL_HEADER_SIZE, size - TURN_CHANNEL_HEADER_SIZE, channel->peer());
}
// Peer发送数据给TurnClient
void TurnServerAllocation::OnExternalPacket(rtc::AsyncPacketSocket* socket, const char* data, size_t size, const rtc::SocketAddress& addr, const rtc::PacketTime& packet_time) {
  RTC_DCHECK(external_socket_.get() == socket);
  Channel* channel = FindChannel(addr);
  if (channel) {
    // There is a channel bound to this address. Send as a channel message.
    rtc::ByteBufferWriter buf;
    buf.WriteUInt16(channel->id());
    buf.WriteUInt16(static_cast<uint16_t>(size));
    buf.WriteBytes(data, size);
    server_->Send(&conn_, buf);
  } else if (!server_->enable_permission_checks_ || HasPermission(addr.ipaddr())) {
    // No channel, but a permission exists. Send as a data indication.
    TurnMessage msg;
    msg.SetType(TURN_DATA_INDICATION);
    msg.SetTransactionID(rtc::CreateRandomString(kStunTransactionIdLength));
    msg.AddAttribute(rtc::MakeUnique<StunXorAddressAttribute>(STUN_ATTR_XOR_PEER_ADDRESS, addr));
    msg.AddAttribute(rtc::MakeUnique<StunByteStringAttribute>(STUN_ATTR_DATA, data, size));
    server_->SendStun(&conn_, &msg);
  } else {
    LOG_J(LS_WARNING, this) << "Received external packet without permission, " << "peer=" << addr;
  }
}
TurnPort(TURN客户端)

ICE

以下ICE内容完全来自维基-交互式连接创建,阅读原文获取更全面的信息。

交互式连接创建(Interactive Connectivity Establishment),一种综合性的NAT穿越的技术。
交互式连接创建是由IETF的MMUSIC工作组开发出来的一种framework,可集成各种NAT穿透技术,如STUN、TURN(Traversal Using Relay NAT,中继NAT实现的穿透)、RSIP(Realm Specific IP,特定域IP)等。该framework可以让SIP的客户端利用各种NAT穿透方式打穿远程的防火墙。

NAT类型

以下NAT内容完全来自维基-网络地址转换,阅读原文获取更全面的信息。

网络地址转换(英语:Network Address Translation,缩写:NAT;又称网络掩蔽、IP掩蔽)在计算机网络中是一种在IP数据包通过路由器或防火墙时重写来源IP地址或目的IP地址的技术。这种技术被普遍使用在有多台主机但只通过一个公有IP地址访问互联网的私有网络中。它是一个方便且得到了广泛应用的技术。当然,NAT也让主机之间的通信变得复杂,导致了通信效率的降低。

完全圆锥形NAT(Full cone NAT)

  • 一旦一个内部地址(iAddr:port)映射到外部地址(eAddr:port),所有发自iAddr:port的包都经由eAddr:port向外发送。任意外部主机都能通过给eAddr:port发包到达iAddr:port(注:port不需要一样)
    在这里插入图片描述

受限圆锥形NAT(Address-Restricted cone NAT)

  • 内部客户端必须首先发送数据包到对方(IP=X.X.X.X),然后才能接收来自X.X.X.X的数据包。在限制方面,唯一的要求是数据包是来自X.X.X.X。
  • 内部地址(iAddr:port1)映射到外部地址(eAddr:port2),所有发自iAddr:port1的包都经由eAddr:port2向外发送。外部主机(hostAddr:any)能通过给eAddr:port2发包到达iAddr:port1。(注:any指外部主机源端口不受限制,但是目的端口必须是port2。只有外部主机数据包的目的IP 为 内部客户端的所映射的外部ip,且目的端口为port2时数据包才被放行。
    Restricted Cone NAT.svg

端口受限圆锥形NAT(Port-Restricted cone NAT)

类似受限制锥形NAT(Restricted cone NAT),但是还有端口限制。

  • 一旦一个内部地址(iAddr:port1)映射到外部地址(eAddr:port2),所有发自iAddr:port1的包都经由eAddr:port2向外发送。
  • 在受限圆锥型NAT基础上增加了外部主机源端口必须是固定的。
    Port Restricted Cone NAT.svg

对称NAT(Symmetric NAT)

  • 每一个来自相同内部IP与端口,到一个特定目的地地址和端口的请求,都映射到一个独特的外部IP地址和端口。同一内部IP与端口发到不同的目的地和端口的信息包,都使用不同的映射
  • 只有曾经收到过内部主机数据的外部主机,才能够把数据包发回
    Symmetric NAT.svg
  • 1
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值