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 interfaceint length = in.read(packet.array());...// Write response packets backout.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 在对数据报和协议的解决上,要相对容易实现少量。
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » Android 上的 Shadowsocks 源码浅析