shadowsocks项目是基于socks5协议实现的,因此本文先简单说说socks5协议,然后再结合shadowsocks一起分析其代码实现。
socks5协议简单谈
socks5协议是一个代理协议,顾名思义就是在源服务器与目标服务器之间加一层代理服务器,源服务器把数据发送给代理服务器,再由代理服务器转发给目标服务器,如下图:
socks5协议通过上图可以看到,socks5协议是工作在源服务器与代理服务器之间的,而与目标服务器没有关系。
下面说说socks5协议中关于TCP协议的处理流程:
一、握手
由于在源服务器与目标服务器之间加了一层代理,毫无疑问源服务器首先需要的就是跟代理服务器建立连接,因此第一步就是源服务器跟代理服务器进行握手建立连接。具体过程是源服务器发送一个协议头给代理服务器,该协议头包括协议版本号以及本地支持的访问代理服务器需要的权限校验方法,代理服务器从请求数据中选一个自己支持的权限校验方法返回给客户端。
socks5协议在源服务器与代理服务器进行握手时定义了多种权限校验方法比如使用用户名密码,当然也有无需任何校验直接就可以连接的情况。
二、发送目标服务器地址
完成握手以后,源服务器与代理服务器连接就建立好了,这时源服务器就需要把真正要访问的目标服务器地址发送给代理服务器,代理服务器会根据目标地址与目标服务器建立连接,然后返回给用户建立连接的结果。
三、发送数据
一旦第二步中代理服务器返回成功,就可以开始发送数据了,过程就是源服务器把数据发送给代理服务器,然后代理服务器通过第二步中与目标服务器建立的连接把数据转发给目标服务器,最后目标服务器把响应结果返回给代理服务器,代理服务器再把数据返回给源服务器。
到这里socks5协议的基本工作流程就确认了,关于它规定的一些数据包的细节我们结合shadowsocks的源码一起看下,好了,下面进入正题。
shadowsocks中socks5协议的应用
话不多说,先上一张shadowsocks的架构图,如下:
shadowsocks结构注:图中ss表示shadowsocks的缩写。
从上图首先可以看出,整个shadowsocks分为client跟server两个端。
浏览器通过设置代理服务器为本地ss-client将请求都转发给ss-client,然后ss-client把浏览器发来的请求数据加密后发送给ss-server,最后ss-server将接收到的数据解密后再发给目标服务器。
同样,响应数据的处理正好与上面反过来,目标服务器把响应结果返回给ss-server,然后ss-server对该结果进行加密后发送给ss-client,最后ss-client将返回的数据进行解密后把明文返回给浏览器。
注:ss-client,ss-server各自都需要解密对方发来的的数据以及加密要发送给对方的数据。
结合上图以及shadowsocks的执行流程,你可能会认为shadowsocks跟socks5协议相比其实是需要两层代理的,也就是说浏览器、ss-client、ss-server这三个模块之间的数据交互可以认为是一个socks5协议,此外,ss-client、ss-server、目标服务这三个模块又组成了一个socks5协议。因为这两组服务器之间都存在转发对方数据的逻辑。
换句话说,从socks5协议的规定来看浏览器跟ss-client之间需要进行一次socks5协议的握手,另外ss-client与ss-server之间也需要进行一次同样的握手,这样整个流程才能跑通。
真的需要应用两次socks5协议才能实现么?
事实上shadowsocks并没有采用这么复杂的方式实现,起码本文所讨论的go语言版没有这么实现。我下面讨论的也都以go语言版的shadowsocks为标准进行分析。
shadowsocks实际是在中间的那一层代理服务器上做文章,把它一分为二,如下图:
shadowsocks结构
这么一看就清楚多了,ss-client跟ss-server看成是一个完整的代理服务,基于前面将的socks5协议,那么就是说我们只需要在浏览器跟代理服务器之间做好握手建立连接以及发送目标地址给代理服务器两个环节就好了。至于ss-client与ss-server之间怎么进行通信这就是socks5协议本身之外的事情,shadowssocks也确实是自定义了它们之间的交互方式,更多细节一起从源码来看下。
shadowsocks源码分析
一、ss-client源码分析:
在看源码之前,首先要根据上面的架构图想一想ss-client需要做一些什么事情。
大体来说有两件事:
- 接收浏览器发来的连接请求,与浏览器握手建立连接。
- 与ss-server建立连接并把浏览器发来的数据转发给它。
注:shadowsocks中关于ss-client与ss-server之间建立连接需要的一些信息,比如ss-server的IP地址,以及连接需要的密码,数据的加密方式等都是预先在配置文件中定义好的,这份配置文件ss-client跟ss-server都需要。
铺垫了这么多,终于可以上代码了,go语言版本的shadowsocks中ss-client端的代码放在local.go文件中,入口就是main方法,一起看下:
func main() {
//...
//正如前面所说的,很多信息放在配置文件里
//因此上面省略了大量的启动配置信息的校验逻辑,
//该方法是根据config里面的信息初始化跟ss-server端交互用的加密信息
//经过上面一系列的解析过程,这里的参数config封装了ss-server的IP,端口,密码
//以及本地ss-client监听浏览器请求的端口,本地IP也可以设置(默认127.0.0.1)
parseServerConfig(config)
//根据指定的本地IP跟端口启动监听程序,这里是监听浏览器的连接请求
run(config.LocalAddress + ":" + strconv.Itoa(config.LocalPort))
}
在看parseServerConfig方法时首先要注意的就是一个ss-client是允许配置多个ss-server进行访问的,这样ss-client会自动判断某个ss-server是否可用,如果不可用则会自动切换到下一个ss-server。但这里为了简单起见,我们只分析单个ss-server的情况。
下面先看下parseServerConfig方法:
func parseServerConfig(config *ss.Config) {
//go语法,用命名变量的方式命名一个方法,用于判断是否包含port
hasPort := func(s string) bool {
_, port, err := net.SplitHostPort(s)
if err != nil {
return false
}
return port != ""
}
//只指定了一个server情况,这个ServerPassword会在前面解析配置文件时进行初始化
//如果只指定了一个ss-server地址,那么这个变量就为空
if len(config.ServerPassword) == 0 {
//method就是指定使用的加密方法
method := config.Method
//初始化config时,如果指定的method后面附加了-auth则会把config.Auth设置为true
//然后把method截取掉-auth赋值给config.Method,这里是初始化加密要用,所以又加上-auth
if config.Auth {
method += "-auth"
}
//根据指定的加密方法初始化加密对象,后面详细分析内部实现
cipher, err := ss.NewCipher(method, config.Password)
//error不为空说明初始化失败
if err != nil {
log.Fatal("Failed generating ciphers:", err)
}
//校验port合法性
srvPort := strconv.Itoa(config.ServerPort)
//获取server数组
srvArr := config.GetServerArray()
//这个n就是指定的ss-server数量,这里讨论的就是n=1的情况
n := len(srvArr)
//创建与ss-server数量相同的加密对象数组,因为每个ss-server对应自己的加密方法
servers.srvCipher = make([]*ServerCipher, n)
for i, s := range srvArr {
//这里的hasPort就是方法开始定义的内部方法,用来判断是否包含端口
if hasPort(s) {
//这里的s就是用户指定的ss-server的地址,cipher是该ss-server对应的加密对象
servers.srvCipher[i] = &ServerCipher{s, cipher}
} else {
servers.srvCipher[i] = &ServerCipher{net.JoinHostPort(s, srvPort), cipher}
}
}
} else {
//这里省略的就是多个ss-server的处理逻辑
}
//每个ss-server对应一个failCnt失败数,这样用于后面ss-server不可用时的切换,可省略不看
servers.failCnt = make([]int, len(servers.srvCipher))
for _, se := range servers.srvCipher {
log.Println("available remote server", se.server)
}
return
}
在整个shadowsocks源码中,个人认为最难懂的就是加解密的整个过程,而上面的代码片段中ss.NewCipher就是初始化加密对象的一部分逻辑,因此必须要仔细阅读,但在分析它的源码之前必须先普及一点go语言中加密API的基本使用方式,由于shadowsocks推荐使用的加密方式是aes-256-cfb,因此我们先来看看这种加密方式的基本使用方式,代码如下:
//aes加密逻辑
func ExampleNewCFBEncrypter() {
//用于加密的key
key, _ := hex.DecodeString("6368616e676520746869732070617373")
//想要加密的明文
plaintext := []byte("要加密的明文")
//根据key初始化一个block对象,这里就是原生的api了,细节不用考虑
block, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
//这里创建了一个aes.BlockSize加上明文长度的字节数组,
//这个aes.BlockSize根据加密方式的不同拥有不同的大小,
//shadowsocks中使用的是aes-256,因此它的长度是32,这里只使用了默认的16
ciphertext := make([]byte, aes.BlockSize+len(plaintext))
//aes.BlockSize长度对应的字节中的内容在加密算法里叫做初始变量iv,加解密时需要它
iv := ciphertext[:aes.BlockSize]
//这里是使用go的语法直接随机生成指定大小(即iv长度)的随机串来作为加密要使用的iv值
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
panic(err)
}
//初始化加密流,可以看到需要用到由key生成的block以及随机生成的iv
stream := cipher.NewCFBEncrypter(block, iv)
//这里就是对明文加密了,重点可以看到并没有iv进行加密
stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)
//打印密文
fmt.Println(ciphertext[aes.BlockSize:])
}
看完了加密API的基本使用方式,再来看下解密的API:
func ExampleNewCFBDecrypter() {
//要使用跟加密一样的key才行
key, _ := hex.DecodeString("6368616e676520746869732070617373")
ciphertext, _ := hex.DecodeString("把要解密密文传入返回字节数组")
//跟加密一样初始化
block, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
//这里是重点,可以看到它是先读取密文中的前aes.BlockSize长度字节作为iv
//结合上面的加密看,这个iv是没有加密的
iv := ciphertext[:aes.BlockSize]
//截取真正的密文内容
ciphertext = ciphertext[aes.BlockSize:]
//根据block,iv初始化解密流
stream := cipher.NewCFBDecrypter(block, iv)
//解密,这里有个隐藏细节就是明文字节数与密文字节数相等
stream.XORKeyStream(ciphertext, ciphertext)
//打印解密后的明文
fmt.Println(ciphertext)
}
一口气看完上面的加解密API可能有点不好理解,出去这里面的细节,我们只需要重点关注下面三点,这对于后面理解shadowsocks有很大作用。
-
加密需要使用一个key跟一个指定大小的iv变量,其中iv可以随机生成,iv的大小跟具体使用的加密算法有关。
-
解密也需要key跟iv两个变量,key需要提前指定也就是所谓的密码,而iv可以看到由加密的时候随机生成,但它需要把该变量加在密文前面一起发送给解密端,因为解密时不仅需要key,还需要iv。
-
明文跟密文的字节大小相同。
了解了加解密API的基本使用方式以后就可以看下shadowsocks中初始化加密对象的代码,如下:
//这里入参,method当然就是加密方法,password则是配置文件中指定的访问
//ss-server时使用的密码
func NewCipher(method, password string) (c *Cipher, err error) {
if password == "" {
return nil, errEmptyPassword
}
var ota bool
//如果有必要就截取指定加密方法后面的-auth来获得真正的加密方法
if strings.HasSuffix(strings.ToLower(method), "-auth") {
method = method[:len(method)-5] // len("-auth") = 5
//如果指定了-auth就把ota设置为true,这里后面会用到
ota = true
} else {
ota = false
}
//这里是从一个全局定义的map中根据加密方法名获得该加密方法的一些信息
//返回的这个mi对象中包括了对应加密方法需要的key跟iv的长度值
mi, ok := cipherMethod[method]
if !ok {
return nil, errors.New("Unsupported encryption method: " + method)
}
//这里根据指定的密码跟key的长度生成一个key,也就是说key是由
//password计算出来,相同的password必然是生成相同的key
//具体生成逻辑就不看了,因为这个不是强制的,你可以使用任何方式
//去根据password生成一个指定长度的key,只要保证同一个password
//生成的key是相同的即可
key := evpBytesToKey(password, mi.keyLen)
//把key跟加密信息封装成Cipher对象
c = &Cipher{key: key, info: mi}
if err != nil {
return nil, err
}
//保存ota信息
c.ota = ota
return c, nil
}
可以看到上面初始化的时候没有管加密所需要的iv变量,是的,它是在要加密数据的时候才会生成,后面会分析,这里接着往下看就好。
承接上面ss-local中main方法的源码,接着看最后的一条run方法的实现,如下:
//入参即为本地监听端口
func run(listenAddr string) {
//开启监听,这里是监听浏览器的连接请求,可以看到是tcp协议
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatal(err)
}
for {
conn, err := ln.Accept()
if err != nil {
log.Println("accept:", err)
continue
}
//一旦有浏览器的连接进来,那就创建一个线程去跟该连接交互
go handleConnection(conn)
}
}
可以想象所有的交互逻辑应该就在handleConnection方法中了,该方法的代码实现有很多细节,不管是加解密也好,还是socks5协议格式也好都需要补充,因此先不要注重细节,首先看下整个方法的处理流程,然后再逐个细节进行分析:
func handleConnection(conn net.Conn) {
closed := false
//go语法,函数执行完自动关闭连接
defer func() {
if !closed {
conn.Close()
}
}()
var err error = nil
//从方法名就可以看出来这里是处理socks5协议的握手过程
if err = handShake(conn); err != nil {
log.Println("socks handshake:", err)
return
}
//根据协议规则解析请求,可以想到上面握手成功了,
//那么这里就是浏览器会把真正要访问的目标服务器的地址发过来
//先不管解析协议的细节,先记住返回的rawaddr是由字节表示的目标服务地址
//而addr是把字节转成字符串以后的地址,可读性高
rawaddr, addr, err := getRequest(conn)
if err != nil {
log.Println("error getting request:", err)
return
}
//这里就是收到浏览器发来的目标服务器地址以后给浏览器的响应,
//关于格式的细节后面会说,这里先记住要给浏览器一个反馈
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x43})
if err != nil {
debug.Println("send connection confirmation:", err)
return
}
//这里传入的地址是要访问的真实地址,不是代理服务器地址,因为代理服务器地址已经配置在配置文件中了
//返回的remote就是ss-client与ss-server的连接
remote, err := createServerConn(rawaddr, addr)
if err != nil {
if len(servers.srvCipher) > 1 {
log.Println("Failed connect to all avaiable shadowsocks server")
}
return
}
defer func() {
if !closed {
remote.Close()
}
}()
//下面的两个方法就是转发数据的核心
//读取conn的数据并写入remote
go ss.PipeThenClose(conn, remote, nil)
//读取remote的数据并写入conn
ss.PipeThenClose(remote, conn, nil)
closed = true
}
到这里,整个流程就走通了,下面就该看看各个阶段的实现细节了。
文章开头也说了socks5协议在本地与代理服务器建立连接时要进行握手,首先看一下socks5协议对于握手数据格式的定义。
本地要通过socks5协议连接代理服务器,首要先发送下面数据包:
头协议
简单解释一下各个字段的含义:
字段名 | 说明 |
---|---|
VER | 占用1个字节,表示当前协议版本,在这里肯定就是0x05了。 |
NMETHODS | 占用1个字节,表示本地支持连接校验方式的数量。 |
METHODS | 占用1-255个字节,每个METHOD占用一个字节,也就是说前面的NMETHODS是几就表示后面的METHODS有多少个字节,连接校验的方法主要是无需认证,需要用户名密码认证之类的。 |
ss-local收到上面的数据以后,socks5协议规定返回数据格式如下:
头字节
毫无疑问,VER字段的值也是0x05,METHOD则是从客户端发来的METHODS中选择一个支持的认证方式返回给客户端,如果都不支持那就返回0xFF表示无可接受的方法,此时握手结束。
理论到实践,在shadowsocks的实现中,浏览器与ss-local连接是不需要认证的,可以直连,因此响应中METHOD的值设为0x00表示无需认证。
了解了上面的协议格式,下面看下握手方法handShake的源码:
func handShake(conn net.Conn) (err error) {
const (
idVer = 0
idNmethod = 1
)
//看协议论文这里最大应该是257个字节,这里初始化为258是为了预留一个保底?
buf := make([]byte, 258)
var n int
ss.SetReadTimeout(conn)
//前两个字节一个是ver,一个是nmethod
if n, err = io.ReadAtLeast(conn, buf, idNmethod+1); err != nil {
return
}
//第一个字节为协议版本
if buf[idVer] != socksVer5 {
return errVer
}
//第二个字节为支持的校验方法数量
nmethod := int(buf[idNmethod])
//这里根据头信息中指明的方法数量(一个就是1字节)然后再加上上面说的ver跟nmethods两个字节作为消息的总长度
msgLen := nmethod + 2
//n是实际读取到的字节数,理论上跟实际字节数是一样的
if n == msgLen {
//这个if内部表示实际读取到的字节跟理论需要读取的字节数一直,那么结束
} else if n < msgLen {
//如果不够就把剩余的读完
if _, err = io.ReadFull(conn, buf[n:msgLen]); err != nil {
return
}
} else {
//读取数据出现错误
return errAuthExtraData
}
//响应浏览器,按照上面说的返回两个字节,一个是协议版本号,一个是无需认证标识0
_, err = conn.Write([]byte{socksVer5, 0})
return
}
从上面的握手协议可以看到,ss-local与浏览器建立连接时并没有管浏览器发来的支持哪种校验方式的字段,而是直接返回无需认证标识。
结束了握手协议以后,socks5协议规定,此时客户端需要把要访问的目标地址(这个是真实的地址)发送给代理服务器,协议格式如下:
目标地址请求
还是分别说明一下各个字段的含义:
字段名 | 说明 |
---|---|
VER | 占用1个字节,表示当前协议版本,在这里肯定就是0x05了。 |
CMD | SOCK的命令码,占用1个字节,表示建立连接的类型,其中,0x01表示CONNECT请求,0x02表示BIND请求,0x03表示UDP转发 |
RSV | 0x00,保留字段 |
ATYP | DST.ADDR类型,其中0x01表示IPv4地址,此时DST.ADDR部分4字节长度。0x03则表示域名,此时DST.ADDR部分第一个字节为域名长度,DST.ADDR剩余的内容为域名,没有\0结尾。0x04表示IPv6地址,此时DST.ADDR部分16个字节长度。 |
DST.ADDR | 目标服务地址 |
DST.PORT | 网络字节序表示的目标服务的端口 |
代理服务器收到源服务器发来的目标地址数据以后,需要给源服务器个响应,格式定义如下:
应答目标地址请求
老规矩,逐个说明如下:
字段名 | 说明 |
---|---|
VER | 占用1个字节,表示当前协议版本,在这里肯定就是0x05了。 |
REP | 应答字段,0x00表示成功,0x01普通SOCKS服务器连接失败,0x02现有规则不允许连接,0x03网络不可达,0x04主机不可达,0x05连接被拒,0x06 TTL超时,0x07不支持的命令,0x08不支持的地址类型,0x09 - 0xFF未定义 |
RSV | 0x00,保留字段 |
ATYP | BND.ADDR类型,其中0x01表示IPv4地址,此时DST.ADDR部分4字节长度。0x03则表示域名,此时DST.ADDR部分第一个字节为域名长度,DST.ADDR剩余的内容为域名,没有\0结尾。0x04表示IPv6地址,此时DST.ADDR部分16个字节长度。 |
BND.ADDR | 代理服务器绑定的地址 |
BND.PORT | 网络字节序表示的服务器绑定的端口 |
搞清楚响应数据的格式,再翻返回看看上面代码中ss-local给浏览器的响应数据就一目了然了。
理论转实践,看看shadowsocks中解析目标地址数据的逻辑,这部分逻辑就在getRequest方法中,一起看下:
func getRequest(conn net.Conn) (rawaddr []byte, host string, err error) {
const (
idVer = 0
idCmd = 1
idType = 3 //根据上面的协议头,第4个字节表示地址类型
idIP0 = 4 //如果地址是IP,则第5个字节起为IP内容
idDmLen = 4 //如果地址是域名,则第5个字节表示域名长度
idDm0 = 5 //如果地址是域名,则第6个字节起表示域名开始
typeIPv4 = 1 // ATYP为1表示IPV4
typeDm = 3 // ATYP为3表示域名
typeIPv6 = 4 // ATYP为4表示IPV6
//如果是IPV4,则整个请求数据长度为:3(ver+cmd+rsv) + 1addrType + ipv4 + 2port
lenIPv4 = 3 + 1 + net.IPv4len + 2
//如果是IPV6,则整个请求数据长度为:3(ver+cmd+rsv) + 1addrType + ipv6 + 2port
lenIPv6 = 3 + 1 + net.IPv6len + 2
//如果是域名,则整个请求的长度为:3 + 1addrType + 1addrLen + 2port,
//再加上addrLen表示的域名字节长度
lenDmBase = 3 + 1 + 1 + 2
)
//这里初始化读取数据的大小就是理论上协议最大长度+1
buf := make([]byte, 263)
var n int
ss.SetReadTimeout(conn)
//这里是至少读5个字节,如果有多余的数据也会一起读进来
//就是说如果是域名,那也读取到了域名的长度
//返回值n就是实际读取的大小
if n, err = io.ReadAtLeast(conn, buf, idDmLen+1); err != nil {
return
}
//检验协议头
if buf[idVer] != socksVer5 {
err = errVer
return
}
//这里看出来shadowsocks中只处理CONNECT类型请求
if buf[idCmd] != socksCmdConnect {
err = errCmd
return
}
reqLen := -1
//这里是根据地址类型计算发来的请求数据的理论上的长度
switch buf[idType] {
case typeIPv4:
reqLen = lenIPv4
case typeIPv6:
reqLen = lenIPv6
case typeDm:
reqLen = int(buf[idDmLen]) + lenDmBase
default:
err = errAddrType
return
}
//同样是判断理论应该读取的字节数跟实际读取的是否一致
if n == reqLen {
//如果一直则什么也不做
} else if n < reqLen {
//如果不够则继续读完
if _, err = io.ReadFull(conn, buf[n:reqLen]); err != nil {
return
}
} else {
//报错
err = errReqExtraData
return
}
//截取地址信息,例如IPV4的话,rawaddr包括的信息是1addrType + ipv4 + 2port
rawaddr = buf[idType:reqLen]
//如果开启了debug模式则把目标地址字节转化成字符串用于后面打印日志
//可忽略
if debug {
switch buf[idType] {
case typeIPv4:
host = net.IP(buf[idIP0 : idIP0+net.IPv4len]).String()
case typeIPv6:
host = net.IP(buf[idIP0 : idIP0+net.IPv6len]).String()
case typeDm:
host = string(buf[idDm0 : idDm0+buf[idDmLen]])
}
port := binary.BigEndian.Uint16(buf[reqLen-2 : reqLen])
host = net.JoinHostPort(host, strconv.Itoa(int(port)))
}
return
}
结合上面的handleConnection方法看,到这里已经完成了浏览器与ss-local的握手以及目标地址的交换,那么接下来要做的就是ss-local与ss-server之间建立连接了,一起看下
createServerConn方法的实现:
//一定要注意这里传入的两个参数
//rawaddr目标地址的字节形式,addr目标地址的字符串形式
func createServerConn(rawaddr []byte, addr string) (remote *ss.Conn, err error) {
const baseFailCnt = 20
n := len(servers.srvCipher)
skipped := make([]int, 0)
for i := 0; i < n; i++ {
if servers.failCnt[i] > 0 && rand.Intn(servers.failCnt[i]+baseFailCnt) != 0 {
skipped = append(skipped, i)
continue
}
remote, err = connectToServer(i, rawaddr, addr)
if err == nil {
return
}
}
for _, i := range skipped {
remote, err = connectToServer(i, rawaddr, addr)
if err == nil {
return
}
}
return nil, err
}
前面说到shadowsock允许ss-local配置多个ss-server地址,它会根据连接成功失败的次数自动进行ss-server的切换,上面的代码片段就是做了这个逻辑,不过我们可以抛开表象看本质,除去这些附加逻辑,我们看到关键方法就是connectToServer,上源码:
//这里的入参,后两个已经说过,就不赘述了
//第一个是serverId,其实就是ss-local会把指定的ss-server的地址
//封装成一个数组,这里我们可以认为只有一个ss-server
//那么这个serverId传入的就是0
func connectToServer(serverId int, rawaddr []byte, addr string) (remote *ss.Conn, err error) {
//获取到ss-server对象
se := servers.srvCipher[serverId]
//DialWithRawAddr方法的入参分别是目标服务器的地址,ss-server的地址以及加密对象
remote, err = ss.DialWithRawAddr(rawaddr, se.server, se.cipher.Copy())
if err != nil {
//如果失败则做一些记录
const maxFailCnt = 30
if servers.failCnt[serverId] < maxFailCnt {
servers.failCnt[serverId]++
}
return nil, err
}
//如果连接成功则清楚该server对应是连接失败次数
servers.failCnt[serverId] = 0
return
}
一层一层,再往里看DialWithRawAddr方法的实现:
func DialWithRawAddr(rawaddr []byte, server string, cipher *Cipher) (c *Conn, err error) {
//连接服务器,这里的服务器就是ss-server
conn, err := net.Dial("tcp", server)
if err != nil {
return
}
//把与ss-server的连接与加密方式一起封装
c = NewConn(conn, cipher)
//这里回到了最一开始的ota变量,在加密方法后面附加-auth标识
//如果开启校验逻辑,则把验证信息加在rawaddr后面
if cipher.ota {
if c.enc == nil {
//初始化加密算法
if _, err = c.initEncrypt(); err != nil {
return
}
}
//这里是使用go原生的conn类写iv变量(明文)给ss-server
conn.Write(cipher.iv)
//对rawaddr的第一个字节做位运算,ss-server也会用该位运算
//来校验是否开启ota
rawaddr[0] |= OneTimeAuthMask
//进行ota的请求的封装处理
rawaddr = otaConnectAuth(cipher.iv, cipher.key, rawaddr)
}
//向代理服务器发目标地址,这里是使用封装好的连接对象,走加密逻辑
//这里rawaddr如果开启ota,则就是经过处理的rawaddr,否则为原生rawaddr
if _, err = c.write(rawaddr); err != nil {
c.Close()
return nil, err
}
return
}
首先来看下初始化加密对象的方法,如下:
func (c *Cipher) initEncrypt() (iv []byte, err error) {
if c.iv == nil {
//在这里随机生成了初始变量iv
iv = make([]byte, c.info.ivLen)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
c.iv = iv
} else {
iv = c.iv
}
//创建了加密流
c.enc, err = c.info.newStream(c.key, iv, Encrypt)
return
}
该方法不用多做解释了,其实就是根据前面说的AES加密API,这里会初始化iv变量以及Stream。
简单看下otaConnectAuth方法的实现:
func otaConnectAuth(iv, key, data []byte) []byte {
return append(data, HmacSha1(append(iv, key...), data)...)
}
很简单,就是在数据的后面加上一个经过hash算法的字符串,ss-server也会用同样的方式计算一次hash值来校验数据是否被串改。
数据已经准备好了,最后我们看下发送目标地址给ss-server的逻辑:
//注:这里传入的就是目标地址的字节表示
func (c *Conn) write(b []byte) (n int, err error) {
var iv []byte
//如果前面没有ota,那么这里进行加密初始化,不赘述
if c.enc == nil {
iv, err = c.initEncrypt()
if err != nil {
return
}
}
//获取一个用于写数据的字节buffer
cipherData := c.writeBuf
//这里有一层隐藏含义,如果c.enc没有初始化,则上面代码会初始化并设置iv的值
//如果已经初始化了,iv没有复制,即这里len(iv)就是0,也就是只有数据长度
dataSize := len(b) + len(iv)
//根据实际发送数据的大小调整写buffer的大小
if dataSize > len(cipherData) {
cipherData = make([]byte, dataSize)
} else {
cipherData = cipherData[:dataSize]
}
if iv != nil {
//如果这次是初始化,参考上面。则先把iv装进去,也就是iv放在数据前面
copy(cipherData, iv)
}
//这里可以看出来只加密数据,不加密iv
c.encrypt(cipherData[len(iv):], b)
//发送加密数据
n, err = c.Conn.Write(cipherData)
return
}
走到这里ss-local就跟ss-server建立起了连接,并且ss-local已经把要访问的目标地址发送给ss-server,接下来就是上面handleConnection这个方法中的最后一部分逻辑,开启数据的转发,来看下PipeThenClose方法的实现:
func PipeThenClose(src, dst net.Conn, addFlow func(int)) {
defer dst.Close()
//获取一个用于读数据的buffer
buf := leakyBuf.Get()
//如果方法执行完毕,则归还该读buffer
defer leakyBuf.Put(buf)
for {
SetReadTimeout(src)
//从源连接读取字节
n, err := src.Read(buf)
//该方法在ss-server用来统计流量,ss-local可忽略
if addFlow != nil {
addFlow(n)
}
//把读取到的数据写给dst
if n > 0 {
if _, err := dst.Write(buf[0:n]); err != nil {
Debug.Println("write:", err)
break
}
}
if err != nil {
//如果报错则退出
break
}
}
return
}
特别说明:
PipeThenClose方法这里入参src跟dst虽然都是net.Conn类型,但其实与浏览器的连接是真正的net.Conn,而与ss-server的连接则是经过封装以后的Conn并且该封装重写了Read,Write方法,也就是说同样都是Read方法,原生的net.Conn是单纯的读取数据,而重写的Read是先读取加密数据然后解密后返回明文,同样的重写的Write方法则是先加密再发送数据。
关于ss-local最后我们看下它重写的Write跟Read方法,也就是加密明文后写出跟读入密文后解密的逻辑。
首先看下Read方法:
func (c *Conn) Read(b []byte) (n int, err error) {
//如果是第一次读取数据,则初始化解密对象
if c.dec == nil {
//上面我们已经分析过加密用的iv变量会以明文的形式放在数据最前面发送
//因此这里直接根据长度读取iv变量即可
iv := make([]byte, c.info.ivLen)
if _, err = io.ReadFull(c.Conn, iv); err != nil {
return
}
//这里就是初始化解密Stream,不多说了
if err = c.initDecrypt(iv); err != nil {
return
}
if len(c.iv) == 0 {
c.iv = iv
}
}
//根据实际要读取的字节大小调整cipherData
cipherData := c.readBuf
if len(b) > len(cipherData) {
cipherData = make([]byte, len(b))
} else {
cipherData = cipherData[:len(b)]
}
//读取密文
n, err = c.Conn.Read(cipherData)
if n > 0 {
//解密返回明文
c.decrypt(b[0:n], cipherData[0:n])
}
return
}
再看下Write方法:
func (c *Conn) Write(b []byte) (n int, err error) {
nn := len(b)
//如果是ota则会对数据进行处理,后面细说
if c.ota {
chunkId := c.GetAndIncrChunkId()
b = otaReqChunkAuth(c.iv, chunkId, b)
}
//nn是要发送数据的大小,这里的len(b)则是处理后的大小
//它们的差就是处理时新增的数据头
headerLen := len(b) - nn
//加密写出,该方法上面已经分析过,特别注意一个是小写write,一个是大写Write
n, err = c.write(b)
//返回的n是指写出去的真实数据,如果在写出去以前因为加密原因增加了一些头信息,则需要减去
if n >= headerLen {
n -= headerLen
}
return
}
注1:开启ota的情况下,发送目标地址给ss-server时是使用的otaConnectAuth方法进行处理,而发送后面的转发数据给ss-server时是使用otaReqChunkAuth方法进行处理,思路都是一样的,只是有点小区别,前者是把校验信息加在数据后面,后者则是把校验信息加在数据前面,同样ss-server在处理接受数据时也会分别对这两类数据进行处理。
注2:在otaReqChunkAuth生成的请求中,会有开头专门的两个字节表示数据的长度,而在普通请求中没有长度的概念。原因是普通请求中不需要做任何校验,纯转发,客户端发十个字节的数据,服务端一个字节一个字节的读取再转发没有任何影响,而otaReqChunkAuth生成的数据需要做校验,校验就必须要知道数据的长度,因为客户端是用指定长度的数据做hash的,服务端必须读取到相同大小的字节进行hash才能校验成功。
最后一点,开启ota时对普通转发数据的包装处理代码:
func (c *Conn) GetAndIncrChunkId() (chunkId uint32) {
//获取一个叠加的id
chunkId = c.chunkId
c.chunkId += 1
return
}
//其实就是经过一系列的计算,得出一个header字节加在数据前面...
func otaReqChunkAuth(iv []byte, chunkId uint32, data []byte) []byte {
//这个nb字节表示数据的长度
nb := make([]byte, 2)
binary.BigEndian.PutUint16(nb, uint16(len(data)))
chunkIdBytes := make([]byte, 4)
binary.BigEndian.PutUint32(chunkIdBytes, chunkId)
//可以看到开头两个字节nb表示数据长度是明文
header := append(nb, HmacSha1(append(iv, chunkIdBytes...), data)...)
return append(header, data...)
}
好长好长,如果能坚持读到这里来那么恭喜你,你已经把浏览器连接ss-local,ss-local连接ss-server的流程都看完了,但请耐着性子,因为我们还有最有一点ss-server的逻辑需要看完,庆幸的是ss-local跟ss-server之间有很多公用的代码,相信读ss-server源码时你会比较轻松,好了,接着来吧。
二、ss-server源码分析:
这段代码写在server.go文件中,依旧从main函数开始看:
func main() {
//...省略掉一系列解析配置的代码,跟ss-local类似
for port, password := range config.PortPassword {
//创建一个线程开始监听
go run(port, password, config.Auth)
//如果是UDP则开启UDP监听
if udp {
go runUDP(port, password, config.Auth)
}
}
//这里是添加了一个管理线程,可以动态管理ss-server
//比如增加或删除一个端口之类的
if managerAddr != "" {
addr, err := net.ResolveUDPAddr("udp", managerAddr)
if err != nil {
os.Exit(1)
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
os.Exit(1)
}
defer conn.Close()
go managerDaemon(conn)
}
//监听系统信号,更新密码或者退出
waitSignal()
}
本文只讨论TCP协议,因此一起看下关于TCP连接的处理代码:
//入参就是监听端口,连接密码以及是否需要校验
func run(port, password string, auth bool) {
ln, err := net.Listen("tcp", ":"+port)
if err != nil {
os.Exit(1)
}
//管理该连接的流量
passwdManager.add(port, password, ln)
var cipher *ss.Cipher
for {
//接收来自ss-local的连接
conn, err := ln.Accept()
if err != nil {
return
}
//加密对象初始化,前面已经分析,不赘述
if cipher == nil {
cipher, err = ss.NewCipher(config.Method, password)
if err != nil {
conn.Close()
continue
}
}
go handleConnection(ss.NewConn(conn, cipher.Copy()), auth, port)
}
}
重点看下ss-server跟ss-local之间的交互逻辑,接着看handleConnection:
func handleConnection(conn *ss.Conn, auth bool, port string) {
var host string
//统计连接数量
connCnt++
if connCnt-nextLogConnCnt >= 0 {
nextLogConnCnt += logCntDelta
}
closed := false
//方法回调函数
defer func() {
connCnt--
if !closed {
conn.Close()
}
}()
//解析连接,这里是从ss-local发来的请求中解析处目标服务器的地址
host, ota, err := getRequest(conn, auth)
if err != nil {
closed = true
return
}
//校验
if strings.ContainsRune(host, 0x00) {
log.Println("invalid domain name.")
closed = true
return
}
debug.Println("connecting", host)
//ss-server与真正的目标服务器建立连接
remote, err := net.Dial("tcp", host)
//如果连接失败则根据错误类型记录日志
if err != nil {
if ne, ok := err.(*net.OpError); ok && (ne.Err == syscall.EMFILE || ne.Err == syscall.ENFILE) {
log.Println("dial error:", err)
} else {
log.Println("error connecting to:", host, err)
}
return
}
defer func() {
if !closed {
remote.Close()
}
}()
//这里可以看到针对是否配置了ota,对ss-client读入数据采用的不同的方式
if ota {
go func() {
ss.PipeThenCloseOta(conn, remote, func(flow int) {
passwdManager.addFlow(port, flow)
})
}()
} else {
go func() {
ss.PipeThenClose(conn, remote, func(flow int) {
passwdManager.addFlow(port, flow)
})
}()
}
//把目标服务返回的数据写入ss-local连接
ss.PipeThenClose(remote, conn, func(flow int) {
passwdManager.addFlow(port, flow)
})
closed = true
return
}
结合前面的ss-local发送目标地址给ss-server的逻辑,先来看下服务端的getRequest解析过程:
func getRequest(conn *ss.Conn, auth bool) (host string, ota bool, err error) {
ss.SetReadTimeout(conn)
//结合上面分析的ss-local的代码理解这里设置的初始字节数
// 1(addrType) + 1(lenByte) + 255(max length address) + 2(port) + 10(hmac-sha1)
buf := make([]byte, 269)
//这里要跟客户端代码一起看,conn如果是IPV4的话发过来的数据是1addrType + ipv4 + 2port
if _, err = io.ReadFull(conn, buf[:idType+1]); err != nil {
return
}
var reqStart, reqEnd int
//根据上面的格式说明,这里第一个字节就是IP类型
addrType := buf[idType]
switch addrType & ss.AddrMask {
case typeIPv4:
reqStart, reqEnd = idIP0, idIP0+lenIPv4
case typeIPv6:
reqStart, reqEnd = idIP0, idIP0+lenIPv6
case typeDm:
if _, err = io.ReadFull(conn, buf[idType+1:idDmLen+1]); err != nil {
return
}
reqStart, reqEnd = idDm0, idDm0+int(buf[idDmLen])+lenDmBase
default:
err = fmt.Errorf("addr type %d not supported", addrType&ss.AddrMask)
return
}
if _, err = io.ReadFull(conn, buf[reqStart:reqEnd]); err != nil {
return
}
//这里容易混淆的是IPv4len变量跟上面的lenIPv4变量,注意这里IPv4len仅仅是IP的长度
switch addrType & ss.AddrMask {
case typeIPv4:
host = net.IP(buf[idIP0 : idIP0+net.IPv4len]).String()
case typeIPv6:
host = net.IP(buf[idIP0 : idIP0+net.IPv6len]).String()
case typeDm:
host = string(buf[idDm0 : idDm0+int(buf[idDmLen])])
}
//解析端口号
port := binary.BigEndian.Uint16(buf[reqEnd-2 : reqEnd])
host = net.JoinHostPort(host, strconv.Itoa(int(port)))
//校验的方式就是重新计算请求数据的hash值(排除客户端发来的hash值),然后比较hash值是否相同
if auth || addrType&ss.OneTimeAuthMask > 0 {
ota = true
if _, err = io.ReadFull(conn, buf[reqEnd:reqEnd+lenHmacSha1]); err != nil {
return
}
iv := conn.GetIv()
key := conn.GetKey()
actualHmacSha1Buf := ss.HmacSha1(append(iv, key...), buf[:reqEnd])
if !bytes.Equal(buf[reqEnd:reqEnd+lenHmacSha1], actualHmacSha1Buf) {
err = fmt.Errorf("verify one time auth failed, iv=%v key=%v data=%v", iv, key, buf[:reqEnd])
return
}
}
return
}
最后来看下一直都没有分析过的PipeThenCloseOta方法,其实根据上面分析ss-local在ota情况下发送数据的逻辑,可以猜到这里就是在读取数据的时候需要先处理header数据然后再处理真实数据,上代码:
func PipeThenCloseOta(src *Conn, dst net.Conn, addFlow func(int)) {
const (
dataLenLen = 2
hmacSha1Len = 10
idxData0 = dataLenLen + hmacSha1Len
)
defer func() {
dst.Close()
}()
//获取一个用于读数据的buffer
buf := leakyBuf.Get()
defer leakyBuf.Put(buf)
for i := 1; ; i += 1 {
SetReadTimeout(src)
if n, err := io.ReadFull(src, buf[:dataLenLen+hmacSha1Len]); err != nil {
if err == io.EOF {
break
}
break
}
//后面的这段解析数据过程一定要结合ss-local发送数据的格式一起看
//首先是开头两个字节的数据长度
dataLen := binary.BigEndian.Uint16(buf[:dataLenLen])
//然后是十个字节的hash值
expectedHmacSha1 := buf[dataLenLen:idxData0]
var dataBuf []byte
if len(buf) < int(idxData0+dataLen) {
dataBuf = make([]byte, dataLen)
} else {
dataBuf = buf[idxData0 : idxData0+dataLen]
}
//读取真实数据
if n, err := io.ReadFull(src, dataBuf); err != nil {
if err == io.EOF {
break
}
break
}
//流量统计
addFlow(int(dataLen))
//重新计算一次hash值
chunkIdBytes := make([]byte, 4)
chunkId := src.GetAndIncrChunkId()
binary.BigEndian.PutUint32(chunkIdBytes, chunkId)
actualHmacSha1 := HmacSha1(append(src.GetIv(), chunkIdBytes...), dataBuf)
//比较两个hash值判断数据是否被串改
if !bytes.Equal(expectedHmacSha1, actualHmacSha1) {
break
}
if n, err := dst.Write(dataBuf); err != nil {
break
}
}
return
}
到这里终于把shadowsocks中整个TCP协议建立建立,转发数据,各种加解密以及校验数据完整性的逻辑全部分析完了,可以长舒一口气了。
后记:
前后研究shadowsocks的源码大概有三周左右,因为之前没接触过go语言,所以需要先看下go的基本语法,然后再结合该项目边看边学。
这篇博客算是目前为止写的最长的一篇了,没办法,shadowsocks涉及到socks5协议,很多很少接触的加密算法以及各种数据完整性校验逻辑,想一一说明白真的不容易,不过如果有人能读到这里,那真是更加不容易了。
本文只讨论技术,不讨论任何敏感问题,祝好。
网友评论
第二个问题连接只会建立一次,也就是说只有发送第一个请求之前才会走建立连接的逻辑,而一旦连接建立成功,后续的请求都是直接直接把数据发给该建立好的连接,而不用重复走握手过程。