a CURD boy's blog.

将 V2ray 订阅链接转换为 config 文件

2019.10.02

1.请求并解码

大多数订阅链接都是base64编码过的, 所以首先使用net/http中的Get()方法请求订阅链接, 获得base64编码后进行解码, 得到vmess://xxx格式的订阅链接
简单写一个方法测试一下:

func getSubscribe(link string) {
	resp, err := http.Get(link)
	if err != nil {
		log.Println(err)
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Println(err)
	}
	res, err:= base64.StdEncoding.DecodeString(string(body))
	if err != nil {
		log.Println(err)
	}
	fmt.Println(string(res))
}

发现可以成功请求并解码得到正确订阅链接, 但是会报错(1024非真实数字):

illegal base64 data at input byte 1024  

该错误可能是因为这串Base64是base64url, 而golang源码中也提到, 解码这种base64应该使用 RawURLEncoding

// RawURLEncoding is the unpadded alternate base64 encoding defined in RFC 4648.
// It is typically used in URLs and file names.
// This is the same as URLEncoding but omits padding characters.
var RawURLEncoding = URLEncoding.WithPadding(NoPadding)

所以只要把StdEncoding改为RawURLEncoding即可
流程没问题了, 重写一下, 使用NewDecoder()直接decode resp.Body后返回一个bytes.Buffer, 方便进行进一步处理

func getSubscribe(link string) *bytes.Buffer {
	resp, err := http.Get(link)
	if err != nil || resp.StatusCode != http.StatusOK {
		log.Panic(err) //请求错误, 不应该继续向下执行, panic掉
	}
	defer resp.Body.Close()
	//将数据放入一个 bytes.Buffer 中, 方便操作
	resBuffer := new(bytes.Buffer)
	_, _ = resBuffer.ReadFrom(base64.NewDecoder(base64.RawURLEncoding, resp.Body))
	return resBuffer
}

2.处理vmess://链接

然后开始处理所有的vmess://格式的链接, 将上面得到的buffer传进来, 按行分割之后, 再来一次base64解码, 然后反序列化, 返回一个struct数组, 方便后续使用

func encodeVmessLinks(b *bytes.Buffer) (resStruct []V2rayConfig) {
	for {
		link, err := b.ReadString('\n')
		if err != nil {
			if err == io.EOF { //已经读取了所有vmess链接
				break
			}
			log.Println(err)
			continue
		}
		res, err := base64.StdEncoding.DecodeString(link[8:])		//截断vmess://
		if err != nil {
			log.Println(err)
			return
		}
		var tmpStruct V2rayConfig
		err = json.Unmarshal(res, &tmpStruct)
		if err != nil {
			log.Println(err)
			return
		}
		resStruct = append(resStruct, tmpStruct)
	}
	return
}

3.测试延迟

开始想着可不可以调用v2ray来做延迟测试, 想了想感觉有点难搞, 咕咕咕以后有空再说, 就退而求其次使用直接ping的方法做, 开始直接用的是go-ping这个库

func pingAndPrint(AllVmessStruct []V2rayConfig) {
	var wg sync.WaitGroup
	for key, value := range AllVmessStruct {
		wg.Add(1)
		go func(k int, v V2rayConfig) {
			pinger, err := ping.NewPinger(v.Add)
			if err != nil {
				panic(err)
			}
			pinger.Count = 5
			pinger.Timeout = 5 * time.Second
			pinger.Interval = 100 * time.Millisecond
			pinger.Run()
			stats := pinger.Statistics()
			fmt.Printf([%02d]:%-40s| 最低延迟:%-12s 最高延迟:%-12s 丢包率%v \n,
				k,
				v.Ps,
				stats.MinRtt.Round(time.Millisecond).String(),	//抛弃小数点后的位数
				stats.MaxRtt.Round(time.Millisecond).String(),
				stats.PacketLoss)
			wg.Done()
		}(key, value)
	}
	wg.Wait()
}

这里给每一个ping都开了一个goroutine, 一方面可以快点搞完, 另一方面也可以顺便按照速度来排序, 不过有个缺点就是节点很多的情况下得往上滚一下才能看到速度最快的几个节点
还有一个要注意的细节是在for循坏内使用go func要传参, 不然的话只能取到第一个值, 闭包内取外部函数的参数的时候取的是地址, 而不是调用闭包时的参数值

	// 例如想要起goroutine来处理一个slice
	tmp := []string{one, two, three}
	
	//错误写法,三次输出均为 0 one
	for key_outside, value_outside := range tmp {
		go func() {
			fmt.Println(key_outside, value_outside)
		}()
	}
	
	//正确写法
	tmp := []string{one, two, three}
	for key_outside, value_outside := range tmp {
		go func(key_inside int, value_inside string) {
			fmt.Println(key_inside, value_inside)
		}(key, value)
	}

以及 go-ping 库的作者在README中写了在Linux上使用会遇到权限问题

Error listening for ICMP packets: socket: permission denied

他在Github中给出的解决方法是执行 sudo sysctl -w net.ipv4.ping_group_range=0 2147483647
经过测试之后, 发现只要将用户的group写进去就可以了, 不需要这么大的范围:sudo sysctl -w net.ipv4.ping_group_range='1000 1000' 然而, 在已经实现这部分之后发现这个命令只能管用一次, 重启就不行了, 要么就得以root权限运行程序, 感觉十分不优雅, 经过一番查找之后, 受stack overflow上的这个回答的启发, 感觉应该可以通过net包里面的DialTimeout函数来实现对服务器延迟的检测, 验证一下:

func pingAndPrint(AllVmessStruct []V2rayConfig) {
	var wg sync.WaitGroup
	for key, value := range AllVmessStruct {
		wg.Add(1)
		go func(k int, v V2rayConfig) {
			pinger, err := ping.NewPinger(v.Add)
			if err != nil {
				panic(err)
			}
			pinger.Count = 5
			pinger.Timeout = 5 * time.Second
			pinger.Interval = 100 * time.Millisecond
			pinger.Run()
			stats := pinger.Statistics()
			fmt.Printf([%02d]:%-40s| 最低延迟:%-12s 最高延迟:%-12s 丢包率%v \n,
				k,
				v.Ps,
				stats.MinRtt.Round(time.Millisecond).String(),	//抛弃小数点后的位数
				stats.MaxRtt.Round(time.Millisecond).String(),
				stats.PacketLoss)
			wg.Done()
		}(key, value)
	}
	wg.Wait()
}

大功告成, 测试之后, 这个方法和直接ping的结果是非常接近的, 其实就是建立了一次完整的TCP连接耗费的时间.

4.选择节点

最开始使用很原始的输入编号然后选择的写法, 想了想应该有现成的轮子, Github一找, 果然找到了这个: survey, 支持单选多选,挺好用的, 然而得把所有的节点都给传进去才行, 再一番尝试之后还是放弃了, 这个库虽然还算好用, 但是有几个缺点, 一是选项只能是[]string, 然后选择结果也是一个[]string, 并不灵活, 我还需要用一个map来存节点名字和配置的对应关系, 二是选择完之后他会输出选择的选项, 没办法关掉, 有点丑, 于是最后还是回到手动输入节点的方式来实现了:

fmt.Print(请输入要选择的节点编号,以空格分割,回车结束: )
	scanner := bufio.NewScanner(os.Stdin)
	scanner.Scan()
	selectResult := numbers(scanner.Text())

ps: 这篇文章是几年前写的, 因为一些需求,我又用了一次这个库, 还是一如既往的不太好用

5.生成config.json文件

这部分倒是最简单的部分, 写struct然后输出就行了, 就是生成struct的过程不太优雅, 不过已经可以用了, 就懒得再折腾了