Loading...
墨滴

羽毛

2021/12/23  阅读:126  主题:默认主题

网络初识(一)

背景

政府要求app网络请求ipv6的流量占比超过50%,58同城app现状:有时达标(78%),有时不达标(19%)。借此机会,我们复习下网络基础知识。

流量占比统计方案

如何统计流量占比?通过Charles抓包能统计流量占比吗?

从上图可以看出,Charles能看到请求接口域名所对应的IP。现在运维提供支持ipv6的wifi,但是android只能设置ipv4的代理地址,抓包看发出的请求还是ipv4;Charles也没有提供统计的功能;网信办测试的环境是app选择三大运营商,跑Monkey看流量占比,所以我们不能使用Charles抓包统计流量占比。

听云

接入听云,可以跑出ipv6和ipv4流量占比部分数据。

客户端日志

客户端在okhttp加拦截,输出dns解析和网络请求,将结果发送到tracker,native发出的请求都能统计到。

以上两种方式统计数据都不准确,只能当做补充。

wireshark

网信办测试完成后会提供一个pcap文件,通过调研,可以使用Wireshark分析pcap文件。Wireshark(前称Ethereal)是一个网络包分析软件,可以展示显示出最为详细的网络包资料。 如下图所示,可以按协议过滤,并展示http协议的uri。

wireshark可以统计出哪些域名使用ipv4地址,但是没有汇总出流量占比。

开发分析脚本

网信办提供了自研工具AndroidTrafficRecord,可以统计出流量占比。但是他没有统计出哪些域名在使用ipv4,他的报告只提供一个pcap文件。 我们解压开他们的工具,发现他们使用了python脚本,并且使用了dpkt模块分析数据包。经过调研,得出结论:解析速度dpkt > scapy > pyshark,但在功能易用性上来说大致是相反的结论。所以我们选择使用pyshark分析网络包。

我们统计出ipv6占比,我们统计的结果和网信办工具统计的结果非常吻合,只差1%(pcap文件有个别包数据格式有问题,解析不了,丢弃这块数据),所以我们统计的方式没有问题。 我们输出Excel,能看到各个ipv4的占比,并且我们解析出对应的域名。

 //cap pcap文件
 for pkt in cap://pkt 数据包
    dns = pkt.dns//dns
    http = pkt.http//http
    tls = pkt.tls//https
    
    ip = pkt.ip//ip
    dst_host = ip.dst_host //目标IP地址

打断点看看能到什么数据

网络知识

OSI模型

我们统计数据需要分析哪层的数据?我从网上拷了一个张图

通过这个模型我们可以看出,只需要解析传输层的数据包就可以获取到ip。

为什么需要传输层? 同一台主机上有许多程序都需要用到网络,比如,你一边浏览网页,一边与朋友在线聊天。当一个数据包从互联网上发来的时候,你怎么知道,它是表示网页的内容,还是表示在线聊天的内容?

也就是说,我们还需要一个参数,表示这个数据包到底供哪个程序(进程)使用。这个参数就叫做"端口"(port)。每个数据包都发到主机的特定端口,所以不同的程序就能取到自己所需要的数据。

"端口"是0到65535之间的一个整数,正好16个二进制位。0到1023的端口被系统占用,用户只能选用大于1023的端口。不管是浏览网页还是在线聊天,应用程序会随机选用一个端口,然后与服务器的相应端口联系。

"传输层"的功能,就是建立"端口到端口"的通信。相比之下,"网络层"的功能是建立"主机到主机"的通信。只要确定主机和端口,我们就能实现程序之间的交流。socket:(ip+port) + (ip+port)

一次完整的HTTP请求过程

我们通过抓包,看一次完整的HTTP请求过程

tcpdump

首页有个知识点,移动网络如何抓包?可以使用tcpdump, tcpdump是一款强大的网络抓包工具。android root后可以执行tcpdump,并将结果保存为pcap文件。使用网信办的工具,必须要求root手机,我们也就能推测出他们也是使用tcpdump抓包,然后分析数据。mac电脑也能安装tcpdump,我们在mac运行tcpdump,看看效果。

TCP三次握手和四次挥手。

传输层还有UDP协议,我们经常说TCP面向连接的、可靠的传输协议。

三次握手,保证可靠性。防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。

三次握手后,建立了连接。双方系统内核会开辟资源,为发送数据包,解包做准备,为上层服务用。经过传输层我们的包已经送出去了吗?

SYN 这个消息是用来初始化和建立连接的 ACK 帮助对方确认收到的 SYN 消息 SYN-ACK 本地的 SYN 消息和较早的 ACK 数据包 FIN 用来断开连接

SYN:它的全称是 Synchronize Sequence Numbers,同步序列编号。是 TCP/IP 建立连接时使用的握手信号。在客户机和服务器之间建立 TCP 连接时,首先会发送的一个信号。客户端在接受到 SYN 消息时,就会在自己的段内生成一个随机值 X。 SYN-ACK:服务器收到 SYN 后,打开客户端连接,发送一个 SYN-ACK 作为答复。确认号设置为比接收到的序列号多一个,即 X + 1,服务器为数据包选择的序列号是另一个随机数 Y。 ACK:Acknowledge character, 确认字符,表示发来的数据已确认接收无误。最后,客户端将 ACK 发送给服务器。序列号被设置为所接收的确认值即 Y + 1。

使用完后,需要释放资源。就有了四次挥手。

应用层交互

但是我们怎么获取到ip对应的域名呢?

解析http包

解析https包

解析DNS包

Android DNS解析的过程

DNS解析概念

DNS的全称是domain name system,即域名系统。DNS是因特网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的去访问互联网而不用去记住能够被机器直接读取的IP地址。通过域名最终得到该域名对应的IP地址的过程则是域名解析的过程。

DNS解析过程

  1. 系统会检查浏览器缓存中有没有这个域名对应的解析过的IP地址,如果缓存中有,这个解析过程就将结束。 Android在Java层和native都有缓存,java层缓存16个,时间为2秒 native的缓存取决于TTL值

  2. 如果用户的浏览器缓存中没有,浏览器会查找操作系统缓存中即为本地的Host文件。 Android的Host文件路径(/system/etc/hosts)

  3. 如果本地Host文件中没有那么操作系统会把这个域名发送给LocalDNS(本地域名服务器)

       这个LocalDNS通常都提供给你本地互联网接入的一个DNS解析服务。这个专门的域名解析服务器性能都会很好,它们一般都会缓存域名解析结果,当然缓存时间是受域名的失效时间控制的,一般缓存空间不是影响域名失效的主要因素。大约90%的域名解析都到这里就已经完成了,所以LDNS主要承担了域名的解析工作。

  4. 如果LDNS仍然没有命中,就直接到Root Server(根域名服务器)请求解析(LDNS 去Root Server 请求)

       根域名服务器是最高层次的域名服务器,所有的根域名服务器都知道所有的顶级域名服务器的IP地址。根域名服务器有13个域名,由一群服务器组成。

根域名服务器
根域名服务器
  1. 根域名服务器返回给本地域名服务器一个所查询的域的顶级域名服务(gTLD Server)地址。

       gTLD 是国际顶级域名服务器, 如.com、.cn、.org等

  2. 本地域名服务器(Local DNS Server)再向上一步返回的gTLD服务器发送请求。

  3. 接受请求的gTLD服务器查找并返回此域名对应的权威域名服务器(又叫权限域名服务器)的地址

  4. 权威域名服务器会查询存储的域名和IP的映射关系表,正常情况下都根据域名得到目标IP记录,连同一个TTL值返回给DNS Server域名服务器。

  5. 返回该域名对应的IP和TTL值,Local DNS Server会缓存这个域名和IP的对应关系,缓存的时间由TTL值控制。

  6. 把解析的结果返回给用户,用户根据TTL值缓存在本地系统缓存中,域名解析过程结束。

DNS请求流程图

DNS请求的过程
DNS请求的过程

Android的DNS解析的过程(Android 9.0的源码)

java层

httpUrlConnection网络请求都会经过此方法,查询IP地址(发送一个https请求,在此方法打断点)。

  1. 从缓存中拿
  2. 获取不到去请求
  3. 请求后的结果放在缓存中
private static InetAddress[] lookupHostByName(String host, int netId)
          throws UnknownHostException {
      BlockGuard.getThreadPolicy().onNetwork();
      1.
      Object cachedResult = addressCache.get(host, netId);
     
      try {
          StructAddrinfo hints = new StructAddrinfo();
          hints.ai_flags = AI_ADDRCONFIG;
          hints.ai_family = AF_UNSPEC;
          hints.ai_socktype = SOCK_STREAM;
          2.
          InetAddress[] addresses = Libcore.os.android_getaddrinfo(host, hints, netId);
          for (InetAddress address : addresses) {
              address.holder().hostName = host;
              address.holder().originalHostName = host;
          }
          3.
          addressCache.put(host, netId, addresses);
          return addresses;
      } 
     
}

缓存结果

addressCache.put(host, netId, addresses);将请求的结果放到缓存中

class AddressCache {
     
    private static final int MAX_ENTRIES = 16;

    // The TTL for the Java-level cache is short, just 2s.
    private static final long TTL_NANOS = 2 * 1000000000L;

    private final BasicLruCache<AddressCacheKey, AddressCacheEntry> cache
            = new BasicLruCache<AddressCacheKey, AddressCacheEntry>(MAX_ENTRIES);

    static class AddressCacheEntry {
        final Object value;
        final long expiryNanos;
        AddressCacheEntry(Object value) {
            this.value = value;
            this.expiryNanos = System.nanoTime() + TTL_NANOS;
        }
    }

    public void put(String hostname, int netId, InetAddress[] addresses) {
        cache.put(new AddressCacheKey(hostname, netId), new AddressCacheEntry(addresses));
    }
    
}
  1. java层的TTL值是一个固定的2s,那网络DNS请求包中的TTL值存在那里了?
  2. 是不是可以修改Java层的值TTL_NANOS 和 MAX_ENTRIES ,缓存ip的值,达到加快网络速度

发起DNS请求

Libcore.os.android_getaddrinfo()

public final class Libcore {
    private Libcore() { }
    public static Os rawOs = new Linux();
    public static Os os = new BlockGuardOs(rawOs);
}

BlockGuardOs.java 文件
public InetAddress[] android_getaddrinfo(String node, StructAddrinfo hints, int netId) throws GaiException {
       return os.android_getaddrinfo(node, hints, netId);
}
public final class Linux implements Os {
    Linux() { }
  、、、
    public native InetAddress[] android_getaddrinfo(String node, StructAddrinfo hints, int netId) throws GaiException;
  、、、
    }

所以最终调用了native的方法

Java层流程图

java层流程图
java层流程图

JNI 层

关注 /libcore/luni/src/main/native/libcore_io_Linux.cpp 文件

Native层

Native层(客户端进程)

Libcore.os.android_getaddrinfo() 最终调用到getaddrinfo.c文件中的android_getaddrinfo_proxy方法(详细流程和调用链,关注流程图)

static int
android_getaddrinfo_proxy(
    const char *hostname, const char *servname,
    const struct addrinfo *hints, struct addrinfo **res, unsigned netid)

{
 FILE* proxy = android_open_proxy();
 // Send the request.
 if (fprintf(proxy, "getaddrinfo %s %s %d %d %d %d %u",
      hostname == NULL ? "^" : hostname,
      servname == NULL ? "^" : servname,
      hints == NULL ? -1 : hints->ai_flags,
      hints == NULL ? -1 : hints->ai_family,
      hints == NULL ? -1 : hints->ai_socktype,
      hints == NULL ? -1 : hints->ai_protocol,
      netid) < 0) {
  goto exit;
 }
}
1.android_open_proxy()
__LIBC_HIDDEN__ FILE* android_open_proxy() {
    、、、
   int s = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);  
   const int one = 1;
   setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    
   struct sockaddr_un proxy_addr;
   memset(&proxy_addr, 0, sizeof(proxy_addr));
   proxy_addr.sun_family = AF_UNIX;
   strlcpy(proxy_addr.sun_path, "/dev/socket/dnsproxyd", sizeof(proxy_addr.sun_path));
  
   if (TEMP_FAILURE_RETRY(connect(s, (const struct sockaddr*) &proxy_addr, sizeof(proxy_addr))) != 0) {
    close(s);
    return NULL;
   }
   return fdopen(s, "r+");
}
  1. 创建socket套接字并初始化
  2. 将netd进程的socket拷贝到 proxy_addr.sun_path(strlcpy() 字符串的拷贝(比Strcpy多了一个‘\0’))
  3. 建立连接 这里就涉及到socket网络编程了,就不去探索了,感兴趣的可以关注connet,accept 等函数
socket基础知识
socket基础知识
2.fprintf(proxy, "getaddrinfo")

Linux下一切皆文件,将命令“getaddrinfo” 写入文件中,这样就与net进程进行通信;

Native层流程图

Native层流程图
Native层流程图

netd进程

先储备一点点netd进程的知识

init进程解析init.rc文件,创建zygote进程,netd进程,serviceManager服务,surfaceFlinger等一系类服务和进程(通过adb shell ps 命令可以查看进程名称和id);

DNS的解析是通过Netd代理的方式进行的。Netd是Network Daemon的缩写,Netd在Android中负责物理端口的网络操作相关的实现,如Bandwidth,NAT,PPP,soft-ap等。Netd为Framework隔离了底层网络接口的差异,提供了统一的调用接口,简化了整个网络逻辑的使用。简单来说就是Android将监听/dev/socket/dnsproxyd,如果系统需要DNS解析服务,那么就需要打开dnsproxyd,然后安装一定的格式写入命令,然后监听等待目标回答(CDNS 对netd进程比较好的解释)。

这里省略netd进程创建和初始化的过程,假设我们现在都了解这些知识,并且已经走到该函数中。 这里是netd进程,已经跨进程;

void DnsProxyListener::GetAddrInfoHandler::run() {
    、、、
    if (queryLimiter.start(uid)) {
        rv = android_getaddrinfofornetcontext(mHost, mService, mHints, &mNetContext, &result);
        queryLimiter.finish(uid);
    } 
    、、、
}
static int explore_fqdn(const struct addrinfo *pai, const char *hostname,
    const char *servname, struct addrinfo **res,
    const struct android_net_context *netcontext)

{
 static const ns_dtab dtab[] = {
  NS_FILES_CB(_files_getaddrinfo, NULL)
  { NSSRC_DNS, _dns_getaddrinfo, NULL }, /* force -DHESIOD */
  NS_NIS_CB(_yp_getaddrinfo, NULL)
  { 000 }
 };
  、、、
  nsdispatch(&result, dtab, NSDB_HOSTS, "getaddrinfo",
   default_dns_files, hostname, pai, netcontext)
 
 }
}
static void _sethtent(FILE **hostf)
{
 if (!*hostf)
  *hostf = fopen(_PATH_HOSTS, "re");
 else
  rewind(*hostf);
}
#define _PATH_HOSTS "/system/etc/hosts"
  1. 从文件/system/etc/hosts获取
hosts文件
hosts文件
  1. 从dns服务器获取

netd进程到底是怎么向DNS服务发起请求的,在res_send.c文件send_dg()真正发起网络请求的地方,有没有一种熟悉的感觉,创建套接字,建立连接,发送请求,等待结果。(详细流程和调用链,关注流程图)

static int
send_dg(res_state statp,
 const u_char *buf, int buflen, u_char *ans, int anssiz,
 int *terrno, int ns, int *v_circuit, int *gotsomewhere,
 time_t *at, int *rcode, int* delay)

{

 if (EXT(statp).nssocks[ns] == -1) {
  EXT(statp).nssocks[ns] = socket(nsap->sa_family, SOCK_DGRAM | SOCK_CLOEXEC, 0);

  if (random_bind(EXT(statp).nssocks[ns], nsap->sa_family) < 0) {
   Aerror(statp, stderr"bind(dg)", errno, nsap,
       nsaplen);
   res_nclose(statp);
   return (0);
  }
  if (__connect(EXT(statp).nssocks[ns], nsap, (socklen_t)nsaplen) < 0) {
   Aerror(statp, stderr"connect(dg)", errno, nsap,
       nsaplen);
   res_nclose(statp);
   return (0);
  }
 if (sendto(s, (const char*)buf, buflen, 0, nsap, nsaplen) != buflen)
 {
  Aerror(statp, stderr"sendto", errno, nsap, nsaplen);
  res_nclose(statp);
  return (0);
 }
 /*
  * Wait for reply.
  */

 seconds = get_timeout(statp, ns);
 now = evNowTime();
 timeout = evConsTime((long)seconds, 0L);
 finish = evAddTime(now, timeout);
 n = retrying_select(s, &dsmask, NULL, &finish);
  }
}
  1. java层的TTL值是一个固定的2s,那网络DNS请求包中的TTL值存在那里了? 当发送DNS请求,请求回来以后,将请求结果保存
void _resolv_cache_add(const void* answer)//这里省略部分参数
{
    //从响应报文中获取本次查询结果中指定的查询结果的有效期
    ttl = answer_getTTL(answer, answerlen);
    if (ttl > 0) {
     //ttl大于0,表示该地址可以保留一段时间,那么创建一个新的cache项,
        //然后设定其有效期,并将其加入到cache中
        e = entry_alloc(key, answer, answerlen);
        if (e != NULL) {
            e->expires = ttl + _time_now();
            _cache_add_p(cache, lookup, e);
        }
    }
}

在res_nsend()真正向DNS服务器发起DNS查询请求之前,会首先向自己的cache查询,如果cache可以命中,那么直接返回,否则才继续向DNS服务器查询。该查询过程是通过_resolv_cache_lookup()完成的。

ResolvCacheStatus _resolv_cache_lookup(unsigned netid,const void* query,int querylen,void*  answer,intanswersize,int   *answerlen )
{
  、、、
    lookup = _cache_lookup_p(cache, key);
    e      = *lookup;
    now = _time_now();
    //查询结果无效,返回没有查询到结果,向DNS服务器发起查询请求
    if (now >= e->expires) {
        XLOG( " NOT IN CACHE (STALE ENTRY %p DISCARDED)", *lookup );
        XLOG_QUERY(e->query, e->querylen);
        _cache_remove_p(cache, lookup);
        goto Exit;
    }
    //ok,到这里说明cache中的结果没问题
    memcpy( answer, e->answer, e->answerlen );
    //返回查询成功
    XLOG( "FOUND IN CACHE entry=%p", e );
    result = RESOLV_CACHE_FOUND;
    、、、
    return result;
}

Android系统中通过这种方式来管理DNS的好处是,所有解析后得到的 DNS 记录都将缓存在 Netd 进程中,从而使这些信息成为一个公共的资源,最大程度做到信息共享。

Netd进程流程图

Netd进程流程图
Netd进程流程图

流程图

概括流程图
概括流程图

最后

Android DNS请求解析,缓存机制不仅仅是这么简单,这里只是大概的流程,路漫漫其修远兮!!

羽毛

2021/12/23  阅读:126  主题:默认主题

作者介绍

羽毛