Android 上的 Shadowsocks 源码浅析
一、 代理的简单流程
- 应用程序使用 socket,将相应的数据包发送到真实的网络设备上。一般移动设备只有无线网卡,因此是发送到真实的WiFi设备上。
- Android 系统通过 iptables,使用 NAT,将所有的数据包转发到 TUN 虚拟网络设备上去,端口是 tun0。
- VPN 程序通过打开 /dev/tun 设备,并读取该设备上的数据,可以获得所有转发到 TUN 虚拟网络设备上的 IP 包。因为设备上的所有 IP 包都会被 NAT 转成原地址是 tun0 端口发送的,所以也就是说你的 VPN 程序可以获得进出该设备的几乎所有的数据(也有例外,不是全部,比如回环数据就无法获得)。
- VPN 数据可以做一些处理,然后将处理过后的数据包,通过真实的网络设备发送出去。为了防止发送的数据包再被转到 TUN 虚拟网络设备上,VPN 程序所使用的 socket 必须先被明确绑定到真实的网络设备上去。
二、利用 VpnService 截获网络层所有 IP 数据报
以下代码截取自 shadowsocks-android-java
2.1 启动 VpnService
- 客户端程序一般要首先调用 VpnService.prepare() 函数:
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (LocalVpnService.IsRunning != isChecked) {
switchProxy.setEnabled(false);
if (isChecked) {
Intent intent = LocalVpnService.prepare(this);
if (intent == null) {
startVPNService();
} else {
startActivityForResult(intent, START_VPN_SERVICE_REQUEST_CODE);
}
} else {
LocalVpnService.IsRunning = false;
}
}
}
- 目前Android只支持一条 VPN 连接,如果新的程序想建立一条 VPN 连接,必须先中断系统中当前存在的那个 VPN 连接。在正式建立之前,系统还会弹出一个对话框,让用户点击确认。
- VpnService.prepare() 函数的目的,主要是用来检查当前系统中是不是已经存在一个VPN连接。
- 如果当前系统中没有 VPN 连接,或者存在的 VPN 连接不是本程序建立的,则VpnService.prepare() 函数会返回一个 intent。这个 intent 就是用来触发确认对话框的,程序会接着调用 startActivityForResult 将对话框弹出来等用户确认。如果用户确认了,则会关闭前面已经建立的 VPN 连接,并重置虚拟端口。该对话框返回的时候,会调用 onActivityResult 函数,并告之用户的选择。
- 如果当前系统中有 VPN 连接,并且这个连接就是本程序建立的,则函数会返回 null,就不需要用户再确认了。因为用户在本程序第一次建立 VPN 连接的时候已经确认过了,就不要再重复确认了,直接手动调用 onActivityResult 函数即可。
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
if (requestCode == START_VPN_SERVICE_REQUEST_CODE) {
if (resultCode == RESULT_OK) {
startVPNService();
} else {
switchProxy.setChecked(false);
switchProxy.setEnabled(true);
onLogReceived("canceled.");
}
return;
}
...
...
super.onActivityResult(requestCode, resultCode, intent);
}
private void startVPNService() {
String ProxyUrl = readProxyUrl();
if (!isValidUrl(ProxyUrl)) {
Toast.makeText(this, R.string.err_invalid_url, Toast.LENGTH_SHORT).show();
switchProxy.post(new Runnable() {
@Override
public void run() {
switchProxy.setChecked(false);
switchProxy.setEnabled(true);
}
});
return;
}
textViewLog.setText("");
GL_HISTORY_LOGS = null;
onLogReceived("starting...");
LocalVpnService.ProxyUrl = ProxyUrl;
startService(new Intent(this, LocalVpnService.class));
}
2.2 创建并初始化虚拟网络端口
- 通过在 VpnService 类中的一个内部类 Builder 来完成。
通用代码如下:
Builder builder = new Builder();
builder.setMtu(...);
builder.addAddress(...);
builder.addRoute(...);
builder.addDnsServer(...);
builder.addSearchDomain(...);
builder.setSession(...);
builder.setConfigureIntent(...);
ParcelFileDescriptor interface = builder.establish();
在具体实践中,可参考下面的代码:
private ParcelFileDescriptor establishVPN() throws Exception {
Builder builder = new Builder();
builder.setMtu(ProxyConfig.Instance.getMTU());
if (ProxyConfig.IS_DEBUG)
System.out.printf("setMtu: %d\n", ProxyConfig.Instance.getMTU());
IPAddress ipAddress = ProxyConfig.Instance.getDefaultLocalIP();
LOCAL_IP = CommonMethods.ipStringToInt(ipAddress.Address);
builder.addAddress(ipAddress.Address, ipAddress.PrefixLength);
if (ProxyConfig.IS_DEBUG)
System.out.printf("addAddress: %s/%d\n", ipAddress.Address, ipAddress.PrefixLength);
for (ProxyConfig.IPAddress dns : ProxyConfig.Instance.getDnsList()) {
builder.addDnsServer(dns.Address);
if (ProxyConfig.IS_DEBUG)
System.out.printf("addDnsServer: %s\n", dns.Address);
}
if (ProxyConfig.Instance.getRouteList().size() > 0) {
for (ProxyConfig.IPAddress routeAddress : ProxyConfig.Instance.getRouteList()) {
builder.addRoute(routeAddress.Address, routeAddress.PrefixLength);
if (ProxyConfig.IS_DEBUG)
System.out.printf("addRoute: %s/%d\n", routeAddress.Address, routeAddress.PrefixLength);
}
builder.addRoute(CommonMethods.ipIntToString(ProxyConfig.FAKE_NETWORK_IP), 16);
if (ProxyConfig.IS_DEBUG)
System.out.printf("addRoute for FAKE_NETWORK: %s/%d\n", CommonMethods.ipIntToString(ProxyConfig.FAKE_NETWORK_IP), 16);
} else {
builder.addRoute("0.0.0.0", 0);
if (ProxyConfig.IS_DEBUG)
System.out.printf("addDefaultRoute: 0.0.0.0/0\n");
}
Class<?> SystemProperties = Class.forName("android.os.SystemProperties");
Method method = SystemProperties.getMethod("get", new Class[]{String.class});
ArrayList<String> servers = new ArrayList<String>();
for (String name : new String[]{"net.dns1", "net.dns2", "net.dns3", "net.dns4",}) {
String value = (String) method.invoke(null, name);
if (value != null && !"".equals(value) && !servers.contains(value)) {
servers.add(value);
if (value.replaceAll("\\d", "").length() == 3){//防止IPv6地址导致问题
builder.addRoute(value, 32);
} else {
builder.addRoute(value, 128);
}
if (ProxyConfig.IS_DEBUG)
System.out.printf("%s=%s\n", name, value);
}
}
if (AppProxyManager.isLollipopOrAbove){
if (AppProxyManager.Instance.proxyAppInfo.size() == 0){
writeLog("Proxy All Apps");
}
for (AppInfo app : AppProxyManager.Instance.proxyAppInfo){
builder.addAllowedApplication("com.vm.shadowsocks");//需要把自己加入代理,不然会无法进行网络连接
try{
builder.addAllowedApplication(app.getPkgName());
writeLog("Proxy App: " + app.getAppLabel());
} catch (Exception e){
e.printStackTrace();
writeLog("Proxy App Fail: " + app.getAppLabel());
}
}
} else {
writeLog("No Pre-App proxy, due to low Android version.");
}
Intent intent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0);
builder.setConfigureIntent(pendingIntent);
builder.setSession(ProxyConfig.Instance.getSessionName());
ParcelFileDescriptor pfdDescriptor = builder.establish();
onStatusChanged(ProxyConfig.Instance.getSessionName() + getString(R.string.vpn_connected_status), true);
return pfdDescriptor;
}
-
如果一切正常的话,tun0 虚拟网络接口就建立完成了。并且,同时还会通过 iptables 命令,修改 NAT 表,将所有数据转发到 tun0 接口上。
-
接下来,就可以通过读写
VpnService.Builder()
返回的 ParcelFileDescriptor 实例来获得设备上所有向外发送的 IP 数据包和返回处理过后的 IP 数据包到 TCP/IP 协议栈。
同样的,通用示例代码比较简单,如下:
// Packets to be sent are queued in this input stream.
FileInputStream in = new FileInputStream(interface.getFileDescriptor());
// Packets received need to be written to this output stream.
FileOutputStream out = new FileOutputStream(interface.getFileDescriptor());
// Allocate the buffer for a single packet.
ByteBuffer packet = ByteBuffer.allocate(32767);
...
// Read packets sending to this interface
int length = in.read(packet.array());
...
// Write response packets back
out.write(packet.array(), 0, length);
而具体在实践中, 我们也是拿到 VpnService.Builder()
返回的 ParcelFileDescriptor 实例之后,对其进行进一步的操作:
private void runVPN() throws Exception {
this.m_VPNInterface = establishVPN();
this.m_VPNOutputStream = new FileOutputStream(m_VPNInterface.getFileDescriptor());
FileInputStream in = new FileInputStream(m_VPNInterface.getFileDescriptor());
int size = 0;
while (size != -1 && IsRunning) {
while ((size = in.read(m_Packet)) > 0 && IsRunning) {
if (m_DnsProxy.Stopped || m_TcpProxyServer.Stopped) {
in.close();
throw new Exception("LocalServer stopped.");
}
onIPPacketReceived(m_IPHeader, size);
}
Thread.sleep(20);
}
in.close();
disconnectVPN();
}
-
每次调用
FileInputStream.read()
函数会读取一个IP数据包,而调用FileOutputStream.write()
函数会写入一个 IP 数据包到 TCP/IP 协议栈。 -
到这里,我们就可以让某个应用程序方便的截获设备上所有发送出去和接收到的数据包。而我们能获得这些数据包,当然可以非常方便的将它们封装起来,和远端 VPN 服务器建立 VPN 链接。接下来,我们看看实践中是如何封装这些数据包的。
三、处理截获到的 IP 数据包
3.1 在 VPNService 启动时,创建并运行了线程 TcpProxyServer,类似于代理服务器。
@Override
public synchronized void run() {
try {
...
...
m_TcpProxyServer = new TcpProxyServer(0);
m_TcpProxyServer.start();
writeLog("LocalTcpServer started.");
m_DnsProxy = new DnsProxy();
m_DnsProxy.start();
writeLog("LocalDnsProxy started.");
while (true) {
if (IsRunning) {
//加载配置文件
writeLog("set shadowsocks/(http proxy)");
try {
ProxyConfig.Instance.m_ProxyList.clear();
ProxyConfig.Instance.addProxyToList(ProxyUrl);
writeLog("Proxy is: %s", ProxyConfig.Instance.getDefaultProxy());
} catch (Exception e) {
;
String errString = e.getMessage();
if (errString == null || errString.isEmpty()) {
errString = e.toString();
}
IsRunning = false;
onStatusChanged(errString, false);
continue;
}
String welcomeInfoString = ProxyConfig.Instance.getWelcomeInfo();
if (welcomeInfoString != null && !welcomeInfoString.isEmpty()) {
writeLog("%s", ProxyConfig.Instance.getWelcomeInfo());
}
writeLog("Global mode is " + (ProxyConfig.Instance.globalMode ? "on" : "off"));
runVPN();
} else {
Thread.sleep(100);
}
}
} catch (InterruptedException e) {
System.out.println(e);
} catch (Exception e) {
e.printStackTrace();
writeLog("Fatal error: %s", e.toString());
} finally {
writeLog("App terminated.");
dispose();
}
}
3.2 VPNService 在网络层截获所有流量,从数据包的的 IP 头和 TCP 头解析该数据包去往的 IP 地址、端口号,并将它们改成本地另一个 TCP 服务器的地址和端口,也就是TcpProxyServer。 这里就实现了一个网络层的转发,由网络层转发到传输层。
private void runVPN() throws Exception {
this.m_VPNInterface = establishVPN();
this.m_VPNOutputStream = new FileOutputStream(m_VPNInterface.getFileDescriptor());
FileInputStream in = new FileInputStream(m_VPNInterface.getFileDescriptor());
int size = 0;
while (size != -1 && IsRunning) {
//读取系统的IP包,自旋等待
while ((size = in.read(m_Packet)) > 0 && IsRunning) {
if (m_DnsProxy.Stopped || m_TcpProxyServer.Stopped) {
in.close();
throw new Exception("LocalServer stopped.");
}
//处理接收到的IP包
onIPPacketReceived(m_IPHeader, size);
}
Thread.sleep(20);
}
in.close();
disconnectVPN();
}
void onIPPacketReceived(IPHeader ipHeader, int size) throws IOException {
//首先判断接收到的包的协议,根据流中的第九位+偏移量来判断,TCP是6,UDP是17
switch (ipHeader.getProtocol()) {
case IPHeader.TCP:
TCPHeader tcpHeader = m_TCPHeader;
tcpHeader.m_Offset = ipHeader.getHeaderLength();
if (ipHeader.getSourceIP() == LOCAL_IP) {
if (tcpHeader.getSourcePort() == m_TcpProxyServer.Port) {// 收到本地TCP服务器数据
NatSession session = NatSessionManager.getSession(tcpHeader.getDestinationPort());
if (session != null) {
ipHeader.setSourceIP(ipHeader.getDestinationIP());
tcpHeader.setSourcePort(session.RemotePort);
ipHeader.setDestinationIP(LOCAL_IP);
CommonMethods.ComputeTCPChecksum(ipHeader, tcpHeader);
m_VPNOutputStream.write(ipHeader.m_Data, ipHeader.m_Offset, size);
m_ReceivedBytes += size;
} else {
System.out.printf("NoSession: %s %s\n", ipHeader.toString(), tcpHeader.toString());
}
} else {
//这里是收到NAT的数据,向TCP服务器发送,转发到代理服务器
// 添加端口映射
int portKey = tcpHeader.getSourcePort();
NatSession session = NatSessionManager.getSession(portKey);
if (session == null || session.RemoteIP != ipHeader.getDestinationIP() || session.RemotePort != tcpHeader.getDestinationPort()) {
session = NatSessionManager.createSession(portKey, ipHeader.getDestinationIP(), tcpHeader.getDestinationPort());
}
session.LastNanoTime = System.nanoTime();
session.PacketSent++;//注意顺序
int tcpDataSize = ipHeader.getDataLength() - tcpHeader.getHeaderLength();
if (session.PacketSent == 2 && tcpDataSize == 0) {
return;//丢弃tcp握手的第二个ACK报文。因为客户端发数据的时候也会带上ACK,这样可以在服务器Accept之前分析出HOST信息。
}
//分析数据,找到host
if (session.BytesSent == 0 && tcpDataSize > 10) {
int dataOffset = tcpHeader.m_Offset + tcpHeader.getHeaderLength();
String host = HttpHostHeaderParser.parseHost(tcpHeader.m_Data, dataOffset, tcpDataSize);
if (host != null) {
session.RemoteHost = host;
} else {
System.out.printf("No host name found: %s", session.RemoteHost);
}
}
// 转发给本地TCP服务器
ipHeader.setSourceIP(ipHeader.getDestinationIP());
ipHeader.setDestinationIP(LOCAL_IP);
tcpHeader.setDestinationPort(m_TcpProxyServer.Port);
CommonMethods.ComputeTCPChecksum(ipHeader, tcpHeader);
m_VPNOutputStream.write(ipHeader.m_Data, ipHeader.m_Offset, size);
session.BytesSent += tcpDataSize;//注意顺序
m_SentBytes += size;
}
}
break;
case IPHeader.UDP:
// 转发DNS数据包:
UDPHeader udpHeader = m_UDPHeader;
udpHeader.m_Offset = ipHeader.getHeaderLength();
if (ipHeader.getSourceIP() == LOCAL_IP && udpHeader.getDestinationPort() == 53) {
m_DNSBuffer.clear();
m_DNSBuffer.limit(ipHeader.getDataLength() - 8);
DnsPacket dnsPacket = DnsPacket.FromBytes(m_DNSBuffer);
if (dnsPacket != null && dnsPacket.Header.QuestionCount > 0) {
m_DnsProxy.onDnsRequestReceived(ipHeader, udpHeader, dnsPacket);
}
}
break;
}
}
3.3 当 TcpProxyServer 在传输层得到刚才转发过来的流量,根据代理规则向外网建立连接并建立隧道,在传输层通过 TCP Socket 进行转发。
3.4 当 TcpProxyServer 获得外网回来的流量后,会转发给系统内部一个不存在的 IP 地址,TUN 虚拟网卡截获到去往这个不存在的 IP 地址数据报后,用同样的方法将它们修改回原本的地址和端口号,并将它们写入到虚拟网卡中,交由系统的 TCP/IP 协议栈去处理。
- TcpProxyServer 实现了 Runnbale,首先是 TcpProxyServer 的构造函数:
public TcpProxyServer(int port) throws IOException {
m_Selector = Selector.open();
m_ServerSocketChannel = ServerSocketChannel.open();
m_ServerSocketChannel.configureBlocking(false);
//这里外部是传入0的,端口号 0 是一种由系统指定动态生成的端口。
m_ServerSocketChannel.socket().bind(new InetSocketAddress(port));
//Selector的用法可以自行百度下,是NIO下的一个类,一个单独的线程可以管理多个channel,从而管理多个网络连接
m_ServerSocketChannel.register(m_Selector, SelectionKey.OP_ACCEPT);
this.Port = (short) m_ServerSocketChannel.socket().getLocalPort();
System.out.printf("AsyncTcpServer listen on %d success.\n", this.Port & 0xFFFF);
}
- 然后我们看看他的 run() 方法:
@Override
public void run() {
try {
while (true) {
m_Selector.select();
Iterator<SelectionKey> keyIterator = m_Selector.selectedKeys().iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isValid()) {
try {
if (key.isReadable()) {
((Tunnel) key.attachment()).onReadable(key);
} else if (key.isWritable()) {
((Tunnel) key.attachment()).onWritable(key);
} else if (key.isConnectable()) {
((Tunnel) key.attachment()).onConnectable();
} else if (key.isAcceptable()) {
onAccepted(key);
}
} catch (Exception e) {
System.out.println(e.toString());
}
}
keyIterator.remove();
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
this.stop();
System.out.println("TcpServer thread exited.");
}
}
- TcpProxyServer 里面的大多数业务都使用 Tunnel 来执行,我们看看 Tunnel 的成员变量,可以看到他维护了一个 Channel 用于收发数据,一个 m_BrotherTunnel 。对于 ShadowsocksTunnel 来说,他的 m_BrotherTunnel 就是 localTunnel,m_ServerEP 就是代理服务器的地址了,m_DestAddress 是资源服务器,也就是真实地址。
private SocketChannel m_InnerChannel;
private ByteBuffer m_SendRemainBuffer;
private Selector m_Selector;
private Tunnel m_BrotherTunnel;
private boolean m_Disposed;
private InetSocketAddress m_ServerEP;
protected InetSocketAddress m_DestAddress;
按照顺序,先来看 onAccepted()
方法:
void onAccepted(SelectionKey key) {
Tunnel localTunnel = null;
try {
//首先用accept获取本地连接TCPProxy的channel
SocketChannel localChannel = m_ServerSocketChannel.accept();
//把channel包装成这里自定义的Tunnel
localTunnel = TunnelFactory.wrap(localChannel, m_Selector);
//获取代理服务器的地址
InetSocketAddress destAddress = getDestAddress(localChannel);
if (destAddress != null) {
//这里我们用的SS协议,所以创建的是一个ShadowsocksTunnel
Tunnel remoteTunnel = TunnelFactory.createTunnelByConfig(destAddress, m_Selector);
remoteTunnel.setBrotherTunnel(localTunnel);//关联兄弟
localTunnel.setBrotherTunnel(remoteTunnel);//关联兄弟
remoteTunnel.connect(destAddress);//开始连接
} else {
LocalVpnService.Instance.writeLog("Error: socket(%s:%d) target host is null.", localChannel.socket().getInetAddress().toString(), localChannel.socket().getPort());
localTunnel.dispose();
}
} catch (Exception e) {
e.printStackTrace();
LocalVpnService.Instance.writeLog("Error: remote socket create failed: %s", e.toString());
if (localTunnel != null) {
localTunnel.dispose();
}
}
}
我们注意到上面的这行代码 remoteTunnel.connect(destAddress);//开始连接
,看下面:
public void connect(InetSocketAddress destAddress) throws Exception {
if (LocalVpnService.Instance.protect(m_InnerChannel.socket())) {//保护socket不走vpn
m_DestAddress = destAddress;
m_InnerChannel.register(m_Selector, SelectionKey.OP_CONNECT, this);//注册连接事件
m_InnerChannel.connect(m_ServerEP);//连接目标
} else {
throw new Exception("VPN protect socket failed.");
}
}
上面的 LocalVpnService.Instance.protect(m_InnerChannel.socket()))
,一般的应用程序,在获得这些 IP 数据包后,会将它们再通过 socket 发送出去。但是,这样做会有问题,你的程序建立的 socket 和别的程序建立的 socket 其实没有区别,发送出去后,还是会被转发到 tun0 接口,再回到你的程序,这样就是一个死循环了。为了解决这个问题,VpnService 类提供了一个叫 protect() 的函数,在 VPN 程序自己建立 socket 之后,必须要对其进行保护。
- 连接成功后,Selector 会回调 TcpProxyServer,取出 tunnel,调用 onConnectable() 方法,看下面:
@SuppressLint("DefaultLocale")
public void onConnectable() {
try {
if (m_InnerChannel.finishConnect()) {//连接成功
onConnected(GL_BUFFER);//通知子类TCP已连接,子类可以根据协议实现握手等。
} else {//连接失败
LocalVpnService.Instance.writeLog("Error: connect to %s failed.", m_ServerEP);
this.dispose();
}
} catch (Exception e) {
LocalVpnService.Instance.writeLog("Error: connect to %s failed: %s", m_ServerEP, e);
this.dispose();
}
}
@Override
protected void onConnected(ByteBuffer buffer) throws Exception {
buffer.clear();
// https://shadowsocks.org/en/spec/protocol.html
buffer.put((byte) 0x03);//domain
byte[] domainBytes = m_DestAddress.getHostName().getBytes();
buffer.put((byte) domainBytes.length);//domain length;
buffer.put(domainBytes);
buffer.putShort((short) m_DestAddress.getPort());
buffer.flip();
byte[] _header = new byte[buffer.limit()];
buffer.get(_header);
buffer.clear();
buffer.put(m_Encryptor.encrypt(_header));
buffer.flip();
if (write(buffer, true)) {
m_TunnelEstablished = true;
onTunnelEstablished();
} else {
m_TunnelEstablished = true;
this.beginReceive();
}
}
-
onReadable()
和onWritable()
注释里已经讲得很明白了
public void onReadable(SelectionKey key) {
try {
ByteBuffer buffer = GL_BUFFER;
buffer.clear();
int bytesRead = m_InnerChannel.read(buffer);
if (bytesRead > 0) {
buffer.flip();
afterReceived(buffer);//先让子类处理,例如解密数据。
if (isTunnelEstablished() && buffer.hasRemaining()) {//将读到的数据,转发给兄弟。
m_BrotherTunnel.beforeSend(buffer);//发送之前,先让子类处理,例如做加密等。
if (!m_BrotherTunnel.write(buffer, true)) {
key.cancel();//兄弟吃不消,就取消读取事件。
if (ProxyConfig.IS_DEBUG)
System.out.printf("%s can not read more.\n", m_ServerEP);
}
}
} else if (bytesRead < 0) {
this.dispose();//连接已关闭,释放资源。
}
} catch (Exception e) {
e.printStackTrace();
this.dispose();
}
}
public void onWritable(SelectionKey key) {
try {
this.beforeSend(m_SendRemainBuffer);//发送之前,先让子类处理,例如做加密等。
if (this.write(m_SendRemainBuffer, false)) {//如果剩余数据已经发送完毕
key.cancel();//取消写事件。
if (isTunnelEstablished()) {
m_BrotherTunnel.beginReceive();//这边数据发送完毕,通知兄弟可以收数据了。
} else {
this.beginReceive();//开始接收代理服务器响应数据
}
}
} catch (Exception e) {
this.dispose();
}
}
四、小结
- 无论是实现代理还是 VPN ,它们都具有共同点。
1、首先,都要借助 TUN 虚拟网络设备得到数据。
2、根据协议和实现需要,对数据报进行处理。
3、最后,通过真实的网卡将数据发送出去。
4、在远程服务器端也需要相应的配置。
- 对于代理的实现,可供选择项比较少,通过 Socks5 协议,相对于使用 VPN 的方式要便捷一些。
- 对于 VPN 的实现,可供选择项比较多,功能点也比较多,但相对于代理的实现也要复杂很多。代理 相对于 VPN 在对数据报和协议的处理上,要相对容易实现一些。