JasonWang's Blog

由Policy Routing引发的一个奇怪问题

字数统计: 4.1k阅读时长: 18 min
2020/06/03

最近新的项目又开始了, 开始还算顺利, 却不料碰到了一个奇怪的问题. 先来了解下问题的背景. 这个项目里, Android中有两个以太网网口, 一个用于内网通讯, 不具备上外网的能力;一个用于外网通讯, 使用该网口可以访问互联网. 在网络管理模块的工作完成后, 提交了代码我原本以为可以高枕无忧, 前两天组内的同学跑过来告诉我, 他有个系统服务一直没法通过与内网的其他设备上的服务建立TCP链接, 但是网络却一直可以ping通; 而另外的一个开发板上却不存在这个问题.

开始我有点不相信竟然会有这样的问题, 但事实摆在面前, 我也不好抵赖, 于是自己找来一个板子, 看了下, 才逐渐找到答案. 问题的根源在于Android配置的策略路由规则隐含了一个针对系统默认网络的fwmark规则, 要解决问题, 只要我们将包含了内网路由表的路由规则的优先级提升到高于Android隐含的这条规则即可. 虽然找到了解决方案, 但是还是决定花点时间把整个事情的来龙去脉都理清楚.

大致分如下几个部分来讲一讲这个问题:

  • 介绍下什么是Policy Routing<策略路由>
  • 分析具体的问题, 并给出方案
  • 从源代码角度来分析下, 为何TCP无法建立, 但ping却可以

什么是Policy Routing

我们都知道, 传统的Linux路由都是基于目标IP地址来进行路由设置, 策略路由不同的是, 在原有路由表的基础上, 添加一系列具有优先级的规则, 这些规则可以根据数据包的入口<本地或者lo>, 出口, TOS<Type Of Service>, fwmark标签值, 协议以及端口号等来进行路由表的选择, 所有这些策略规则都放在一个称为routing policy database的数据库中. 一般, 一条策略路由规则都由selector(选择器)以及action predicate(需要执行的动作)两部分组成; 通过ip rule指令, 我们可以修改/删除系统中的策略路由规则. 例如, 在Ubuntu系统中, 输入ip rule list, 大致是这样的:

1
2
3
4
5

0: from all lookup local
32766: from all lookup main
32767: from all lookup default

这些策略路由规则都是内核初始化时默认生成的, 按照规则的优先级大小排列, 数字越小, 优先级越高:

  • 优先级0: 会匹配任何数据包, 执行的动作是在local路由表<ID 255>查找路由
  • 优先级32766: 匹配任何数据包, 执行的动作是在main路由表<ID 254>中查找路由
  • 优先级32767: 匹配任何数据包, 执行的动作是在default路由表<ID 253>中查找路由

通过man ip rule我们可以查看到更多关于RP规则的信息. 而对于Android来说, 由于需要同时管理多个网络, 并根据网络权限/用户UID等来设置防火墙, 策略路由的规则就复杂了很多, 例如在我的开发板上输入adb shell ip rule list可以看到这么一大串的规则列表:

Android Ip Rule list examples

这里, eth0就是用来作内网通讯用的网口, 而usb0是用来连接外网的网口, 当前系统默认的默认网络<具有默认路由>即usb0. Android的Netd(负责网络管理的native进程, 可以参考早前的文章了解更多信息Android Netd详解)会把每个正常工作的网口都建立一个相应的路由表, 路由表的ID就是对应网络的netID<每个网络在创建后都会分配一个唯一的ID>, 例如通过输入ip route show table usb0查看路由表usb0实际是这样的:

1
2
3
4

default via 192.168.225.1 dev usb0 proto static
192.168.225.0/24 dev usb0 proto static scope link

这张路由表包含了两个路由规则: 前一个是默认路由, 用于匹配外网数据(非192.168.225.*IP段的都会匹配该路由)的路由;后一个是用于该网卡局域网内IP地址的路由.上图中我们看到的这个RPDB实际是能正常工作的, 就是说通过eth0可以建立TCP连接, ping网络也正常, 后面在分析问题时会再贴出有问题的RPDB.

网络ping通但无法建立TCP的问题

出问题时的路由表eth0只有一个路由规则:

1
2
3

172.20.1.0/24 dev eth0 proto static scope link

相应的RPDB大致如下, 这里要说明的是21300: from all lookup main这个查找main路由表的规则是需要自己添加的, Android原生代码已经把main表的查找规则剔除了, 对于同时有对个网卡共存的情况, main表是必须的, 否则基于eth0网口的局域网就无法正常ping通.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

0: from all lookup local
10000: from all fwmark 0xc0000/0xd0000 lookup legacy_system
10500: from all iif lo oif dummy0 uidrange 0-0 lookup dummy0
10500: from all iif lo oif eth0 uidrange 0-0 lookup eth0
10500: from all iif lo oif usb0 uidrange 0-0 lookup usb0
13000: from all fwmark 0x10063/0x1ffff iif lo lookup local_network
13000: from all fwmark 0x10064/0x1ffff iif lo lookup eth0
13000: from all fwmark 0x10065/0x1ffff iif lo lookup usb0
14000: from all iif lo oif dummy0 lookup dummy0
14000: from all iif lo oif eth0 lookup eth0
14000: from all iif lo oif usb0 lookup usb0
15000: from all fwmark 0x0/0x10000 lookup legacy_system
16000: from all fwmark 0x0/0x10000 lookup legacy_network
17000: from all fwmark 0x0/0x10000 lookup local_network
19000: from all fwmark 0x64/0x1ffff iif lo lookup eth0
19000: from all fwmark 0x65/0x1ffff iif lo lookup usb0
21300: from all lookup main
22000: from all fwmark 0x0/0xffff iif lo lookup usb0
32000: from all unreachable

基于上述RPDB规则, 尝试测试内网某个设备的连通性: ping 172.20.1.55有看到回应, 但是如果通过ssh指令ssh -vvv root@172.20.1.55尝试登录到对端, 就会提示No Route to Host, 其他上层TCP连接也没法正常建立成功. 细心的同学可能已经发现, 在之前讲到的那个正常RPDB与这里的异常的RPDB唯一的区别就是在与main路由表查找规则的优先级, 一个是21300, 一个18300, 那么为什么优先级的差异会导致不一样的结果?

对于一般的TCP连接, 并不会指定连接的网口(通过setsocktoptSO_BINDTODEVICE选项指定), 所以可以判定那些指定了oif(数据包出口)的ip rule规则应该不会导致问题的发生, 这里我们可以通过ip rule add pref <pref_no> lookup main调整这个规则的优先级, 通过二分查找测试几次就知道了. 在另外一方面, 测试的同学反馈, 如果没有接usb0这个网络设备, 问题就不会存在. 这样这个问题就更清晰了: 跟usb0路由表相关的几条规则是问题的关键. 排除掉指定了oif相关的规则, 只剩下两条:

1
2
3
4

13000: from all fwmark 0x10065/0x1ffff iif lo lookup usb0
19000: from all fwmark 0x65/0x1ffff iif lo lookup usb0

删除掉原有的规则ip rule del pref 21300 lookup main, 然后添加一个优先级高于第一条13000的规则ip rule add pref 12800 lookup main, 试验下发现TCP可以正常建立连接, 问题不存在;再次实验, 先删除main路由表对应的规则, 添加一条ip rule add pref 18300 lookup main的规则, 也可以正常建立TCP连接. 于是, 我们可以断定, 优先级为19000这条规则是罪魁祸首.解决问题的方案就是把原来的main查找的优先级高于19000即可.

1
2
3

ip rule add pref 18300 lookup main

问题是解决了, 可以为什么会这样了? 这条包含了fwmark的规则为何会让TCP连接没法正常建立而ping又可以了? 还是要read the fucking source code才能找到根本原因了.

看看该死的源代码

对于Android来说, 无论是Java的网络请求, 还是native的最终都会通过libc的封装的系统调用来完成. 因此, 第一步就来看看libc中对常用socket API的实现逻辑. 对应的源码位于/bionic/libc. 我们知道, 对于TCP客户端来说, 一般先调用socket创建套接字获取到文件描述符后, 会直接调用connect尝试连接到服务端, 由于创建socket不涉及到路由, 因此就来看看connect的具体调用逻辑. 找到connect.cpp, 代码很简单, 只有一行:

1
2
3
4
5

int connect(int sockfd, const sockaddr* addr, socklen_t addrlen) {
return __netdClientDispatch.connect(sockfd, addr, addrlen);
}

该函数直接是调用了__netdClientDispatch对应的实现, 从函数名字来看, 实际应该是把请求转发给Netd, 不妨接续看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

// private/NetdClientDispatch.h
struct NetdClientDispatch {
int (*accept4)(int, struct sockaddr*, socklen_t*, int);
int (*connect)(int, const struct sockaddr*, socklen_t);
int (*socket)(int, int, int);
unsigned (*netIdForResolv)(unsigned);
};


// NetdClientDispatch.cpp
#ifdef __i386__
#define __socketcall __attribute__((__cdecl__))
#else
#define __socketcall
#endif

extern "C" __socketcall int __accept4(int, sockaddr*, socklen_t*, int);
extern "C" __socketcall int __connect(int, const sockaddr*, socklen_t);
extern "C" __socketcall int __socket(int, int, int);

static unsigned fallBackNetIdForResolv(unsigned netId) {
return netId;
}

// This structure is modified only at startup (when libc.so is loaded) and never
// afterwards, so it's okay that it's read later at runtime without a lock.
__LIBC_HIDDEN__ NetdClientDispatch __netdClientDispatch __attribute__((aligned(32))) = {
__accept4,
__connect,
__socket,
fallBackNetIdForResolv,
};


__netdClientDispatch实际是封装了几个外部函数而已, 那么__socket/__connect/__accept4这几个函数又在哪里实现的了? 搜索下libc下面的代码, 发现原来在libc初始化的时候, 会加载一个libnetd_client.so的库, 然后把相应的实现加载过来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

// NetdClient.cpp
template <typename FunctionType>
static void netdClientInitFunction(void* handle, const char* symbol, FunctionType* function) {
typedef void (*InitFunctionType)(FunctionType*);
InitFunctionType initFunction = reinterpret_cast<InitFunctionType>(dlsym(handle, symbol));
if (initFunction != NULL) {
initFunction(function);
}
}

static void netdClientInitImpl() {
void* netdClientHandle = dlopen("libnetd_client.so", RTLD_NOW);
if (netdClientHandle == NULL) {
// If the library is not available, it's not an error. We'll just use
// default implementations of functions that it would've overridden.
return;
}
netdClientInitFunction(netdClientHandle, "netdClientInitAccept4",
&__netdClientDispatch.accept4);
netdClientInitFunction(netdClientHandle, "netdClientInitConnect",
&__netdClientDispatch.connect);
netdClientInitFunction(netdClientHandle, "netdClientInitNetIdForResolv",
&__netdClientDispatch.netIdForResolv);
netdClientInitFunction(netdClientHandle, "netdClientInitSocket", &__netdClientDispatch.socket);
}

static pthread_once_t netdClientInitOnce = PTHREAD_ONCE_INIT;

extern "C" __LIBC_HIDDEN__ void netdClientInit() {
if (pthread_once(&netdClientInitOnce, netdClientInitImpl)) {
async_safe_format_log(ANDROID_LOG_ERROR, "netdClient", "Failed to initialize netd_client");
}
}

有兴趣的可以看看libc具体的初始化流程. 这里, 我们直接跳到libnetd_client.so这个库去看看netdClientInitConnect的实现. 共享库libnetd_client.so的代码位于/system/netd/client目录, 其中有个文件NetdClient.cpp即实现了该函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

// NetdClient.cpp
int netdClientConnect(int sockfd, const sockaddr* addr, socklen_t addrlen) {
const bool shouldSetFwmark = (sockfd >= 0) && addr
&& FwmarkClient::shouldSetFwmark(addr->sa_family);
if (shouldSetFwmark) {
FwmarkCommand command = {FwmarkCommand::ON_CONNECT, 0, 0, 0};
if (int error = FwmarkClient().send(&command, sockfd, nullptr)) {
errno = -error;
return -1;
}
}
// Latency measurement does not include time of sending commands to Fwmark
Stopwatch s;
const int ret = libcConnect(sockfd, addr, addrlen);
// Save errno so it isn't clobbered by sending ON_CONNECT_COMPLETE
const int connectErrno = errno;
const unsigned latencyMs = lround(s.timeTaken());
// Send an ON_CONNECT_COMPLETE command that includes sockaddr and connect latency for reporting
if (shouldSetFwmark && FwmarkClient::shouldReportConnectComplete(addr->sa_family)) {
FwmarkConnectInfo connectInfo(ret == 0 ? 0 : connectErrno, latencyMs, addr);
// TODO: get the netId from the socket mark once we have continuous benchmark runs
FwmarkCommand command = {FwmarkCommand::ON_CONNECT_COMPLETE, /* netId (ignored) */ 0,
/* uid (filled in by the server) */ 0, 0};
// Ignore return value since it's only used for logging
FwmarkClient().send(&command, sockfd, &connectInfo);
}
errno = connectErrno;
return ret;
}

看到这里的Fwmark等字样, 似乎有点眼熟了, 这个函数的逻辑是, 首先要判断一个socket链接是否要打上防火墙标签(Firewall Mark)shouldSetFwmark, 实际上对于TCP的socket来说, 该函数都返回True, 接着会将对应的socketFd通过一个本地fwmarkd这个socket发送给FwmarkServer, 由其负责将socket打上防火墙标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83

int FwmarkServer::processClient(SocketClient* client, int* socketFd) {
FwmarkCommand command;
FwmarkConnectInfo connectInfo;

iovec iov[2] = {
{ &command, sizeof(command) },
{ &connectInfo, sizeof(connectInfo) },
};
msghdr message;
memset(&message, 0, sizeof(message));
message.msg_iov = iov;
message.msg_iovlen = ARRAY_SIZE(iov);

union {
cmsghdr cmh;
char cmsg[CMSG_SPACE(sizeof(*socketFd))];
} cmsgu;

memset(cmsgu.cmsg, 0, sizeof(cmsgu.cmsg));
message.msg_control = cmsgu.cmsg;
message.msg_controllen = sizeof(cmsgu.cmsg);

int messageLength = TEMP_FAILURE_RETRY(recvmsg(client->getSocket(), &message, MSG_CMSG_CLOEXEC));
if (messageLength <= 0) {
return -errno;
}

if (!((command.cmdId != FwmarkCommand::ON_CONNECT_COMPLETE && messageLength == sizeof(command))
|| (command.cmdId == FwmarkCommand::ON_CONNECT_COMPLETE
&& messageLength == sizeof(command) + sizeof(connectInfo)))) {
return -EBADMSG;
}

// 检查当前用户是否有网络访问权限
Permission permission = mNetworkController->getPermissionForUser(client->getUid());
...
Fwmark fwmark;
socklen_t fwmarkLen = sizeof(fwmark.intValue);
// 获取当前socket上的fwmark
if (getsockopt(*socketFd, SOL_SOCKET, SO_MARK, &fwmark.intValue, &fwmarkLen) == -1) {
return -errno;
}

switch (command.cmdId) {
case FwmarkCommand::ON_ACCEPT: {
// Called after a socket accept(). The kernel would've marked the NetId and necessary
// permissions bits, so we just add the rest of the user's permissions here.
permission = static_cast<Permission>(permission | fwmark.permission);
break;
}

case FwmarkCommand::ON_CONNECT: {
// Called before a socket connect() happens. Set an appropriate NetId into the fwmark so
// that the socket routes consistently over that network. Do this even if the socket
// already has a NetId, so that calling connect() multiple times still works.
//
// But if the explicit bit was set, the existing NetId was explicitly preferred (and not
// a case of connect() being called multiple times). Don't reset the NetId in that case.
....
// 这里explicitlySelected为false, 因此实际会选择默认网络的netId
if (!fwmark.explicitlySelected) {
if (!fwmark.protectedFromVpn) {
fwmark.netId = mNetworkController->getNetworkForConnect(client->getUid());
} else if (!mNetworkController->isVirtualNetwork(fwmark.netId)) {
fwmark.netId = mNetworkController->getDefaultNetwork();
}
}
break;
}
....

fwmark.permission = permission;
// 将该socket打上防火墙的标签, 这个实际就是用来给内核选择路由时用的
if (setsockopt(*socketFd, SOL_SOCKET, SO_MARK, &fwmark.intValue,
sizeof(fwmark.intValue)) == -1) {
return -errno;
}

return 0;
}


到这一步, 我们大概知道, fwmark实际是一个32位的整型数值, 其中网络的netId占了低16位, 网络权限permission占了2位, 这样系统所有的TCP连接都会被打上fwmark. 那么, 内核的RPDB规则又何时被添加过去的了? 我们再来看看Netd的代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14

union Fwmark {
uint32_t intValue;
struct {
unsigned netId : 16;
bool explicitlySelected : 1;
bool protectedFromVpn : 1;
Permission permission : 2;
bool uidBillingDone : 1;
};
constexpr Fwmark() : intValue(0) {}
};


Android对网络路由相关的管理与控制逻辑都放在/system/netd/server/RouteController.cpp中, 找到对应开始引起问题的那个RP规则, 其优先级为19000, 这正好是RULE_PRIORITY_IMPLICIT_NETWORK这个值.

1
2
19000:	from all fwmark 0x65/0x1ffff iif lo lookup usb0 

搜索这个关键字, 可以看到Netd会在创建无需任何权限的PhysicalNetwork对象时, 会根据网络的netId时生成一条隐性的策略路由规则modifyImplicitNetworkRule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

WARN_UNUSED_RESULT int RouteController::modifyPhysicalNetwork(unsigned netId, const char* interface,
Permission permission, bool add) {
//if network id has register interface, other interface route add to the table with interface registered by netid
....
if (int ret = modifyIncomingPacketMark(netId, interface, permission, add)) {
return ret;
}
if (int ret = modifyExplicitNetworkRule(netId, table, permission, INVALID_UID, INVALID_UID,
add)) {
return ret;
}
if (int ret = modifyOutputInterfaceRules(interface, table, permission, INVALID_UID, INVALID_UID,
add)) {
return ret;
}

// Only set implicit rules for networks that don't require permissions.
//
// This is so that if the default network ceases to be the default network and then switches
// from requiring no permissions to requiring permissions, we ensure that apps only use the
// network if they explicitly select it. This is consistent with destroySocketsLackingPermission
...
if (permission == PERMISSION_NONE) {
return modifyImplicitNetworkRule(netId, table, add);
}
return 0;
}


这个函数会设置一个值为默认网络netIdfwmark, 也就是我们最开始看到的那条优先级为19000的规则, 并通过类型为NETLINK_ROUTE的netlink向内核配置该规则, 内核就会根据这条规则来匹配上对应的TCP包, 因而就会出现我们最开始的那个问题:使用TCP连接会提示No Route to Host, 那为何ping不存在这个问题了?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

WARN_UNUSED_RESULT int modifyImplicitNetworkRule(unsigned netId, uint32_t table, bool add) {
Fwmark fwmark;
Fwmark mask;

fwmark.netId = netId;
mask.netId = FWMARK_NET_ID_MASK;

fwmark.explicitlySelected = false;
mask.explicitlySelected = true;

fwmark.permission = PERMISSION_NONE;
mask.permission = PERMISSION_NONE;

return modifyIpRule(add ? RTM_NEWRULE : RTM_DELRULE, RULE_PRIORITY_IMPLICIT_NETWORK, table,
fwmark.intValue, mask.intValue, IIF_LOOPBACK, OIF_NONE, INVALID_UID,
INVALID_UID);
}

我们都知道ping一般是基于IPPROTO_ICMP协议, 实际发送ping的ECHO_REQUEST时, 只需要创建一个socket接口, 然后直接通过sendto发送对应的数据报文就好了;从刚开始的代码知道, libc会把socket相关的请求转发给Netd, 我们直接看Netd中NetdClient.cpp的建立socket相关的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

std::atomic_uint netIdForProcess(NETID_UNSET);

int netdClientSocket(int domain, int type, int protocol) {
int socketFd = libcSocket(domain, type, protocol);
if (socketFd == -1) {
return -1;
}
unsigned netId = netIdForProcess;
if (netId != NETID_UNSET && FwmarkClient::shouldSetFwmark(domain)) {
if (int error = setNetworkForSocket(netId, socketFd)) {
return closeFdAndSetErrno(socketFd, error);
}
}
return socketFd;
}

这个函数首先会调用libc创建socket, ping的时候并没有调用setNetworkForProcess指定网络netId, 因此实际路由时会跳过19000这条规则, 使用的是后面21300这个main路由规则来进行路由选择. 至此问题的谜团也算揭开了.

参考资料

原文作者:Jason Wang

更新日期:2021-08-11, 14:57:50

版权声明:本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可

CATALOG
  1. 1. 什么是Policy Routing
  2. 2. 网络ping通但无法建立TCP的问题
  3. 3. 看看该死的源代码
  4. 4. 参考资料