透明代理本机出口tcp连接

目标非常明确,对于本机对外请求的 tcp 连接都拦截下来,经过一个代理,再发出去。为了避免代理自身的出流量被二次拦截造成死循环,代理发出去的包会被打上 2515 的 mark。在 iptables 过滤的时候会对有 2515 mark 的连接放行。

概念原型已经跑通了:alternative reality

本文大部分精华来自抄袭

use socket directly https://gist.github.com/jbenet/5c191d698fe9ec58c49d

get original destination GitHub - ryanchapman/go-any-proxy: A transparent proxy written in Golang. It can send to upstream proxies (e.g. corporate) and CONNECT on any port through corp proxies, giving internet access to external internet resources.

拦截出口 TCP 连接

第一件事情是要把连接给拦下来

sudo iptables -t nat -A OUTPUT -p tcp -j REDIRECT --to-port 2515

通过 -j REDIRECT 我们把连接给重定向到了本地的 2515 端口

listener, err := net.Listen("tcp", "127.0.0.1:2515") if err != nil { panic(err) } leftConn, err := listener.Accept() if err != nil { panic(err) }

一个普通的 tcp 服务器就可以把连接给接住。这个时候 curl 发起连接 百度一下,你就知道 的话,它会认为已经连接上了百度,但是实际上连接到了我们本地的代理。

获取目标的IP和端口

代理把 TCP 连接拦截下来之后,它并不知道原来的目标地址是什么,从而无法实现转发。这里需要使用

const SO_ORIGINAL_DST = 80 addr, err :=syscall.GetsockoptIPv6Mreq(int(tcpConnFile.Fd()), syscall.IPPROTO_IP, SO_ORIGINAL_DST)

来获得实际的 ip:port。在 python 里,这是一个很容易的操作。但是在 golang 里 fd 的获取还要费一些周折。

SO_ORIGINAL_DST 是这里的关键,fqsocks 之类的透明代理软件都是基于这个原理来实现的。

获取 TCP 连接的 FD

tcpConn := leftConn.(*net.TCPConn) // connection => file, will make a copy tcpConnFile, err := tcpConn.File() if err != nil { panic(err) } else { tcpConn.Close() }

调用 tcp connection 上的 File 方法可以获得文件对象,从而获得 Fd() 文件句柄。但是要注意,这个获取底层是一次拷贝操作。所以要把原来的 tcp 连接给关掉,然后把新获得的文件重新转换为 tcp 连接,否则会出现文件句柄泄露。

// file => connection leftConn, err = net.FileConn(tcpConnFile) if err != nil { panic(err) }

自此我们已经实现了 TCP 连接的拦截,并且知道了原始的目标地址是什么。

与真正目标建立连接

接下来的目标是给真正的目标地址建立连接。然后就可以双向转发了。

要建立连接,得先穿过我们之前设置的 iptables 规则。所以需要开个穿透规则

sudo iptables -t nat -I OUTPUT -p tcp -m mark --mark 2515 -j ACCEPT

在顶部插入一条规则,对 2515 的 mark 放行。

那么,现在问题就是怎么给包打上 2515 的 mark了。

给包打 Mark

打 Mark 的原理是给 socket 设置 SO_MARK 的属性值。但是 golang 的 dial 方法是不允许我们修改 socket 的 SO_MARK 的。所以需要把 dial 的过程重新实现一遍。

func newSocket() (fd int, err error) { syscall.ForkLock.RLock() fd, err = syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP) if err == nil { syscall.CloseOnExec(fd) } syscall.ForkLock.RUnlock() if err != nil { return -1, err } if err = syscall.SetNonblock(fd, true); err != nil { syscall.Close(fd) return -1, err } if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil { syscall.Close(fd) return -1, err } if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, 2515); err != nil { syscall.Close(fd) return -1, err } return fd, err }

这样我们就设置好了SO_MARK为2515了。接下来就是 connect 了,还是直接调用 syscall

// this is close to the connect() function inside stdlib/net func connect(fd int, ra syscall.Sockaddr, deadline time.Time) error { switch err := syscall.Connect(fd, ra); err { case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR: case nil, syscall.EISCONN: if !deadline.IsZero() && deadline.Before(time.Now()) { return errTimeout } return nil default: return err } var err error var to syscall.Timeval var toptr *syscall.Timeval var pw syscall.FdSet FD_SET(uintptr(fd), &pw) for { // wait until the fd is ready to read or write. if !deadline.IsZero() { to = syscall.NsecToTimeval(deadline.Sub(time.Now()).Nanoseconds()) toptr = &to } // wait until the fd is ready to write. we cant use: // if err := fd.pd.WaitWrite(); err != nil { // return err // } // so we use select instead. if _, err = Select(fd+1, nil, &pw, nil, toptr); err != nil { fmt.Println(err) return err } var nerr int nerr, err = syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_ERROR) if err != nil { return err } switch err = syscall.Errno(nerr); err { case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR: continue case syscall.Errno(0), syscall.EISCONN: if !deadline.IsZero() && deadline.Before(time.Now()) { return errTimeout } return nil default: return err } } }

解决权限问题

这段代码普通用户是无法执行的。因为 SO_MARK 是一个特权操作。要么使用 sudo,用 root 执行。要么 setcap

go build && sudo setcap cap_net_admin=eip motrix

给可执行文件加上了 cap_net_admin 的 capability 之后,普通用户就可以执行了。

双向转发

当我们有了两个连接之后,需要做的事情就是把A的请求交给B,然后把B的响应,交还给A。这样双方都感觉不到中间的 tcp 代理的存在了。

go io.Copy(leftConn, rightConn) go io.Copy(rightConn, leftConn)

fork 两个 goroutine 出来,双向拷贝,搞定。

为什么要做这么蛋疼的事情

because we can