写在前面
继前段时间把iOS macOS版本的55客户端做出来后,发现还是很不爽,由于使用了NetworkExtension不允许外部分发,iOS上架AppStore无可厚非,但连macOS版本的也要求上架他的Mac App Store,我想基本很少人用MAS吧。
一直想搞win版本的,毕竟win的用户量是大头。
由于没有win的电脑,加上C#版本的55实在看不懂,只能去考虑跨平台了。
于是,golang来了~也是因为在论坛看到有大佬使用55-go的版本二次开发出跨
平台客户端,这个想法才最终落实。
赶紧想办法要到大佬的qq,迫不及待的咨询了大佬
好吧 我承认,在这之前我从来没接触过golang...
于是开始gayhub上疯狂找代码.....
也不知道找了多久。
总算让哥哥我找到了一个大佬,写了一个能满足我大部分功能的跨平台客户端
灵感来自于蓝灯
如果对不同平台的逻辑(路由规则控制,桌面托盘控制)进行区分处理,它甚至可以装在路由器上. 如果您需要此类功能,请加入这个项目吧。
这个版本的客户端完全适用golang编写,55核心来自带有流量追踪的 55-go.
本版本的目的是希望能做出一个简单的方式,来实现55 ui跨平台的重新开发和编译。
本版本理论上支持 Mac(osx), Windows, Linux(linux没有进行编译与测试)
But!!!仔细一看这是一个本地的web程序。对于连H5、CSS、JS看着都一头雾水的我,加上一无所知的golang基础,简直是天大的考验。。。
鬼知道我是怎么写出来的。。。
下面这是我的修改版,用上了基佬紫
当然,Bug必须是有的。比如无法断开连接,连接失败的提示啦等等等。。。
第一次写Go的内容,好紧脏!!!
好了不啰嗦了。大量golang代码预警!!!
项目简介
主要根据55-go local.go 改写 原版的总共加起来才400多行代码,也是牛逼的。
分析了下铜蛇的修改版,GUI方面使用纯H5、CSS、Angular.js 说白了就是通过golang本地起一个服务,每次web操作记录通过接口访问的方式获取、保存数据。local.go的代码基本全部可以复用,增加一个登录流程,节点自动获取(在写iOS版的时候就已经写好了接口),使用情况。
代码
第三方库的使用
"github.com/getlantern/systray"
"github.com/gorilla/mux"
"github.com/skratchdot/open-golang/open"
"golang.org/x/net/proxy"
goproxy "gopkg.in/elazarl/goproxy.v1"
"github.com/getlantern/pac"
config.go
设置本地化存储,将配置信息保存到本地 这边主要就是增删改查
初始化跨平台路径
func init() {
if runtime.GOOS == "darwin" {
storageFolder = os.Getenv("HOME") + "/Library/Application Support"
cacheFolder = os.Getenv("HOME") + "/Library/Caches"
} else if runtime.GOOS == "windows" {
storageFolder = os.Getenv("APPDATA")
cacheFolder = os.Getenv("LOCALAPPDATA")
} else {
if os.Getenv("XDG_CONFIG_HOME") != "" {
storageFolder = os.Getenv("XDG_CONFIG_HOME")
} else {
storageFolder = filepath.Join(os.Getenv("HOME"), ".config")
}
if os.Getenv("XDG_CACHE_HOME") != "" {
cacheFolder = os.Getenv("XDG_CACHE_HOME")
} else {
cacheFolder = filepath.Join(os.Getenv("HOME"), ".cache")
}
}
s := GetStorageDir()
if !isPathExist(s) {
os.Mkdir(s, 0755)
}
if !isPathExist(GetStorageFile(Logo)) {
err := ioutil.WriteFile(GetStorageFile(Logo), GetRes(Logo), 0644)
if err != nil {
panic(err)
}
}
}
proxy.go
这里基本是55-go的local.go的内容,用于连接55
package main
import (
ss "github.com/dawei101/shadowsocks-go/shadowsocks"
"golang.org/x/net/proxy"
goproxy "gopkg.in/elazarl/goproxy.v1"
)
var (
errAddrType = errors.New("socks addr type not supported")
errVer = errors.New("socks version not supported")
errMethod = errors.New("socks only support 1 method now")
errAuthExtraData = errors.New("socks authentication get extra data")
errReqExtraData = errors.New("socks request get extra data")
errCmd = errors.New("socks command not supported")
)
//定义流量追踪
type TrafficListener struct {
in int64
out int64
}
//下载流量
func (t *TrafficListener) WhenIn(len int) {
atomic.AddInt64(&t.in, int64(len))
if atomic.LoadInt64(&t.in) > 10485760 {
go t.Sync()
}
}
//上传流量
func (t *TrafficListener) WhenOut(len int) {
atomic.AddInt64(&t.out, int64(len))
}
//保存流量记录
func (t *TrafficListener) Sync() {
in, out := atomic.LoadInt64(&t.in), atomic.LoadInt64(&t.out)
config, err := LoadConfig()
log.Printf("Sync traffic, in: %d, out: %d", in, out)
if err != nil {
log.Printf("Load config failed, when sync traffic, error is: %v", err)
return
}
config.AddTraffic(in, out)
err = SaveConfig(config)
if err != nil {
log.Printf("Save config failed, when sync traffic, error is: %v", err)
return
}
atomic.StoreInt64(&t.in, 0)
atomic.StoreInt64(&t.out, 0)
}
var TrafficCounter *TrafficListener
const (
socksVer5 = 5
socksCmdConnect = 1
)
func init() {
TrafficCounter = &TrafficListener{0, 0}
rand.Seed(time.Now().Unix())
}
func fatalf(fmtStr string, args interface{}) {
fmt.Fprintf(os.Stderr, fmtStr, args)
os.Exit(-1)
}
//shake处理
func handShake(conn net.Conn) (err error) {
const (
idVer = 0
idNmethod = 1
)
buf := make([]byte, 258)
var n int
ss.SetReadTimeout(conn)
// make sure we get the nmethod field
if n, err = io.ReadAtLeast(conn, buf, idNmethod+1); err != nil {
return
}
if buf[idVer] != socksVer5 {
return errVer
}
nmethod := int(buf[idNmethod])
msgLen := nmethod + 2
if n == msgLen { // handshake done, common case
// do nothing, jump directly to send confirmation
} else if n < msgLen { // has more methods to read, rare case
if _, err = io.ReadFull(conn, buf[n:msgLen]); err != nil {
return
}
} else { // error, should not get extra data
return errAuthExtraData
}
// send confirmation: version 5, no authentication required
_, err = conn.Write([]byte{socksVer5, 0})
return
}
//获取连接请求
func getRequest(conn net.Conn) (rawaddr []byte, host string, err error) {
const (
idVer = 0
idCmd = 1
idType = 3 // address type index
idIP0 = 4 // ip addres start index
idDmLen = 4 // domain address length index
idDm0 = 5 // domain address start index
typeIPv4 = 1 // type is ipv4 address
typeDm = 3 // type is domain address
typeIPv6 = 4 // type is ipv6 address
lenIPv4 = 3 + 1 + net.IPv4len + 2 // 3(ver+cmd+rsv) + 1addrType + ipv4 + 2port
lenIPv6 = 3 + 1 + net.IPv6len + 2 // 3(ver+cmd+rsv) + 1addrType + ipv6 + 2port
lenDmBase = 3 + 1 + 1 + 2 // 3 + 1addrType + 1addrLen + 2port, plus addrLen
)
// refer to getRequest in server.go for why set buffer size to 263
buf := make([]byte, 263)
var n int
ss.SetReadTimeout(conn)
// read till we get possible domain length field
if n, err = io.ReadAtLeast(conn, buf, idDmLen+1); err != nil {
return
}
// check version and cmd
if buf[idVer] != socksVer5 {
err = errVer
return
}
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 {
// common case, do nothing
} else if n < reqLen { // rare case
if _, err = io.ReadFull(conn, buf[n:reqLen]); err != nil {
return
}
} else {
err = errReqExtraData
return
}
rawaddr = buf[idType:reqLen]
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
}
//加密
type ServerCipher struct {
server string
cipher *ss.Cipher
}
var servers struct {
sync.RWMutex
srvCipher []*ServerCipher
failCnt []int // failed connection count
}
//连接到服务器
func connectToServer(serverId int, rawaddr []byte, addr string) (remote *ss.Conn, err error) {
se := servers.srvCipher[serverId]
remote, err = ss.DialWithRawAddr(rawaddr, se.server, se.cipher.Copy())
if err != nil {
log.Println("error connecting to shadowsocks server:", err)
const maxFailCnt = 30
if servers.failCnt[serverId] < maxFailCnt {
servers.failCnt[serverId]++
}
return nil, err
}
log.Printf("connected to %s via %s\n", addr, se.server)
servers.failCnt[serverId] = 0
return
}
//创建服务器连接conn
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++ {
// skip failed server, but try it with some probability
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
}
}
// last resort, try skipped servers, not likely to succeed
for _, i := range skipped {
remote, err = connectToServer(i, rawaddr, addr)
if err == nil {
return
}
}
return nil, err
}
//处理连接
func handleConnection(conn net.Conn, tl *TrafficListener) {
if debug {
log.Printf("socks connect from %s\n", conn.RemoteAddr().String())
}
closed := false
defer func() {
if !closed {
conn.Close()
}
}()
var err error = nil
if err = handShake(conn); err != nil {
log.Println("socks handshake:", err)
return
}
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 {
log.Println("send connection confirmation:", err)
return
}
servers.RLock()
remote, err := createServerConn(rawaddr, addr)
servers.RUnlock()
if err != nil || remote == nil {
if len(servers.srvCipher) > 1 {
log.Println("Failed connect to all avaiable shadowsocks server")
}
return
}
defer func() {
if !closed {
remote.Close()
}
}()
remote.TrafficListener = tl
go ss.PipeThenClose(conn, remote)
ss.PipeThenClose(remote, conn)
closed = true
log.Println("closed connection to", addr)
}
//开启ss入口
func StartSS() {
ln, err := net.Listen("tcp", SocksProxy)
if err != nil {
log.Fatal(err)
}
log.Printf("starting local socks5 server at %v ...\n", SocksProxy)
for {
conn, err := ln.Accept()
if err != nil {
log.Println("accept:", err)
continue
}
go handleConnection(conn, TrafficCounter)
}
}
//设置隧道即配置信息
func SetTunnels(tunnels []*SSTunnel) {
if len(tunnels) == 0 {
return
}
srvCipher := make([]*ServerCipher, len(tunnels))
cipherCache := make(map[string]*ss.Cipher)
for i, tunnel := range tunnels {
cacheKey := tunnel.Method + "|" + tunnel.Password
cipher, ok := cipherCache[cacheKey]
if !ok {
var err error
cipher, err = ss.NewCipher(tunnel.Method, tunnel.Password)
if err != nil {
log.Fatal("Failed generating ciphers:", err)
}
cipherCache[cacheKey] = cipher
}
hostPort := fmt.Sprintf("%s:%s", tunnel.Ip, tunnel.Port)
srvCipher[i] = &ServerCipher{hostPort, cipher}
}
log.Printf("Reset %d tunnels", len(tunnels))
servers.Lock()
servers.srvCipher = srvCipher
servers.failCnt = make([]int, len(servers.srvCipher))
servers.Unlock()
}
//开启http代理
func StartHttpProxy() {
parentProxy, err := url.Parse(fmt.Sprintf("socks5://%s", SocksProxy))
if err != nil {
fatalf("Failed to parse proxy URL: %v\n", err)
}
tbDialer, err := proxy.FromURL(parentProxy, proxy.Direct)
if err != nil {
fatalf("Failed to obtain proxy dialer: %v\n", err)
}
server := goproxy.NewProxyHttpServer()
server.Tr = &http.Transport{Dial: tbDialer.Dial}
log.Printf("start http proxy at: %s", HttpProxy)
err = http.ListenAndServe(HttpProxy, server)
if err != nil {
fatalf("Failed to start http proxy: %v\n", err)
}
}
//创建代理
func MakeProxyClient() *http.Client {
proxyUrl, _ := url.Parse(fmt.Sprintf("http://%s", HttpProxy))
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyUrl)}, Timeout: 10 * time.Second}
}
主业务
主要使用了systray和mux来实现本地web的启动
func main() {
systray.Run(onTrayReady)
}
func onTrayReady() {
// iconBytes should be the []byte content of .ico for windows and .ico/.jpg/.png
// for other platforms.
setrlimit()
systray.SetIcon(GetRes("icon22x22.ico"))
systray.SetTitle("")
systray.SetTooltip("铜蛇")
go StartSS()
go StartHttpProxy()
config, _ := LoadConfig()
SetTunnels(config.GetSSTunnels())
SetPac()
go traceTray()
StartWeb()
}
func tokenRequired(f func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("token")
if err != nil || cookie.Value != Token {
log.Printf("token is not correct, get:%s ", cookie)
res := &JsonResponse{Succeed: false, Data: nil, Message: "Unknown source"}
renderJson(w, res)
} else {
f(w, r)
}
}
}
//开启本地web
func StartWeb() {
Token = RandomString(32)
rtr := mux.NewRouter()
rtr.HandleFunc("/pac", getPac)
rtr.HandleFunc("/set", tokenRequired(set))
rtr.HandleFunc("/settings", tokenRequired(settings))
rtr.HandleFunc("/shadowsocks", tokenRequired(shadowsocks))
rtr.PathPrefix("/").HandlerFunc(static)
http.Handle("/", rtr)
srv := &http.Server{
Handler: rtr,
Addr: GetManagementAddr(),
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Printf("start web at : %s", GetManagementAddr())
log.Fatal(srv.ListenAndServe())
}
剩下就是接口处理了,这里贴一个主要的增删改查
func shadowsocks(w http.ResponseWriter, r *http.Request) {
config, err := LoadConfig()
if err != nil {
}
switch r.Method {
case "POST":
ss := r.FormValue("ss")
log.Printf("Post ss with value: %s", ss)
err = config.AddTunnel(ss)
case "DELETE":
ss := r.URL.Query().Get("ss")
log.Printf("Delete ss: %s", ss)
err = config.DeleteTunnel(ss)
case "PUT":
ss := r.FormValue("ss")
old := r.URL.Query().Get("ss")
err = config.UpdateTunnel(old, ss)
}
log.Printf("ss tunnels count is %d now", len(config.GetSSTunnels()))
SetTunnels(config.GetSSTunnels())
if len(config.GetSSTunnels()) == 0 {
UnsetPac()
}
if r.Method == "POST" && len(config.GetSSTunnels()) == 1 {
SetPac()
}
bt, _ := json.Marshal(config.SSTunnels)
data := (*json.RawMessage)(&bt)
if err == nil {
res := &JsonResponse{Succeed: true, Data: data, Message: ""}
renderJson(w, res)
} else {
res := &JsonResponse{Succeed: false, Data: data, Message: err.Error()}
renderJson(w, res)
}
}
Angular JS
还要有小伙伴的支援 不然html上的标签完全不知道怎么看。。。
这边基本上看到客户端检查下元素就能看到代码了。。
这边贴下angular的处理吧,主要就是发请求到本地和接受本地请求
angular
.module('tsapp', [
'ui.router',
'ngAnimate'
])
.config(function($stateProvider, $urlRouterProvider) {
$urlRouterProvider.otherwise('/settings');
$stateProvider
.state('settings', {
url: '/settings',
templateUrl: 'views/settings.html',
controller: 'SettingsCtrl'
})
.state('about', {
url: '/about',
templateUrl: 'views/about.html'
});
})
.controller('SettingsCtrl', ['$scope', '$http', function($scope, $http) {
$scope.config = {};
$scope.shadowsocks = [];
function reqSS(url, method, data, errDom){
var params = {
method: method,
url: url,
}
if (data) {
params.data = data;
}
if (method != 'GET') {
params.transformRequest = transformReq;
params.headers = {'Content-Type': 'application/x-www-form-urlencoded'}
}
return $http(params).then(
function(res){
console.info(res.data)
if (res.data.ok) {
$scope.shadowsocks = res.data.data;
} else if (errDom) {
errDom.innerText = res.data.message;
errDom.className = errDom.className.split('hide').join(' ')
}
},
function(res){}
);
}
reqSS(apiUrl + '/shadowsocks', "GET")
$scope.ssAction = {
add: function($event){
var tr = ($event.currentTarget || $event.srcElement).closest('tr')
var ipt = tr.querySelectorAll('input')[0];
var errE = tr.querySelectorAll('.error')[0];
var nss = ipt.value;
reqSS(apiUrl + '/shadowsocks', "POST", {ss: nss}, errE).then(function(res){
if (!errE.innerText) {
ipt.value = "";
}
})
},
save: function($event, ss){
var elem = $event.currentTarget || $event.srcElement
var nss = elem.closest('tr').querySelectorAll('input')[0].value;
var tr = elem.closest('tr');
var errE = tr.querySelectorAll('.error')[0];
console.info("new ss is", nss)
if (nss==ss) {
return this.cancel($event);
}
reqSS(apiUrl + '/shadowsocks?ss='+encodeURIComponent(ss), "PUT", {ss: nss}, errE)
},
delete: function(ss){
reqSS(apiUrl + '/shadowsocks?ss='+encodeURIComponent(ss), "DELETE")
},
cancel: function($event){
var elem = $event.currentTarget || $event.srcElement
var tr = elem.closest('tr');
tr.className = tr.className.split('editing').join(' ');
},
edit: function($event){
var elem = $event.currentTarget || $event.srcElement
var trs = elem.closest('table').querySelectorAll('tr.shadowsocks');
var tr = elem.closest('tr');
tr.className += ' editing';
var errE = tr.querySelectorAll('.error')[0];
errE.innerText="";
errE.className +=" hide";
}
}
function set(name, value){
var url = apiUrl + "/set";
var params = {name: name, value: value};
$http({
method: "POST",
url: url,
data: params,
transformRequest: transformReq,
headers: {'Content-Type': 'application/x-www-form-urlencoded'}
}).then(
function(res){
console.info(res.data)
if (res.data.data) {
$scope.config = res.data.data;
}
},
function(res){}
)
}
$scope.setDiyDomains = function(){
var dd = document.getElementsByName('diy_domains')[0]
set("diy_domains", dd.value)
}
$scope.toggle = function(name){
var ele = document.getElementsByName(name)[0]
var value = ele.checked?'on':'off';
set(name, value);
}
$http({
method: "GET",
url: apiUrl + "/settings"
}).then(
function(res){
console.info(res.data)
if (res.data.data) {
$scope.config = res.data.data;
}
},
function(res){}
);
}])
;
编译
可以直接使用作者的make
//mac 版
GOOS=darwin GOARCH=amd64 go build -o ./bin/niuniu.darwin.amd64 -v .
//win 64位版 隐藏cmd
GOOS=windows GOARCH=amd64 go build -o ./bin/niuniu64.exe -ldflags -H=windowsgui -v .
和作者同样。没有环境,没有编译linux。。。理论上是可以直接运行。
Tips&工具
Mac app手动签名
由于我们go build 出来后是一个终端执行文件,打包成App最好是做代码签名
首先需要做成App,这个我就直接偷工减料,因为知道蓝灯也是用go写的,直接复制了蓝灯的APP点开内容 除了代码签名其他全部copy到一个空的文件夹里,再修改info.plist一些简单的内容,直接将整个文件夹命名成.app就行
接下来就是签名,我就不啰嗦了。。
推荐看这篇制作DMG
测试版更新很快 要发给小伙伴测试,还是找了个DMG的制作工具DMG Canvas,很方便,省去制作镜像的麻烦给Win版的exe文件做个图标
go build出来的win程序也是只有一个 exe文件 而且是个很丑的cmd默认图标
找了半天只mac下没找到什么好的解决方法,还是要到win上操作
懒得复制了,看这篇
win上需要安装gcc 才行 使用gcc -v 有版本信息就能用了
最后
由于还是有些bug要测 暂时还是先不放出win版了
有需要的就支持下呗牛牛数据 有折扣哦~
基本没啥人用的Mac App Store版
畅销的牛牛数据iOS版本
有疑问加站长微信联系(非本文作者)