基于55-go的二次开发GUI版本Mac&Win&Linux

gwk_iOS · · 1417 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

写在前面

前段时间把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没有进行编译与测试)

GUI
GUI

与大佬对话
与大佬对话

But!!!仔细一看这是一个本地的web程序。对于连H5、CSS、JS看着都一头雾水的我,加上一无所知的golang基础,简直是天大的考验。。。


鬼知道我是怎么写出来的。。。
下面这是我的修改版,用上了基佬紫

状态栏
状态栏
设置中心
设置中心
tips
tips

当然,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版本

iOS版本
iOS版本

有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:gwk_iOS

查看原文:基于55-go的二次开发GUI版本Mac&Win&Linux

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1417 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传