V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
beyondstars
V2EX  ›  宽带症候群

利用 Linux netns 实现全局代理

  •  
  •   beyondstars · 2023-12-14 21:51:25 +08:00 · 2051 次点击
    这是一个创建于 372 天前的主题,其中的信息可能已经有所发展或是发生改变。

    动机

    在 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 发了出去。

    第 1 条附言  ·  2023-12-15 01:57:03 +08:00
    抱歉,概述 下面 “Linux namespace (简称 netns )是” 这句话的括号及括号里的内容是多余的,还请见谅,当时思维太发散了 hhhh
    第 2 条附言  ·  2023-12-19 11:50:49 +08:00

    受到评论区提及 iptables 的网友们的启发,发现其实还可以通过 cgroup 结合 fwmark 和策略路由机制实现同样的需求(i.e. 让指定进程的全部出入站流量经特定网卡转发)。下列是一个演示。

    假设我们希望对 ping 命令指定策略路由:对于它发往 223.5.5.5 的封包静默丢弃,而对于它发往 223.6.6.6 的封包交给当前 netns 的 veth1 转发。

    第 3 条附言  ·  2023-12-19 12:37:58 +08:00

    (接上一条)

    首先创建一个 cgroup ns,系统默认在新的 cgroup ns 中执行当前的 ${SHELL}:

    sudo unshare --cgroup
    

    查看位于新建的 cgroup ns 的 shell 的 pid:

    echo $$
    9882
    

    记下这个 pid,在它的父 shell (或者一个干净的 ssh 登录 shell)查看 pid=9882 进程的 cgroup 路径:

    sudo cat /proc/9882/cgroup
    0::/user.slice/user-1000.slice/session-4.scope
    

    这其实是一个相对路径,是相对于 cgroup 的 sysfs 类型文件系统的根 /sys/fs/cgroup 而言的,但是我们关心的不是这个,接下来在父 shell 中配置 iptables 识别并标记这个 cgroup ns 下的所有 socket 发出的封包:

    sudo iptables -t mangle -A OUTPUT -m cgroup --path /user.slice/user-1000.slice/session-4.scope -j MARK --set-mark 0x123
    

    MARK 这个 target 并不会修改封包本身,只是在内核中对封包和 fwmark 做一个关联,换言之只有当前系统才知道哪些封包对应哪些标记,它不会跨越主机传播。

    有了 fwmark,就可以配置相应的策略路由(仅作为例子):

    sudo ip rule add type blackhole to 223.5.5.5 fwmark 0x123
    sudo ip rule add fwmark 0x123 table 100
    sudo ip route add default via 192.168.64.1 dev veth1 table 100
    

    接下来,所有在位于新建 cgroup 的那个 shell (pid=9882) 执行的命令发出的封包都会和 fwmark 0x123 关联,可以针对具体的 fwmark 实施细粒度的防火墙策略和路由控制。

    这个方法相对于最初的 netns 方案省去了多次 NAT 和 IP 转发的开销,也不用创建新的网卡,思路就是简单清晰的「识别+匹配+应用策略」。

    12 条回复    2024-02-05 16:29:56 +08:00
    omgr
        1
    omgr  
       2023-12-14 22:33:54 +08:00   ❤️ 2
    赞一个👍

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

    如果仅做全局代理, 用 iptables 的策略路由可能会更方便, 可以参考 android.
    nmap
        6
    nmap  
       2023-12-15 11:38:44 +08:00   ❤️ 1
    太重了,执行个简单命令还得创建:ns ,veth ,route ,rule ,nat ,等等
    https_proxy + proxychain 就能解决 99.9%的情况了
    s82kd92l
        7
    s82kd92l  
       2023-12-15 18:40:07 +08:00 via Android
    不用 netns 这么复杂的方案吧,直接把要分流的软件在另一个 uid 运行,再用 iptables 之类的根据 uid 进行策略路由就行了,少了一道路由过程性能更高
    beyondstars
        8
    beyondstars  
    OP
       2023-12-15 18:54:02 +08:00
    @s82kd92l #7 确实这种基于 netns 方案的方案引入了一定的 overheads (比如 NAT 和额外的路由),我理解这个 uid 方案应该是在 ip(6)tables/nftable 中根据 uid 打 mark, 然后设置 mark 对应的策略路由,这应该是一种开销更小的方式。不过对于需要 root 权限的应用可能稍显麻烦。
    beyondstars
        9
    beyondstars  
    OP
       2023-12-15 19:08:59 +08:00
    @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
        10
    aa51513  
       2023-12-16 15:26:18 +08:00
    这也太麻烦了
    sbilly
        11
    sbilly  
       319 天前
    这配置量太大了,还不如在 docker 里面跑
    beyondstars
        12
    beyondstars  
    OP
       319 天前
    @sbilly #11 只要 netns 的隔离就够了,docker 还要自己做 image, docker 还虚拟了其它类型的 ns (等于是 docker 起了新的 netns + 其它各种各样 ns ),用 docker 配置量几乎不变,但是资源 overhead 增加了。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2653 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 20ms · UTC 05:27 · PVG 13:27 · LAX 21:27 · JFK 00:27
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.