利用 Linux netns 实现全局代理

149 天前
 beyondstars

动机

在 Linux 命令行操作环境下,有些命令行工具不支持 HTTP(S)_PROXY 等环境变量,导致使用起来不方便,因此本文介绍一种利用 Linux 网络命名空间和策略路由实现为特定命令指定默认路由的方式,这种方法可以使给定的命令执行过程中产生的流量流经人为指定的网卡,而不需要设置任何环境变量或者修改软件本身的配置。

因为配置这样的虚拟网络环境的过程非常繁杂,本文把其中涉及的命令和过程都记录下来,方便后续参阅。

概述

Linux namespace (简称 netns )是 Linux 操作系统内核提供的一种命名空间隔离机制,它最为人知的应用是配合 cgroups 实现轻量级虚拟化,例如 Docker 容器的实现。

那么从 namespace 和 cgroups 的角度来看,一个虚拟机无非就是命名空间的隔离加上资源用量的限制,我们创建一个 netns ,然后在这个 netns 中执行特定命令,就好比是在一个与当前主机网络命名空间截然不同的另一个「虚拟机」中执行这条命令。

Docker ,以及用 calico-node 作为 network cni-plugin 的 Kubernetes 集群,都广泛地应用 netns 来实现其底层的网络虚拟化,这些网络虚拟化具体包括为每个容器创建对应的虚拟网络,隔离容器与容器之间的网络环境等。

同样是实现全局代理,我们本可以创建一个传统意义上的虚拟机(例如基于 KVM ,VMWare ,ESXI 这类 hypervisor 的) ,然后在宿主机中配置策略路由是的从虚拟机中出来的流量走特定的(虚拟或物理)网卡,但是这种方式最大的一个问题就是它太笨重了,虚拟机需要消耗相当可观的系统资源。和虚拟机相比,netns 抽象出了虚拟化的本质:命名空间的隔离,它只做必要的事,因此是非常轻量的。

过程

整个过程分为两个部分,第一是分配 netns 和当前 netns 的 IP 地址并配置路由使得它们 3 层互通;第二是要配置 DNS 和 NAT ,使得 netns 内部能和外部互联网通信;第三部是应用策略路由技术,使得来自 netns 内部的流量,经指定的网卡转发出去。

打通三层网络

我们需要一台 Linux 主机作为实验环境( VPS 或者物理机皆可),它应当支持 network namespaces 的创建,安装有 iproute2 命令行套件( ip 命令所属的软件包),支持通过 iptablesip6tables 配置封包处理策略。你还需要 root 权限执行下列命令。

接下来我们创建一个 netns:

sudo ip netns add n1

然后创建一对 veth 网卡,你把 netns 看作是一个虚拟机,那么创建这对 veth 的过程就好像是拿一根网线直连两台电脑,当然在软件环境中不存在网卡接口数的限制:

sudo ip link add veth1 netns n1 type veth peer n1-veth1

这样,就通过位于 ns1 网络命名空间 veth1 和位于当前命名空间的 n1-veth1 这对 veth 虚拟网卡连接起了两个逻辑上本来互相隔离的虚拟网络环境。

启用各个网卡,避免后续难以调试的麻烦:

sudo ip -n n1 link set veth1 up
sudo ip -n n1 link set lo up
sudo ip link set n1-veth1 up

配置 IP 地址:

sudo ip -n n1 addr add fd03::1/64 dev veth1
sudo ip -n n1 addr add 10.3.0.101/24 dev veth1

启用当前命名空间 IP 封包转发:

sudo sysctl -w net.ipv6.conf.all.forwarding=1
sudo sysctl -w net.ipv4.ip_forward=1

配置路由:

sudo ip -n n1 route add 169.254.1.1/32 dev veth1 scope link
sudo ip -n n1 route add default via 169.254.1.1
sudo ip route add 10.3.0.101/32 dev n1-veth1 scope link

从 netns 里面 ping 一个当前 netns 的 IP 地址试试,看两个 netns 是否已经 IP 互通:

$ sudo ip netns exec n1 ping 192.168.66.60
PING 192.168.66.60 (192.168.66.60) 56(84) bytes of data.
64 bytes from 192.168.66.60: icmp_seq=1 ttl=64 time=0.036 ms
64 bytes from 192.168.66.60: icmp_seq=2 ttl=64 time=0.040 ms
^C
--- 192.168.66.60 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 0.036/0.038/0.040/0.002 ms

再从外面 ping 里面:

$ ping 10.3.0.101
PING 10.3.0.101 (10.3.0.101) 56(84) bytes of data.
64 bytes from 10.3.0.101: icmp_seq=1 ttl=64 time=0.043 ms
64 bytes from 10.3.0.101: icmp_seq=2 ttl=64 time=0.046 ms
^C
--- 10.3.0.101 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1031ms
rtt min/avg/max/mdev = 0.043/0.044/0.046/0.001 ms

补充,配置 IPv6 路由:

# 查看 veth 对端 IPv6 地址
sudo ip -6 addr show n1-veth1
22323: n1-veth1@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link-netns n1
    inet6 fe80::58e8:b5ff:fef3:886f/64 scope link
       valid_lft forever preferred_lft forever

# 设置 netns 虚拟网络环境的默认 IPv6 路由
sudo ip -6 -n n1 route add default via fe80::58e8:b5ff:fef3:886f dev veth1

# 查看 netns 内的 IPv6 地址:
sudo ip -6 -n n1 addr show veth1
2: veth1@if22323: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link-netnsid 0
    inet6 fd03::1/64 scope global
       valid_lft forever preferred_lft forever
    inet6 fe80::903e:a8ff:fe9b:cd5f/64 scope link
       valid_lft forever preferred_lft forever

# 设置回程路由
sudo ip -6 route add fd03::/64 via fe80::903e:a8ff:fe9b:cd5f dev n1-veth1

从里面 ping 外面:

$ sudo ip netns exec n1 ping -6 240e:xxx
PING 240e:xxx(240e:xxx) 56 data bytes
64 bytes from 240e:xxx: icmp_seq=1 ttl=64 time=0.045 ms
64 bytes from 240e:xxx: icmp_seq=2 ttl=64 time=0.071 ms
^C
--- 240e:xxx ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1010ms
rtt min/avg/max/mdev = 0.045/0.058/0.071/0.013 ms

再从外面 ping 里面:

$ ping -6 fd03::1
PING fd03::1(fd03::1) 56 data bytes
64 bytes from fd03::1: icmp_seq=1 ttl=64 time=0.095 ms
64 bytes from fd03::1: icmp_seq=2 ttl=64 time=0.096 ms
^C
--- fd03::1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1022ms
rtt min/avg/max/mdev = 0.095/0.095/0.096/0.000 ms

至此,netns n1 和当前 netns 已经打通了 3 层网络!

连通 netns 和外部网络

通过以上配置,我们仅仅是实现了 netns n1 到当前 netns 的互通,相当于组了一个只有两个主机的迷你局域网,现在我们要让 netns n1 也能主动 ping 通广域网。

从路由和 IP 封包转发的角度讲,系统启用了 IP 封包转发,netns n1 发往 WAN 的封包到达本机后,会走默认路由去到 WAN ,所以,我们只需要再对来自 netns n1 的出站流量做动态 snat ,使得它能收到回包即可:

# 配置 IPv4 的出站 SNAT
sudo iptables -t nat -I POSTROUTING 1 -s 10.3.0.0/24 -j MASQUERADE

# 配置 IPv6 的出站 SNAT
sudo ip6tables -t nat -I POSTROUTING 1 -s fd03::/64 -j MASQUERADE

我们不用管出站封包的源地址会被 NAT 成哪个网卡的地址:它被路由到用哪个网卡发送,就是用哪个网卡的 IP 地址。也不用管传输层端口会被修改为哪一个:它是随机选的。

现在我们发现 netns n1 可以 ping 通外部世界了:

$ sudo ip netns exec n1 ping 223.5.5.5
PING 223.5.5.5 (223.5.5.5) 56(84) bytes of data.
64 bytes from 223.5.5.5: icmp_seq=1 ttl=117 time=5.23 ms
64 bytes from 223.5.5.5: icmp_seq=2 ttl=117 time=4.25 ms
^C
--- 223.5.5.5 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 4.248/4.740/5.233/0.492 ms

$ sudo ip netns exec n1 ping -6 2400:3200:baba::1
PING 2400:3200:baba::1(2400:3200:baba::1) 56 data bytes
64 bytes from 2400:3200:baba::1: icmp_seq=1 ttl=119 time=3.96 ms
64 bytes from 2400:3200:baba::1: icmp_seq=2 ttl=119 time=4.22 ms
^C
--- 2400:3200:baba::1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 3.960/4.089/4.218/0.129 ms

现在,还有一个问题是 netns n1 内部的应用程序无法将域名解析为 IP 地址,因为:

$ cat /etc/resolv.conf

nameserver 127.0.0.53

所以,我们要拦截 n1 自己发出的 DNS 请求,并且把它重定向到一个可靠的 DNS 服务器地址,我们可以通过在 iptables 的 nat 表的 OUTPUT 链插入规则来做到这一点:

sudo ip netns exec n1 iptables -t nat -I OUTPUT 1 -p udp --dport 53 -j DNAT --to-destination 223.5.5.5:53

但是这样做还不够,因为我们抓包发现:

sudo ip netns exec n1 tcpdump -n -i any udp and dst port 53
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
^Cx veth1 Out IP 127.0.0.1.60391 > 223.5.5.5.53: 46118+ [1au] A? dns.alidns.com. (43)
x veth1 Out IP 127.0.0.1.60391 > 223.5.5.5.53: 7742+ [1au] AAAA? dns.alidns.com. (43)

2 packets captured
2 packets received by filter
0 packets dropped by kernel

我们发现 DNS 请求的源地址竟然是 loopback ,所以,我们还要对 netns n1 主动发出的 DNS 请求数据包做 SNAT:

sudo ip netns exec n1 iptables -t nat -I POSTROUTING 1 -p udp --dport 53 -j MASQUERADE

现在,我们就可以从 netns n1 内访问因特网啦!

$ sudo ip netns exec n1 ping -4 dns.alidns.com
PING dns.alidns.com (223.5.5.5) 56(84) bytes of data.
64 bytes from public1.alidns.com (223.5.5.5): icmp_seq=1 ttl=117 time=4.72 ms
64 bytes from public1.alidns.com (223.5.5.5): icmp_seq=2 ttl=117 time=4.90 ms
^C
--- dns.alidns.com ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1000ms
rtt min/avg/max/mdev = 4.715/4.808/4.901/0.093 ms

sudo ip netns exec n1 ping -6 dns.alidns.com
PING dns.alidns.com(2400:3200:baba::1 (2400:3200:baba::1)) 56 data bytes
64 bytes from 2400:3200:baba::1 (2400:3200:baba::1): icmp_seq=1 ttl=119 time=3.69 ms
64 bytes from 2400:3200:baba::1 (2400:3200:baba::1): icmp_seq=2 ttl=119 time=4.36 ms
^C
--- dns.alidns.com ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 3.694/4.029/4.364/0.335 ms

应用策略路由

应用策略路由可以让来自特定地点的 IP 封包交由人为指定的网卡发送出去,换句话说,我们可以指定形如「来自 interface x 的 IP 封包以 interface y 作为默认网卡」这样的规则。

举例来说,我们希望来自 netns n1 的封包默认从 enp3s0 网卡发送出去,为此,我们首先查询 enp3s0 的默认网关:

$ sudo ip -6 route show default dev enp3s0
default via fe80::fe83:c6ff:fe0d:9aae proto ra metric 101 pref medium

$ sudo ip -4 route show default dev enp3s0
default via 192.168.66.1 proto dhcp metric 101

然后,按如下方式配置策略路由:

sudo ip -4 rule add iif n1-veth1 table 121
sudo ip -6 rule add iif n1-veth1 table 121
sudo ip -4 route add default via 192.168.66.1 dev enp3s0 table 121
sudo ip -6 route add default via fe80::fe83:c6ff:fe0d:9aae dev enp3s0 table 121

然后我们分别以 223.6.6.6 和 2400:3200:baba::1 作为测试地址,验证来自 netns n1 的 IP 封包确实会从 enp3s0 发出去:

sudo tcpdump -n -i any host 2400:3200:baba::1 &
sudo tcpdump -n -i any host 223.6.6.6 &

sudo ip netns exec n1 ping -6 -c 1 2400:3200:baba::1
sudo ip netns exec n1 ping -4 -c 1 223.6.6.6

tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
x n1-veth1 In  IP6 fd03::1 > 2400:3200:baba::1: ICMP6, echo request, id 58357, seq 1, length 64
x enp3s0 Out IP6 240e:x > 2400:3200:baba::1: ICMP6, echo request, id 58357, seq 1, length 64
x enp3s0 In  IP6 2400:3200:baba::1 > 240e:x: ICMP6, echo reply, id 58357, seq 1, length 64
x n1-veth1 Out IP6 2400:3200:baba::1 > fd03::1: ICMP6, echo reply, id 58357, seq 1, length 64
x n1-veth1 In  IP 10.3.0.101 > 223.6.6.6: ICMP echo request, id 5192, seq 1, length 64
x enp3s0 Out IP 192.168.66.60 > 223.6.6.6: ICMP echo request, id 5192, seq 1, length 64
x enp3s0 In  IP 223.6.6.6 > 192.168.66.60: ICMP echo reply, id 5192, seq 1, length 64
x n1-veth1 Out IP 223.6.6.6 > 10.3.0.101: ICMP echo reply, id 5192, seq 1, length 64

由 tcpdump 的输出可知,来自 netns n1 的 IP 封包确实从 enp3s0 发了出去。

1682 次点击
所在节点    宽带症候群
12 条回复
omgr
149 天前
赞一个👍

不过你需要的是不是一个现成的 nsproxy 见 /t/924171
beyondstars
149 天前
@omgr 是的,nsproxy (以及很多这样的软件)应该也能做类似的事。but, 手动配置静态路由 + 策略路由 + iptables + 修改 sysctl 的操作过程可能更过瘾吧 hhhh (满足了人菜瘾大的我),另外这种手动配置的过程也有一个好处就是灵活,而且自己容易掌握其中的原理。
xiaoke
148 天前
感谢分享(⁠•⁠‿⁠•⁠)--此刻一位人菜瘾大的网友路过
wtdg86ok
148 天前
可以用如:nsenter --net=/var/run/netns/ns1 bash 的命令来简化对 network namespace 的配置
basncy
148 天前
感谢 OP 跑通了. 之前就想用 netns 用于网络隔离, 比如部分 p2p 程序会获取所有可用 ip 来建立连接.

如果仅做全局代理, 用 iptables 的策略路由可能会更方便, 可以参考 android.
nmap
148 天前
太重了,执行个简单命令还得创建:ns ,veth ,route ,rule ,nat ,等等
https_proxy + proxychain 就能解决 99.9%的情况了
s82kd92l
148 天前
不用 netns 这么复杂的方案吧,直接把要分流的软件在另一个 uid 运行,再用 iptables 之类的根据 uid 进行策略路由就行了,少了一道路由过程性能更高
beyondstars
148 天前
@s82kd92l #7 确实这种基于 netns 方案的方案引入了一定的 overheads (比如 NAT 和额外的路由),我理解这个 uid 方案应该是在 ip(6)tables/nftable 中根据 uid 打 mark, 然后设置 mark 对应的策略路由,这应该是一种开销更小的方式。不过对于需要 root 权限的应用可能稍显麻烦。
beyondstars
148 天前
@s82kd92l #7 我刚才又看了一下 iptables 似乎支持根据 gid 打 mark: https://ipset.netfilter.org/iptables-extensions.man.html#:~:text=%2D%2Dgid%2Downer%20groupname

感觉或许能让进程运行在一个 user namespace 中,在这个 user ns 里面进程属于 user: root, group 是一个特定的 group, 然后根据 gid 打 mark 。只是一个思路。
aa51513
147 天前
这也太麻烦了
sbilly
96 天前
这配置量太大了,还不如在 docker 里面跑
beyondstars
96 天前
@sbilly #11 只要 netns 的隔离就够了,docker 还要自己做 image, docker 还虚拟了其它类型的 ns (等于是 docker 起了新的 netns + 其它各种各样 ns ),用 docker 配置量几乎不变,但是资源 overhead 增加了。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1000492

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX