Golang 中网络请求使用指定网卡

当发送一个网络请求时,默认流量会通过当前电脑的默认网卡接口流出与流入,但有时需要对将流量通过指定的网卡进行流出流入,这时我们可能需要进行一些额外的开发工作,对其实现主要用到了 Dialer.Control 配置项。

type Dialer struct {

// If Control is not nil, it is called after creating the network
// connection but before actually dialing.
//
// Network and address parameters passed to Control method are not
// necessarily the ones passed to Dial. For example, passing "tcp" to Dial
// will cause the Control function to be called with "tcp4" or "tcp6".
Control func(network, address string, c syscall.RawConn) error
}

可以看到这是一个函数类型的参数。

环境

当前系统一共两个网卡 ens33ens160 ,ip地址分别为 192.168.3.80192.168.3.48

➜  ~ ifconfig
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
      inet 192.168.3.80 netmask 255.255.255.0 broadcast 192.168.3.255
      inet6 fe80::8091:2406:c51e:ecb9 prefixlen 64 scopeid 0x20<link>
      ether 00:0c:29:4f:05:90 txqueuelen 1000 (Ethernet)
      RX packets 4805008 bytes 826619853 (826.6 MB)
      RX errors 0 dropped 104152 overruns 0 frame 0
      TX packets 732513 bytes 284605386 (284.6 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

ens160: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
      inet 192.168.3.48 netmask 255.255.255.0 broadcast 192.168.3.255
      inet6 fe80::259a:d8d4:80a9:7fa4 prefixlen 64 scopeid 0x20<link>
      ether 00:0c:29:4f:05:9a txqueuelen 1000 (Ethernet)
      RX packets 4158530 bytes 746167179 (746.1 MB)
      RX errors 1 dropped 106875 overruns 0 frame 0
      TX packets 351616 bytes 149235606 (149.2 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
      inet 127.0.0.1 netmask 255.0.0.0
      inet6 ::1 prefixlen 128 scopeid 0x10<host>
      loop txqueuelen 1000 (Local Loopback)
      RX packets 426742 bytes 80978543 (80.9 MB)
      RX errors 0 dropped 0 overruns 0 frame 0
      TX packets 426742 bytes 80978543 (80.9 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

路由表记录

➜  ~ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.3.1     0.0.0.0         UG    100    0        0 ens33
0.0.0.0         192.168.3.1     0.0.0.0         UG    101    0        0 ens160
169.254.0.0     0.0.0.0         255.255.0.0     U     1000   0        0 ens33
192.168.3.0     0.0.0.0         255.255.255.0   U     100    0        0 ens33
192.168.3.0     0.0.0.0         255.255.255.0   U     101    0        0 ens160
​

从最后两条路由记录可以看到对于 192.168.3.0/24 这个段的流量会匹配的两个物理网卡,但由于配置的 Metric 的优先级比较的高,因此最终流量只会走网卡 ens33

我们可以在另一台机器 192.168.3.58python3 搭建一个HTTPServer,用命令 python3 -m http.server 8080 即可快速启用一个webserver,这时在这台机器用 curl 发送一个请求。

curl -v http://web.test.com:8080/test/index.html

然后看下python3 的日志

sxf@sxf-virtual-machine:/data/8080$ python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
192.168.3.80 - - [16/Jan/2023 15:08:18] "GET /test/index.html HTTP/1.1" 200 -

可以看到客户端的IP正是 ens33网卡,即当前流量是经过默认网卡流出。

实现

下面我们再看一下如何用 Golang 来指定网卡,为了方便这里直接贴出来完整的代码

package main

import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"net/url"
"syscall"

"golang.org/x/sys/unix"
"net"
)

var interfaceName string = "ens160" // ens33、ens160

const (
network string = "tcp"
address string = "192.168.3.58:8080"
)

func main() {
  // 重要核心代码
d := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return setSocketOptions(network, address, c, interfaceName)
},
}

ctx := context.Background()
// 拨号获取一个连接
conn, err := d.DialContext(ctx, network, address)
if err != nil {
panic(err)
}

// 1. create new request
reqURL, _ := url.Parse("http://web.test.com/test/index.html")
hdr := http.Header{}
req := &http.Request{
Method: "GET",
URL:   reqURL,
Header: hdr,
}
err = req.Write(conn)
if err != nil {
panic(err)
}

// 2. Get a response
r := bufio.NewReader(conn)
resp, err := http.ReadResponse(r, req)
if err != nil {
panic(err)
}
defer resp.Body.Close()

status := resp.StatusCode
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(conn.LocalAddr())
for name, vv := range resp.Header {
fmt.Print(name, ":")
for _, v := range vv {
fmt.Print(v)
}
fmt.Println()
}
fmt.Println()
fmt.Println(status)
fmt.Println(string(body))
}

func isTCPSocket(network string) bool {
switch network {
case "tcp", "tcp4", "tcp6":
return true
default:
return false
}
}

func isUDPSocket(network string) bool {
switch network {
case "udp", "udp4", "udp6":
return true
default:
return false
}
}

func setSocketOptions(network, address string, c syscall.RawConn, interfaceName string) (err error) {
if interfaceName == "" || !isTCPSocket(network) && !isUDPSocket(network) {
return
}

var innerErr error
err = c.Control(func(fd uintptr) {
host, _, _ := net.SplitHostPort(address)
if ip := net.ParseIP(host); ip != nil && !ip.IsGlobalUnicast() {
return
}

if interfaceName != "" {
if innerErr = unix.BindToDevice(int(fd), interfaceName); innerErr != nil {
return
}
}

})

if innerErr != nil {
err = innerErr
}
return
}

我们首先自定义了 Dialer.Control 的实现

    d := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return setSocketOptions(network, address, c, interfaceName)
},
}

这里指定了自定义实现函数setSocketOptions, 下面是实现

func setSocketOptions(network, address string, c syscall.RawConn, interfaceName string) (err error) {
if interfaceName == "" || !isTCPSocket(network) && !isUDPSocket(network) {
return
}

var innerErr error
err = c.Control(func(fd uintptr) {
host, _, _ := net.SplitHostPort(address)
if ip := net.ParseIP(host); ip != nil && !ip.IsGlobalUnicast() {
return
}

// 核心代理
if interfaceName != "" {
if innerErr = unix.BindToDevice(int(fd), interfaceName); innerErr != nil {
return
}
}

})

if innerErr != nil {
err = innerErr
}
return
}

这里重点是使用了 unix.BindToDevice() 函数来指定了要使用的物理网卡,其参数类型是 int 整型,其实现为官方库 https://pkg.go.dev/golang.org/x/sys/unix#BindToDevice

// BindToDevice binds the socket associated with fd to device.
func BindToDevice(fd int, device string) (err error) {
return SetsockoptString(fd, SOL_SOCKET, SO_BINDTODEVICE, device)
}

func SetsockoptString(fd, level, opt int, s string) (err error) {
var p unsafe.Pointer
if len(s) > 0 {
p = unsafe.Pointer(&[]byte(s)[0])
}
return setsockopt(fd, level, opt, p, uintptr(len(s)))
}

func setsockopt(s int, level int, name int, val unsafe.Pointer, vallen uintptr) (err error) {
_, _, e1 := Syscall6(SYS_SETSOCKOPT, uintptr(s), uintptr(level), uintptr(name), uintptr(val), uintptr(vallen), 0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}

通过函数一级级的调用,最终调用了 Syscall6 函数,到这里基本可以不用再往下跟踪了,对于我们来讲,个人觉得只要跟踪到 setsocketopt 函数就可以了,对于写 c 的同学来说,对于这个函数还是非常熟悉的。

此时我们自己的环境修改一些配置并运行上面的程序,在 webserver 的日志里可以看到客户端的ip就是上面程序指定网卡的ip地址。

总结

在一些网络底层基本都会用到 Dialer.Control 这个字段配置,在C中这么多通过 setsocketopt 实现的方法都是在这里实现的,这里推荐大家参考一下耗子叔以前写的一篇博客 从一次经历谈 TIME_WAIT 的那些事 ,采用的也是类似的方法。