go 语言实战入门案例之实现Socks5

举报
timerring 发表于 2023/08/03 13:25:39 2023/08/03
【摘要】 文章和代码已经归档至【Github仓库:https://github.com/timerring/backend-tutorial 】或者公众号【AIShareLab】回复 go 也可获取。 实现 Socks 5 代理 介绍socks5 协议它虽然是代理协议,但它并不能用来翻,它的协议都是明文传输。这个协议历史比较久远,诞生于互联网早期。它的用途是, 比如某些企业的内网为了确保安全性,有很严...

文章和代码已经归档至【Github仓库:https://github.com/timerring/backend-tutorial 】或者公众号【AIShareLab】回复 go 也可获取。

实现 Socks 5 代理

介绍

socks5 协议它虽然是代理协议,但它并不能用来翻,它的协议都是明文传输。
这个协议历史比较久远,诞生于互联网早期。它的用途是, 比如某些企业的内网为了确保安全性,有很严格的防火墙策略,但是带来的副作用就是访问某些资源会很麻烦。socks5 相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源。实际上很多翻软件,最终暴露的也是一个 socks5 协议的端口。
例如爬,在爬取过程中很容易会遇到 IP 访问频率超过限制。这个时候很多人就会去网上找一些代理 IP 池,这些代理 IP 池里面的很多代理的协议就是 socks5。

先看一下最终写完的代理服务器的效果。启动程序,然后在浏览器里面配置使用这个代理,此时打开网页。代理服务器的日志,会打印出你访问的网站的域名或者 IP ,这说明我们的网络流量是通过这个代理服务器的。也能在命令行去测试我们的代理服务器。我们可以用 curl -socks5 + 代理服务器地址,后面加一个可访问的 URL,如果代理服务器工作正常的话,那么 curl 命令就会正常返回。

原理

了解 socks5 协议的工作原理。正常浏览器访问一个网站,如果不经过代理服务器的话,就是先和对方的网站建立 TCP 连接,然后三次握手,握手完之后发起 HTTP 请求,然后服务返回 HTTP 响应。

如果设置代理服务器之后,流程会变得复杂一些。首先是浏览器和 socks5 代理建立 TCP 连接,代理再和真正的服务器建立 TCP 连接。这里可以分成四个阶段,握手阶段、认证阶段、请求阶段、 relay 阶段

  • 第一个握手阶段,浏览器会向 socks 5 代理发送请求,包的内容包括一个协议的版本号,还有支持的认证的种类,socks 5 服务器会选中一个认证方式,返回给浏览器。如果返回的是 00 的话就代表不需要认证,返回其他类型的话会开始认证流程,这里我们就不对认证流程进行概述了。
  • 第三个阶段是请求阶段,认证通过之后浏览器会对 socks 5 服务器发起请求。主要信息包括版本号,请求的类型,一般主要是 connection 请求,就代表代理服务器要和某个域名或者某个 IP 地址某个端口建立 TCP 连接。代理服务器收到响应之后,会真正和后端服务器建立连接,然后返回一个响应。
  • 第四个阶段是 relay 阶段。此时浏览器会发送正常发送请求,然后代理服务器接收到请求之后,会直接把请求转换到真正的服务器上。然后如果真正的服务器以后返回响应的话,那么也会把请求转发到浏览器这边。然后实际上代理服务器并不关心流量的细节,可以是 HTTP 流量,也可以是其它 TCP 流量。

TCP echo server

我们先在 go 里面写一个简单的 TCP echo server。为了方便测试, server 的工作逻辑很简单,你给他发送啥,他就回复啥,代码如下。

package main

import (
	"bufio"
	"log"
	"net"
)

func main() {
	// 首先在 main 函数里面先用 net.listen 去监听一个端口,会返回一个 server
	server, err := net.Listen("tcp", "127.0.0.1:10803")
	if err != nil {
		panic(err)
	}
	for {
		// 然后在一个死循环里面,每次去 accept 一个请求,成功就会返回一个连接
		client, err := server.Accept()
		if err != nil {
			log.Printf("Accept failed %v", err)
			continue
		}
		// 接下来的话我们在一个 process 函数里面去处理这个连接。
		// 这前面会有个 go 关键字,这个代表启动一个 goroutinue, 可以暂时类比为其他语言里面的启动一个子线程。只是这里的 goroutinue 的开销会比子线程要小很多,可以很轻松地处理上万的并发。
		go process(client)
	}
}
// 接下来是这个 process 函数的实现。
func process(conn net.Conn) {
	// 首先第一步的话会先加一个 defer connection.close(), defer 是 Golang 里面的一个语法,这一行的含义就是代表在这个函数退出的时候要把这个连接关掉,否则会有资源的泄露。
	defer conn.Close()
	// 用 bufio.NewReader 来创建一个带缓冲的只读流
	reader := bufio.NewReader(conn)
	for {
		// 带缓冲的流的作用是,可以减少底层系统调用的次数,比如这里为了方便是一个字节一个字节的读取,但是底层可能合并成几次大的读取操作。并且带缓冲的流会有更多的一些工具函数用来读取数据。
		// 可以简单地调用 readbyte 函数来读取单个字节。再把这一个字节写进去连接。
		b, err := reader.ReadByte()
		if err != nil {
			break
		}
		_, err = conn.Write([]byte{b})
		if err != nil {
			break
		}
	}
}

我们来简单测试一下我们的第一个 TCP 服务器,然后测试会需要用到 nc 命令。如果没有的话可以进行安装,这里用 nc 127.0.0.1 10803,输入 timerring 然后服务器就会给你返回 timerring。
先运行代码

然后另开一个终端进行测试:

auth

package main

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"net"
)

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

func main() {
	server, err := net.Listen("tcp", "127.0.0.1:1080")
	if err != nil {
		panic(err)
	}
	for {
		client, err := server.Accept()
		if err != nil {
			log.Printf("Accept failed %v", err)
			continue
		}
		go process(client)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	// 我们实现一个空的 auth 函数,在 process 函数里面调用,再来编写 auth 函数的代码。
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
	log.Println("auth success")
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+----------+----------+
	// |VER | NMETHODS | METHODS  |
	// +----+----------+----------+
	// | 1  |    1     | 1 to 255 |
	// +----+----------+----------+
	// VER: 协议版本,socks5为0x05
	// NMETHODS: 支持认证的方法数量
	// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
	// X’00’ NO AUTHENTICATION REQUIRED
	// X’02’ USERNAME/PASSWORD

	// 先用 read bytes 来把版本号读出来
	ver, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read ver failed:%w", err)
	}
	// 然后如果版本号不是 socket 5 的话直接返回报错
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	// 接下来我们再读取 method size ,也是一个字节。
	methodSize, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read methodSize failed:%w", err)
	}
	// 然后需要我们去 make 一个相应长度的一个 slice ,用 io.ReadFull 把它去填充进去。
	method := make([]byte, methodSize)
	_, err = io.ReadFull(reader, method)
	if err != nil {
		return fmt.Errorf("read method failed:%w", err)
	}
	// 写到这里,我们把获取到的版本号和认证方式打印一下。
	log.Println("ver", ver, "method", method)
	// +----+--------+
	// |VER | METHOD |
	// +----+--------+
	// | 1  |   1    |
	// +----+--------+
	// 此时,代理服务器还需要返回一个response, 返回包包括 两个字段,一个是 version 一个是 method,也就是我们选中的鉴传方式,我们当前只准备实现不需要鉴传的方式,也就是00。
	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil
}

我们回忆一下认证阶段的逻辑,首先第一步的话,浏览器会给代理服务器发送一个包,然后这个包有三个字段,

  • 第一个字段, version 也就是协议版本号,固定是 5
  • 第二个字段 methods,认证的方法数目
  • 第三个字段每个 method 的编码, 0代表不需要认证, 2 代表用户名密码认证
    我们用 curl 命令测试一下当前版本的效果。此时 curl 命令肯定是不成功的,因为我们的协议还没实现完成。

但是我们看日志会发现, version 和 method 可以正常打印,说明当前我们的实现是正确的。

请求阶段

接下来我们开始做第三步,实现请求阶段,我们试图读取到携带 URL 或者 IP 地址+端口的包,然后把它打印出来。

package main

import (
	"bufio"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
)

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

func main() {
	server, err := net.Listen("tcp", "127.0.0.1:1080")
	if err != nil {
		panic(err)
	}
	for {
		client, err := server.Accept()
		if err != nil {
			log.Printf("Accept failed %v", err)
			continue
		}
		go process(client)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
	// 我们实现一个和 auth 函数类似的 connect 函数,同样在 process 里面去调用。再来实现 connect 函数的代码。
	err = connect(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+----------+----------+
	// |VER | NMETHODS | METHODS  |
	// +----+----------+----------+
	// | 1  |    1     | 1 to 255 |
	// +----+----------+----------+
	// VER: 协议版本,socks5为0x05
	// NMETHODS: 支持认证的方法数量
	// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
	// X’00’ NO AUTHENTICATION REQUIRED
	// X’02’ USERNAME/PASSWORD

	ver, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read ver failed:%w", err)
	}
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	methodSize, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read methodSize failed:%w", err)
	}
	method := make([]byte, methodSize)
	_, err = io.ReadFull(reader, method)
	if err != nil {
		return fmt.Errorf("read method failed:%w", err)
	}

	// +----+--------+
	// |VER | METHOD |
	// +----+--------+
	// | 1  |   1    |
	// +----+--------+
	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil
}

func connect(reader *bufio.Reader, conn net.Conn) (err error) {
	// 我们来回忆一下请求阶段的逻辑。浏览器会发送一个包,包里面包含如下6个字段
	// +----+-----+-------+------+----------+----------+
	// |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER 版本号,socks5的值为0x05。version 版本号, 还是 5
	// CMD 0x01表示CONNECT请求。CMD 代表请求的类型,我们只支持 connection 请求,也就是让代理服务建立新的TCP连接。
	// RSV 保留字段,值为0x00,不理会。
	// ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。可能是 IPV4 IPV6 或者域名。
	//   0x01表示IPv4地址,DST.ADDR为4个字节
	//   0x03表示域名,DST.ADDR是一个可变长度的域名
	// DST.ADDR 一个可变长度的值,这个地址的长度是根据 atype 的类型而不同的,port 端口号,两个字节, 我们需要逐个去读取这些字段。
	// DST.PORT 目标端口,固定2个字节

	// 后面这四个字段总共四个字节,我们可以一次性把它读出来。我们定义一个长度为 4 的 buffer 然后把它读满。
	buf := make([]byte, 4)
	_, err = io.ReadFull(reader, buf)
	if err != nil {
		return fmt.Errorf("read header failed:%w", err)
	}
	// 读满之后,然后第0 个、第1个、第3个、分别是 version cmd 和 type
	ver, cmd, atyp := buf[0], buf[1], buf[3]
	// version 需要判断是 socket 5
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	// cmd 需要判断是 1,这里cmdBind是在前面定义好的
	if cmd != cmdBind {
		return fmt.Errorf("not supported cmd:%v", cmd)
	}
	// 下面的 atype,可能是 ipv4 ,ipv6,或者是 host。
	addr := ""
	switch atyp {
	// 如果 IPV4 的话,我们再次读满这个 buffer,因为这个 buffer 长度刚好也是4个字节
	case atypeIPV4:
		_, err = io.ReadFull(reader, buf)
		if err != nil {
			return fmt.Errorf("read atyp failed:%w", err)
		}
		// 然后逐个字节打印成 IP 地址的格式保存到 addr 变量。
		addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
	// 如果是个 host 的话
	case atypeHOST:
		// 需要先读它的长度
		hostSize, err := reader.ReadByte()
		if err != nil {
			return fmt.Errorf("read hostSize failed:%w", err)
		}
		// 再 make 一个相应长度的buf 填充它。
		host := make([]byte, hostSize)
		_, err = io.ReadFull(reader, host)
		if err != nil {
			return fmt.Errorf("read host failed:%w", err)
		}
		// 再转换成字符串保存到 addr 变量。
		addr = string(host)
	// IPV6 用得比较少,我们就暂时先不支持。
	case atypeIPV6:
		return errors.New("IPv6: no supported yet")
	default:
		return errors.New("invalid atyp")
	}
	_, err = io.ReadFull(reader, buf[:2])
	if err != nil {
		return fmt.Errorf("read port failed:%w", err)
	}
	// 最后还有两个字节那个是 port ,我们读取它,然后按协议规定的大端字节序转换成数字。
	// 由于上面的 buffer 已经不会被其他变量使用了,我们可以直接复用之前的内存,建立一个临时的 slice ,长度是2用于读取,这样的话最多会只读两个字节回来。
	port := binary.BigEndian.Uint16(buf[:2])
	// 接下来我们把这个地址和端口打印出来用于调试。
	log.Println("dial", addr, port)
	// 收到浏览器的这个请求包之后,我们需要返回一个包,这个包有很多字段,但其实大部分都不会使用。

	// +----+-----+-------+------+----------+----------+
	// |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER socks版本,这里为0x05,第一个是版本号还是 socket 5。
	// REP Relay field,内容取值如下 X’00’ succeeded,第二个,就是返回的类型,这里是成功就返回0。
	// RSV 保留字段,第三个是保留字段填 0。
	// ATYPE 地址类型,第四个 atype 地址类型填 1。
	// BND.ADDR 服务绑定的地址,第五个,第六个暂时用不到,都填成 0。
	// BND.PORT 服务绑定的端口DST.PORT

	// 一共 4 + 4 + 2 个字节,后面6个字节都是 0 填充。
	_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
	if err != nil {
		return fmt.Errorf("write failed: %w", err)
	}
	return nil
}

现在我们来测试一下当前阶段的成果, 简单 curl 一下。

此时请求还是会失败,我们现在已经能看到正常打印出来访问的 IP 地址和端口,这说明我们当前的实现正常,这样我们就可以做最后一步,我们真正和这个端口建立连接,双向转发数据。

我们直接用 net.dial 建立一个 TCP 连接,建立完连接之后,我们同样要加一个 defer 来关闭连接。接下来需要建立浏览器和下游服务器的双向数据转发。

标准库的 io.copy 可以实现一个单向数据转发,双向转发的话,需要启动两个 goroutinue。

现在有一个问题,connect 函数会立刻返回,返回的时候连接就被关闭了。需要等待任意一个方向 copy 出错的时候,再返回 connect 函数。这里可以使用到标准库里面的一个 context 机制,用 context 连 with cancel 来创建一个 context。

在最后等待 ctx.Done() ,只要 cancel 被调用, ctx.Done 就会立刻返回。然后在上面的两个 goroutinue 里面调用一次 cancel 即可。

完整代码

package main

import (
	"bufio"
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
)

const socks5Ver = 0x05
const cmdBind = 0x01
const atypeIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04

func main() {
	server, err := net.Listen("tcp", "127.0.0.1:1080")
	if err != nil {
		panic(err)
	}
	for {
		client, err := server.Accept()
		if err != nil {
			log.Printf("Accept failed %v", err)
			continue
		}
		go process(client)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	err := auth(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
	err = connect(reader, conn)
	if err != nil {
		log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
		return
	}
}

func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+----------+----------+
	// |VER | NMETHODS | METHODS  |
	// +----+----------+----------+
	// | 1  |    1     | 1 to 255 |
	// +----+----------+----------+
	// VER: 协议版本,socks5为0x05
	// NMETHODS: 支持认证的方法数量
	// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
	// X’00’ NO AUTHENTICATION REQUIRED
	// X’02’ USERNAME/PASSWORD

	ver, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read ver failed:%w", err)
	}
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	methodSize, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read methodSize failed:%w", err)
	}
	method := make([]byte, methodSize)
	_, err = io.ReadFull(reader, method)
	if err != nil {
		return fmt.Errorf("read method failed:%w", err)
	}

	// +----+--------+
	// |VER | METHOD |
	// +----+--------+
	// | 1  |   1    |
	// +----+--------+
	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil
}

func connect(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+-----+-------+------+----------+----------+
	// |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER 版本号,socks5的值为0x05
	// CMD 0x01表示CONNECT请求
	// RSV 保留字段,值为0x00
	// ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
	//   0x01表示IPv4地址,DST.ADDR为4个字节
	//   0x03表示域名,DST.ADDR是一个可变长度的域名
	// DST.ADDR 一个可变长度的值
	// DST.PORT 目标端口,固定2个字节

	buf := make([]byte, 4)
	_, err = io.ReadFull(reader, buf)
	if err != nil {
		return fmt.Errorf("read header failed:%w", err)
	}
	ver, cmd, atyp := buf[0], buf[1], buf[3]
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	if cmd != cmdBind {
		return fmt.Errorf("not supported cmd:%v", cmd)
	}
	addr := ""
	switch atyp {
	case atypeIPV4:
		_, err = io.ReadFull(reader, buf)
		if err != nil {
			return fmt.Errorf("read atyp failed:%w", err)
		}
		addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
	case atypeHOST:
		hostSize, err := reader.ReadByte()
		if err != nil {
			return fmt.Errorf("read hostSize failed:%w", err)
		}
		host := make([]byte, hostSize)
		_, err = io.ReadFull(reader, host)
		if err != nil {
			return fmt.Errorf("read host failed:%w", err)
		}
		addr = string(host)
	case atypeIPV6:
		return errors.New("IPv6: no supported yet")
	default:
		return errors.New("invalid atyp")
	}
	_, err = io.ReadFull(reader, buf[:2])
	if err != nil {
		return fmt.Errorf("read port failed:%w", err)
	}
	port := binary.BigEndian.Uint16(buf[:2])

	dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
	if err != nil {
		return fmt.Errorf("dial dst failed:%w", err)
	}
	defer dest.Close()
	log.Println("dial", addr, port)

	// +----+-----+-------+------+----------+----------+
	// |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
	// +----+-----+-------+------+----------+----------+
	// | 1  |  1  | X'00' |  1   | Variable |    2     |
	// +----+-----+-------+------+----------+----------+
	// VER socks版本,这里为0x05
	// REP Relay field,内容取值如下 X’00’ succeeded
	// RSV 保留字段
	// ATYPE 地址类型
	// BND.ADDR 服务绑定的地址
	// BND.PORT 服务绑定的端口DST.PORT
	_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
	if err != nil {
		return fmt.Errorf("write failed: %w", err)
	}
	// 现在有一个问题,connect 函数会立刻返回,返回的时候连接就被关闭了。需要等待任意一个方向copy出错的时候,再返回 connect 函数。
	// 可以使用到标准库里面的一个 context 机制,用 context 连 with cancel 来创建一个context。
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	// 然后在两个 goroutinue 里面 调用一次 cancel 即可。
	go func() {
		_, _ = io.Copy(dest, reader)
		cancel()
	}()
	go func() {
		_, _ = io.Copy(conn, dest)
		cancel()
	}()
	// 在最后等待 ctx.Done() , 只要 cancel 被调用, ctx.Done就会立刻返回。
	<-ctx.Done()
	return nil
}

我们可以试着在浏览器里面再测试一下,在浏览器里面测试代理需要安装这个 switchomega 插件,然后里面新建一个情景模式,代理服务器选 socks 5,端口 1080 ,保存并启用。此时你应该还能够正常地访问网站,代理服务器这边会显示出浏览器版本的域名和端口。


学习路线推荐


参考:字节内部课 Go 语言原理与实践

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。