ngrok原理浅析

3 Comments

之前在进行微信Demo开发时曾用到过ngrok这个强大的tunnel(隧道)工具,ngrok在其github官方页面上的自我诠释是 “introspected tunnels to localhost",这个诠释有两层含义:
1、可以用来建立public到localhost的tunnel,让居于内网主机上的服务可以暴露给public,俗称内网穿透。
2、支持对隧道中数据的introspection(内省),支持可视化的观察隧道内数据,并replay(重放)相关请求(诸如http请 求)。

因此ngrok可以很便捷的协助进行服务端程序调试,尤其在进行一些Web server开发中。ngrok更强大的一点是它支持tcp层之上的所有应用协议或者说与应用层协议无关。比如:你可以通过ngrok实现ssh登录到内 网主 机,也可以通过ngrok实现远程桌面(VNC)方式访问内网主机。

今天我们就来简单分析一下这款强大工具的实现原理。ngrok本身是用go语言实现的,需要go 1.1以上版本编译。ngrok官方代码最新版为1.7,作者似乎已经完成了ngrok 2.0版本,但不知为何迟迟不放出最新代码。因此这里我们就以ngrok 1.7版本源码作为原理分析的基础。

一、ngrok tunnel与ngrok部署

网络tunnel(隧道)对多数人都是很”神秘“的概念,tunnel种类很多,没有标准定义,我了解的也不多(日常工作较少涉及),这里也就不 深入了。在《HTTP权威指南》中有关于HTTP tunnel(http上承载非web流量)和SSL tunnel的说明,但ngrok中的tunnel又与这些有所不同。

ngrok实现了一个tcp之上的端到端的tunnel,两端的程序在ngrok实现的Tunnel内透明的进行数据交互。

ngrok分为client端(ngrok)和服务端(ngrokd),实际使用中的部署如下:

内网服务程序可以与ngrok client部署在同一主机,也可以部署在内网可达的其他主机上。ngrok和ngrokd会为建立与public client间的专用通道(tunnel)。

二、ngrok开发调试环境搭建

在学习ngrok代码或试验ngrok功能的时候,我们可能需要搭建一个ngrok的开发调试环境。ngrok作者在ngrok developer guide中给出了步骤:

$> git clone https://github.com/inconshreveable/ngrok
$> cd ngrok
$> make client
$> make server

make client和make server执行后,会建构出ngrok和ngrokd的debug版本。如果要得到release版本,请使用make release-clientmake release-server。debug版本与release版本的区别在于debug版本不打包 assets下的资源文件,执行时通过文件系统访问。

修改/etc/hosts文件,添加两行:

127.0.0.1 ngrok.me
127.0.0.1 test.ngrok.me

创建客户端配置文件debug.yml:

server_addr: ngrok.me:4443
trust_host_root_certs: false
tunnels:
      test:
        proto:
           http: 8080

不过要想让ngrok与ngrokd顺利建立通信,我们还得制作数字证书(自签发),源码中自带的证书是无法使用的,证书制作方法可参见《搭建自 己的ngrok服务》一文,相关原理可参考《Go和HTTPS》一文,这里就不赘述了。

我直接使用的是release版本(放在bin/release下),这样在执行命令时可以少传入几个参数:

启动服务端:
$> sudo ./bin/release/ngrokd -domain ngrok.me
[05/13/15 17:15:37] [INFO] Listening for public http connections on [::]:80
[05/13/15 17:15:37] [INFO] Listening for public https connections on [::]:443
[05/13/15 17:15:37] [INFO] Listening for control and proxy connections on [::]:4443

启动客户端:
$> ./bin/release/ngrok -config=debug.yml -log=ngrok.log -subdomain=test 8080

有了调试环境,我们就可以通过debug日志验证我们的分析了。

ngrok的源码结构如下:

drwxr-xr-x   3 tony  staff  102  3 31 16:09 cache/
drwxr-xr-x  16 tony  staff  544  5 13 17:21 client/
drwxr-xr-x   4 tony  staff  136  5 13 15:02 conn/
drwxr-xr-x   3 tony  staff  102  3 31 16:09 log/
drwxr-xr-x   4 tony  staff  136  3 31 16:09 main/
drwxr-xr-x   5 tony  staff  170  5 12 16:17 msg/
drwxr-xr-x   5 tony  staff  170  3 31 16:09 proto/
drwxr-xr-x  11 tony  staff  374  5 13 17:21 server/
drwxr-xr-x   7 tony  staff  238  3 31 16:09 util/
drwxr-xr-x   3 tony  staff  102  3 31 16:09 version/

main目录下的ngrok/和ngrokd/分别是ngrok和ngrokd main包,main函数存放的位置,但这里仅仅是一个stub。以ngrok为例:

// ngrok/src/ngrok/main/ngrok/ngrok.go
package main

import (
    "ngrok/client"
)

func main() {
    client.Main()
}

真正的“main”被client包的Main函数实现。

client/和server/目录分别对应ngrok和ngrokd的主要逻辑,其他目录(或包)都是一些工具类的实现。

三、第一阶段:Control Connection建立

在ngrokd的启动日志中我们可以看到这样一行:

[INFO] Listening for control and proxy connections on [::]:4443

ngrokd在4443端口(默认)监听control和proxy connection。Control Connection,顾名思义“控制连接”,有些类似于FTP协议的控制连接(不知道ngrok作者在设计协议时是否参考了FTP协议^_^)。该连接 只用于收发控制类消息。作为客户端的ngrok启动后的第一件事就是与ngrokd建立Control Connection,建立过程序列图如下:

前面提到过,ngrok客户端的实际entrypoint在ngrok/src/ngrok/client目录下,包名client,实际入口是 client.Main函数。

//ngrok/src/ngrok/client/main.go
func Main() {
    // parse options
    // set up logging
    // read configuration file
    …. …
    NewController().Run(config)
}

ngrok采用了MVC模式构架代码,这既包括ngrok与ngrokd之间的逻辑处理,也包括ngrok本地web页面(用于隧道数据的 introspection)的处理。

//ngrok/src/ngrok/client/controller.go
func (ctl *Controller) Run(config *Configuration) {

    var model *ClientModel

    if ctl.model == nil {
        model = ctl.SetupModel(config)
    } else {
        model = ctl.model.(*ClientModel)
    }
    // init the model
    // init web ui
    // init term ui
   … …
   ctl.Go(ctl.model.Run)
   … …
  
}

我们来继续看看model.Run都做了些什么。

//ngrok/src/ngrok/client/model.go
func (c *ClientModel) Run() {
    … …

    for {
        // run the control channel
        c.control()
        … …
        if c.connStatus == mvc.ConnOnline {
            wait = 1 * time.Second
        }

        … …
        c.connStatus = mvc.ConnReconnecting
        c.update()
    }
}

Run函数调用c.control来运行Control Connection的主逻辑,并在control connection断开后,尝试重连。

c.control是ClientModel的一个method,用来真正建立ngrok到ngrokd的control connection,并完成基于ngrok的鉴权(用户名、密码配置在配置文件中)。

//ngrok/src/ngrok/client/model.go
func (c *ClientModel) control() {
    … …
    var (
        ctlConn conn.Conn
        err     error
    )
    if c.proxyUrl == "" {
        // simple non-proxied case, just connect to the server
        ctlConn, err = conn.Dial(c.serverAddr, "ctl", c.tlsConfig)
    } else {……}
    … …

    // authenticate with the server
    auth := &msg.Auth{
        ClientId:  c.id,
        OS:        runtime.GOOS,
        Arch:      runtime.GOARCH,
        Version:   version.Proto,
        MmVersion: version.MajorMinor(),
        User:      c.authToken,
    }

    if err = msg.WriteMsg(ctlConn, auth); err != nil {
        panic(err)
    }

    // wait for the server to authenticate us
    var authResp msg.AuthResp
    if err = msg.ReadMsgInto(ctlConn, &authResp); err != nil {
        panic(err)
    }

    … …

    c.id = authResp.ClientId
    … ..
}

ngrok封装了connection相关操作,代码在ngrok/src/ngrok/conn下面,包名conn。

//ngrok/src/ngrok/conn/conn.go
func Dial(addr, typ string, tlsCfg *tls.Config) (conn *loggedConn, err error) {
    var rawConn net.Conn
    if rawConn, err = net.Dial("tcp", addr); err != nil {
        return
    }

    conn = wrapConn(rawConn, typ)
    conn.Debug("New connection to: %v", rawConn.RemoteAddr())

    if tlsCfg != nil {
        conn.StartTLS(tlsCfg)
    }

    return
}

ngrok首先创建一条TCP连接,并基于该连接创建了TLS client:

func (c *loggedConn) StartTLS(tlsCfg *tls.Config) {
    c.Conn = tls.Client(c.Conn, tlsCfg)
}

不过此时并未进行TLS的初始化,即handshake。handshake发生在ngrok首次向ngrokd发送auth消息(msg.WriteMsg, ngrok/src/ngrok/msg/msg.go)时,go标准库的TLS相关函数默默的完成这一handshake过程。我们经常遇到的ngrok证书验证失败等问题,就发生在该过程中。

在AuthResp中,ngrokd为该Control Connection分配一个ClientID,该ClientID在后续Proxy Connection建立时使用,用于关联和校验之用。

前面的逻辑和代码都是ngrok客户端的,现在我们再从ngrokd server端代码review一遍Control Connection的建立过程。

ngrokd的代码放在ngrok/src/ngrok/server下面,entrypoint如下:

//ngrok/src/ngrok/server/main.go
func Main() {
    // parse options
    opts = parseArgs()
    // init logging
    // init tunnel/control registry
    … …
    // start listeners
    listeners = make(map[string]*conn.Listener)

    // load tls configuration
    tlsConfig, err := LoadTLSConfig(opts.tlsCrt, opts.tlsKey)
    if err != nil {
        panic(err)
    }
    // listen for http
    // listen for https
    … …

    // ngrok clients
    tunnelListener(opts.tunnelAddr, tlsConfig)
}

ngrokd启动了三个监听,其中最后一个tunnelListenner用于监听ngrok发起的Control Connection或者后续的proxy connection,作者意图通过一个端口,监听两种类型连接,旨在于方便部署。

//ngrok/src/ngrok/server/main.go
func tunnelListener(addr string, tlsConfig *tls.Config) {
    // listen for incoming connections
    listener, err := conn.Listen(addr, "tun", tlsConfig)
    … …

    for c := range listener.Conns {
        go func(tunnelConn conn.Conn) {
            … …
            var rawMsg msg.Message
            if rawMsg, err = msg.ReadMsg(tunnelConn); err != nil {
                tunnelConn.Warn("Failed to read message: %v", err)
                tunnelConn.Close()
                return
            }
            … …
            switch m := rawMsg.(type) {
            case *msg.Auth:
                NewControl(tunnelConn, m)
            … …
            }
        }(c)
    }
}

从tunnelListener可以看到,当ngrokd在新建立的Control Connection上收到Auth消息后,ngrokd执行NewControl来处理该Control Connection上的后续事情。

//ngrok/src/ngrok/server/control.go
func NewControl(ctlConn conn.Conn, authMsg *msg.Auth) {
    var err error

    // create the object
    c := &Control{
        … …
    }

    // register the clientid
    … …
    // register the control
    … …

    // start the writer first so that
    // the following messages get sent
    go c.writer()

    // Respond to authentication
    c.out <- &msg.AuthResp{
        Version:   version.Proto,
        MmVersion: version.MajorMinor(),
        ClientId:  c.id,
    }

    // As a performance optimization,
    // ask for a proxy connection up front
    c.out <- &msg.ReqProxy{}

    // manage the connection
    go c.manager()
    go c.reader()
    go c.stopper()
}

在NewControl中,ngrokd返回了AuthResp。到这里,一条新的Control Connection建立完毕。

我们最后再来看一下Control Connection建立过程时ngrok和ngrokd的输出日志,增强一下感性认知:

ngrok Server:

[INFO] [tun:d866234] New connection from 127.0.0.1:59949
[DEBG] [tun:d866234] Waiting to read message
[DEBG] [tun:d866234] Reading message with length: 126
[DEBG] [tun:d866234] Read message {"Type":"Auth",
"Payload":{"Version":"2","MmVersion":"1.7","User":"","Password":"","OS":"darwin","Arch":"amd64","ClientId":""}}
[INFO] [ctl:d866234] Renamed connection tun:d866234
[INFO] [registry] [ctl] Registered control with id ac1d14e0634f243f8a0cc2306bb466af
[DEBG] [ctl:d866234] [ac1d14e0634f243f8a0cc2306bb466af] Writing message: {"Type":"AuthResp","Payload":{"Version":"2","MmVersion":"1.7","ClientId":"ac1d14e0634f243f8a0cc2306bb466af","Error":""}}

Client:

[INFO] (ngrok/log.Info:112) Reading configuration file debug.yml
[INFO] (ngrok/log.(*PrefixLogger).Info:83) [client] Trusting root CAs: [assets/client/tls/ngrokroot.crt]
[INFO] (ngrok/log.(*PrefixLogger).Info:83) [view] [web] Serving web interface on 127.0.0.1:4040
[INFO] (ngrok/log.Info:112) Checking for update
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [view] [term] Waiting for update
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] New connection to: 127.0.0.1:4443
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Writing message: {"Type":"Auth","Payload":{"Version":"2","MmVersion":"1.7","User":"","Password":"","OS":"darwin","Arch":"amd64","ClientId":""}}
[DEBG] (ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Waiting to read message
(ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Reading message with length: 120
(ngrok/log.(*PrefixLogger).Debug:79) [ctl:31deb681] Read message {"Type":"AuthResp","Payload":{"Version":"2","MmVersion":"1.7","ClientId":"ac1d14e0634f243f8a0cc2306bb466af","Error":""}}
[INFO] (ngrok/log.(*PrefixLogger).Info:83) [client] Authenticated with server, client id: ac1d14e0634f243f8a0cc2306bb466af

四、Tunnel Creation

Tunnel Creation是ngrok将配置文件中的tunnel信息通过刚刚建立的Control Connection传输给 ngrokd,ngrokd登记、启动相应端口监听(如果配置了remote_port或多路复用ngrokd默认监听的http和https端口)并返回相应应答。ngrok和ngrokd之间并未真正建立新连接。

我们回到ngrok的model.go,继续看ClientModel的control方法。在收到AuthResp后,ngrok还做了如下事情:

//ngrok/src/ngrok/client/model.go
 
   // request tunnels
    reqIdToTunnelConfig := make(map[string]*TunnelConfiguration)
    for _, config := range c.tunnelConfig {
        // create the protocol list to ask for
        var protocols []string
        for proto, _ := range config.Protocols {
            protocols = append(protocols, proto)
        }

        reqTunnel := &msg.ReqTunnel{
            … …
        }

        // send the tunnel request
        if err = msg.WriteMsg(ctlConn, reqTunnel); err != nil {
            panic(err)
        }

        // save request id association so we know which local address
        // to proxy to later
        reqIdToTunnelConfig[reqTunnel.ReqId] = config
    }

    // main control loop
    for {
        var rawMsg msg.Message
       
        switch m := rawMsg.(type) {
        … …
        case *msg.NewTunnel:
            … …

            tunnel := mvc.Tunnel{
                … …
            }

            c.tunnels[tunnel.PublicUrl] = tunnel
            c.connStatus = mvc.ConnOnline
           
            c.update()
        … …
        }
    }

ngrok将配置的Tunnel信息逐一以ReqTunnel消息发送给ngrokd以注册登记Tunnel,并在随后的main control loop中处理ngrokd回送的NewTunnel消息,完成一些登记索引工作。

ngrokd Server端对tunnel creation的处理是在NewControl的结尾处:

//ngrok/src/ngrok/server/control.go
func NewControl(ctlConn conn.Conn, authMsg *msg.Auth) {
    … …
    // manage the connection
    go c.manager()
    … …
}

func (c *Control) manager() {
    //… …

    for {
        select {
        case <-reap.C:
            … …

        case mRaw, ok := <-c.in:
            // c.in closes to indicate shutdown
            if !ok {
                return
            }

            switch m := mRaw.(type) {
            case *msg.ReqTunnel:
                c.registerTunnel(m)

            .. …
            }
        }
    }
}

Control的manager在收到ngrok发来的ReqTunnel消息后,调用registerTunnel进行处理。

// ngrok/src/ngrok/server/control.go
// Register a new tunnel on this control connection
func (c *Control) registerTunnel(rawTunnelReq *msg.ReqTunnel) {
    for _, proto := range strings.Split(rawTunnelReq.Protocol, "+") {
        tunnelReq := *rawTunnelReq
        tunnelReq.Protocol = proto

        c.conn.Debug("Registering new tunnel")
        t, err := NewTunnel(&tunnelReq, c)
        if err != nil {
            c.out <- &msg.NewTunnel{Error: err.Error()}
            if len(c.tunnels) == 0 {
                c.shutdown.Begin()
            }

            // we're done
            return
        }

        // add it to the list of tunnels
        c.tunnels = append(c.tunnels, t)

        // acknowledge success
        c.out <- &msg.NewTunnel{
            Url:      t.url,
            Protocol: proto,
            ReqId:    rawTunnelReq.ReqId,
        }

        rawTunnelReq.Hostname = strings.Replace(t.url, proto+"://", "", 1)
    }
}

Server端创建tunnel的实际工作由NewTunnel完成:

// ngrok/src/ngrok/server/tunnel.go
func NewTunnel(m *msg.ReqTunnel, ctl *Control) (t *Tunnel, err error) {
    t = &Tunnel{
      … …
    }

    proto := t.req.Protocol
    switch proto {
    case "tcp":
        bindTcp := func(port int) error {
            if t.listener, err = net.ListenTCP("tcp",
               &net.TCPAddr{IP: net.ParseIP("0.0.0.0"),
               Port: port}); err != nil {
                … …
                return err
            }

            // create the url
            addr := t.listener.Addr().(*net.TCPAddr)
            t.url = fmt.Sprintf("tcp://%s:%d", opts.domain, addr.Port)

            // register it
            if err = tunnelRegistry.RegisterAndCache(t.url, t);
               err != nil {
                … …
                return err
            }

            go t.listenTcp(t.listener)
            return nil
        }

        // use the custom remote port you asked for
        if t.req.RemotePort != 0 {
            bindTcp(int(t.req.RemotePort))
            return
        }
        // try to return to you the same port you had before
        cachedUrl := tunnelRegistry.GetCachedRegistration(t)
        if cachedUrl != "" {
            … …
        }

        // Bind for TCP connections
        bindTcp(0)
        return

    case "http", "https":
        l, ok := listeners[proto]
        if !ok {
            … …
            return
        }

        if err = registerVhost(t, proto, l.Addr.(*net.TCPAddr).Port);
           err != nil {
            return
        }

    default:
        err = fmt.Errorf("Protocol %s is not supported", proto)
        return
    }

    … …

    metrics.OpenTunnel(t)
    return
}

可以看出,NewTunnel区别对待tcp和http/https隧道:

- 对于Tcp隧道,NewTunnel先要看是否配置了remote_port,如果remote_port不为空,则启动监听这个 remote_port。否则尝试从cache里找出你之前创建tunnel时使用的端口号,如果可用,则监听这个端口号,否则bindTcp(0),即 随机选择一个端口作为该tcp tunnel的remote_port。

- 对于http/https隧道,ngrokd启动时就默认监听了80和443,如果ngrok请求建立http/https隧道(目前不支持设置remote_port),则ngrokd通过一种自实现的vhost的机制实现所有http/https请求多路复用到80和443端口上。ngrokd不会新增监听端口。

从下面例子,我们也可以看出一些端倪。我们将debug.yml改为:

server_addr: ngrok.me:4443
trust_host_root_certs: false
tunnels:
      test:
        proto:
           http: 8080
      test1:
        proto:
           http: 8081
      ssh1:
        remote_port: 50000
        proto:
            tcp: 22
      ssh2:
        proto:
            tcp: 22

启动ngrok:

$./bin/release/ngrok -config=debug.yml -log=ngrok.log start test test1  ssh1 ssh2

Tunnel Status                 online
Version                       1.7/1.7
Forwarding                    tcp://ngrok.me:50000 -> 127.0.0.1:22
Forwarding                    tcp://ngrok.me:56297 -> 127.0.0.1:22
Forwarding                    http://test.ngrok.me -> 127.0.0.1:8080
Forwarding                    http://test1.ngrok.me -> 127.0.0.1:8081
Web Interface                 127.0.0.1:4040

可以看出ngrokd为ssh2随机挑选了一个端口56297进行了监听,而两个http隧道,则都默认使用了80端口。

如果像下面这样配置会发生什么呢?

      ssh1:
        remote_port: 50000
        proto:
            tcp: 22
      ssh2:
        remote_port: 50000
        proto:
            tcp: 22

ngrok启动会得到错误信息:
Server failed to allocate tunnel: [ctl:5332a293] [a87bd111bcc804508c835714c18a5664] Error binding TCP listener: listen tcp 0.0.0.0:50000: bind: address already in use

客户端ngrok在ClientModel control方法的main control loop中收到NewTunnel并处理该消息:

    case *msg.NewTunnel:
            if m.Error != "" {
                … …
            }

            tunnel := mvc.Tunnel{
                PublicUrl: m.Url,
                LocalAddr: reqIdToTunnelConfig[m.ReqId].Protocols[m.Protocol],
                Protocol:  c.protoMap[m.Protocol],
            }

            c.tunnels[tunnel.PublicUrl] = tunnel
            c.connStatus = mvc.ConnOnline
            c.Info("Tunnel established at %v", tunnel.PublicUrl)
            c.update()

五、Proxy Connection和Private Connection

到目前为止,我们知道了Control Connection:用于ngrok和ngrokd之间传输命令;Public Connection:外部发起的,尝试向内网服务建立的链接。

这节当中,我们要接触到Proxy Connection和Private Connection。

Proxy Connection以及Private Connection的建立过程如下:

前面ngrok和ngrokd的交互进行到了NewTunnel,这些数据都是通过之前已经建立的Control Connection上传输的。

ngrokd侧,NewControl方法的结尾有这样一行代码:

    // As a performance optimization, ask for a proxy connection up front
    c.out <- &msg.ReqProxy{}

服务端ngrokd在Control Connection上向ngrok发送了"ReqProxy"的消息,意为请求ngrok向ngrokd建立一条Proxy Connection,该链接将作为隧道数据流的承载者。

客户端ngrok在ClientModel control方法的main control loop中收到ReqProxy并处理该消息:

case *msg.ReqProxy:
            c.ctl.Go(c.proxy)

// Establishes and manages a tunnel proxy connection with the server
func (c *ClientModel) proxy() {
    if c.proxyUrl == "" {
        remoteConn, err = conn.Dial(c.serverAddr, "pxy", c.tlsConfig)
    }……

    err = msg.WriteMsg(remoteConn, &msg.RegProxy{ClientId: c.id})
    if err != nil {
        remoteConn.Error("Failed to write RegProxy: %v", err)
        return
    }
    … …
}

ngrok客户端收到ReqProxy后,创建一条新连接到ngrokd,该连接即为Proxy Connection。并且ngrok将RegProxy消息通过该新建立的Proxy Connection发到ngrokd,以便ngrokd将该Proxy Connection与对应的Control Connection以及tunnel关联在一起。

// ngrok服务端
func tunnelListener(addr string, tlsConfig *tls.Config) {
    …. …
    case *msg.RegProxy:
                NewProxy(tunnelConn, m)
    … …
}

到目前为止, tunnel、Proxy Connection都已经建立了,万事俱备,就等待Public发起Public connection到ngrokd了。

下面我们以Public发起一个http连接到ngrokd为例,比如我们通过curl 命令,向test.ngrok.me发起一次http请求。

前面说过,ngrokd在启动时默认启动了80和443端口的监听,并且与其他http/https隧道共同多路复用该端口(通过vhost机制)。ngrokd server对80端口的处理代码如下:

// ngrok/src/ngrok/server/main.go
func Main() {
    … …
 // listen for http
    if opts.httpAddr != "" {
        listeners["http"] =
          startHttpListener(opts.httpAddr, nil)
    }

    … …
}

startHttpListener针对每个连接,启动一个goroutine专门处理:

//ngrok/src/ngrok/server/http.go
func startHttpListener(addr string,
    tlsCfg *tls.Config) (listener *conn.Listener) {
    // bind/listen for incoming connections
    var err error
    if listener, err = conn.Listen(addr, "pub", tlsCfg);
        err != nil {
        panic(err)
    }

    proto := "http"
    if tlsCfg != nil {
        proto = "https"
    }

   … …
    go func() {
        for conn := range listener.Conns {
            go httpHandler(conn, proto)
        }
    }()

    return
}

// Handles a new http connection from the public internet
func httpHandler(c conn.Conn, proto string) {
    … …
    // let the tunnel handle the connection now
    tunnel.HandlePublicConnection(c)
}

我们终于看到server端处理public connection的真正方法了:

//ngrok/src/ngrok/server/tunnel.go
func (t *Tunnel) HandlePublicConnection(publicConn conn.Conn) {
    … …
    var proxyConn conn.Conn
    var err error
    for i := 0; i < (2 * proxyMaxPoolSize); i++ {
        // get a proxy connection
        if proxyConn, err = t.ctl.GetProxy();
           err != nil {
            … …
        }
        defer proxyConn.Close()
       … …

        // tell the client we're going to
        // start using this proxy connection
        startPxyMsg := &msg.StartProxy{
            Url:        t.url,
            ClientAddr: publicConn.RemoteAddr().String(),
        }

        if err = msg.WriteMsg(proxyConn, startPxyMsg);
            err != nil {
           … …
        }
    }

    … …
    // join the public and proxy connections
    bytesIn, bytesOut := conn.Join(publicConn, proxyConn)
    …. …
}

HandlePublicConnection通过选出的Proxy connection向ngrok client发送StartProxy信息,告知ngrok proxy启动。然后通过conn.Join方法将publicConn和proxyConn关联到一起。

// ngrok/src/ngrok/conn/conn.go
func Join(c Conn, c2 Conn) (int64, int64) {
    var wait sync.WaitGroup

    pipe := func(to Conn, from Conn, bytesCopied *int64) {
        defer to.Close()
        defer from.Close()
        defer wait.Done()

        var err error
        *bytesCopied, err = io.Copy(to, from)
        if err != nil {
            from.Warn("Copied %d bytes to %s before failing with error %v", *bytesCopied, to.Id(), err)
        } else {
            from.Debug("Copied %d bytes to %s", *bytesCopied, to.Id())
        }
    }

    wait.Add(2)
    var fromBytes, toBytes int64
    go pipe(c, c2, &fromBytes)
    go pipe(c2, c, &toBytes)
    c.Info("Joined with connection %s", c2.Id())
    wait.Wait()
    return fromBytes, toBytes
}

Join通过io.Copy实现public conn和proxy conn数据流的转发,单向被称作一个pipe,Join建立了两个Pipe,实现了双向转发,每个Pipe直到一方返回EOF或异常失败才会退出。后续在ngrok端,proxy conn和private conn也是通过conn.Join关联到一起的。

我们现在就来看看ngrok在收到StartProxy消息后是如何处理的。我们回到ClientModel的proxy方法中。在向ngrokd成功建立proxy connection后,ngrok等待ngrokd的StartProxy指令。

    // wait for the server to ack our register
    var startPxy msg.StartProxy
    if err = msg.ReadMsgInto(remoteConn, &startPxy);
             err != nil {
        remoteConn.Error("Server failed to write StartProxy: %v",
                   err)
        return
    }

一旦收到StartProxy,ngrok将建立一条private connection:
    // start up the private connection
    start := time.Now()
    localConn, err := conn.Dial(tunnel.LocalAddr, "prv", nil)
    if err != nil {
       … …
        return
    }
并将private connection和proxy connection通过conn.Join关联在一起,实现数据透明转发。

    m.connTimer.Time(func() {
        localConn := tunnel.Protocol.WrapConn(localConn,
             mvc.ConnectionContext{Tunnel: tunnel,
              ClientAddr: startPxy.ClientAddr})
        bytesIn, bytesOut := conn.Join(localConn, remoteConn)
        m.bytesIn.Update(bytesIn)
        m.bytesOut.Update(bytesOut)
        m.bytesInCount.Inc(bytesIn)
        m.bytesOutCount.Inc(bytesOut)
    })

这样一来,public connection上的数据通过proxy connection到达ngrok,ngrok再通过private connection将数据转发给本地启动的服务程序,从而实现所谓的内网穿透。从public视角来看,就像是与内网中的那个服务直接交互一样。

Go和HTTPS

5 Comments

近期在构思一个产品,考虑到安全性的原因,可能需要使用到HTTPS协议以及双向数字证书校验。之前只是粗浅接触过HTTP(使用Golang开 发微信系列)。对HTTPS的了解则始于那次自行搭建ngrok服务,在那个过程中照猫画虎地为服务端生成了一些私钥和证书,虽然结果是好 的:ngrok服务成功搭建起来了,但对HTTPS、数字证书等的基本原理并未求甚解。于是想趁这次的机会,对HTTPS做一些深度挖掘。主要途 径:翻阅网上资料、书籍,并利用golang编写一些实验examples。

一、HTTPS简介

日常生活中,我们上网用的最多的应用层协议就是HTTP协议了,直至目前全世界的网站中大多数依然只支持HTTP访问。

使用Go创建一个HTTP Server十分Easy,十几行代码就能搞定:

//gohttps/1-http/server.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w,
     "Hi, This is an example of http service in golang!")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

执行这段代码:
$ go run server.go

打开浏览器,在地址栏输入"http://localhost:8080", 你会看到“ Hi, This is an example of http service in golang!"输出到浏览器窗口。

不过HTTP毕竟是明文的,在这样一个不安全的世界里,随时存在着窃听(sniffer工具可以简单办到)、篡改甚至是冒充等风险,因此对于一些 对安全比较care的站点或服务,它们需要一种安全的HTTP协议,于是就有了HTTPS。

HTTPS只是我们在浏览器地址栏中看到协议标识,实际上它可以被理解为运行在SSL(Secure Sockets Layer)或TLS(Transport Layer Security)协议所构建的安全层之上的HTTP协议,协议的传输安全性以及内容完整性实际上是由SSL或TLS保证的。

关于HTTPS协议原理的详细说明,没有个百八十页是搞不定的,后续我会在各个实验之前将相关的原理先作一些说明,整体原理这里就不赘述了。有兴 趣的朋友可以参考以下资料:
1、《HTTP权威指南》第十四章
2、《图解HTTP》第七章
3、阮一峰老师的两篇博文“SSL/TLS协议运行机制的概述"和"图解SSL/TLS协议"。

二、实现一个最简单的HTTPS Web Server

Golang的标准库net/http提供了https server的基本实现,我们修改两行代码就能将上面的HTTP Server改为一个HTTPS Web Server:

// gohttps/2-https/server.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w,
        "Hi, This is an example of https service in golang!")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServeTLS(":8081", "server.crt",
                           "server.key", nil)
}

我们用http.ListenAndServeTLS替换掉了http.ListenAndServe,就将一个HTTP Server转换为HTTPS Web Server了。不过ListenAndServeTLS 新增了两个参数certFile和keyFile,需要我们传入两个文件路径。到这里,我们不得不再学习一点HTTPS协议的原理了。不过为 了让这个例子能先Run起来,我们先执行下面命令,利用openssl生成server.crt和server.key文件,供程序使用,原 理后续详述:

$openssl genrsa -out server.key 2048

Generating RSA private key, 2048 bit long modulus
…………….+++
……………+++
e is 65537 (0×10001)

$openssl req -new -x509 -key server.key -out server.crt -days 365

You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
—–
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:localhost
Email Address []:

执行程序:go run server.go
通过浏览器访问:https://localhost:8081,chrome浏览器会显示如下画面:

忽略继续后,才能看到"Hi, This is an example of https service in golang!"这个结果输出在窗口上。

也可以使用curl工具验证这个HTTPS server:

curl -k https://localhost:8081
Hi, This is an example of http service in golang!

注意如果不加-k,curl会报如下错误:

$curl https://localhost:8081
curl: (60) SSL certificate problem: Invalid certificate chain
More details here: http://curl.haxx.se/docs/sslcerts.html

curl performs SSL certificate verification by default, using a "bundle"
 of Certificate Authority (CA) public keys (CA certs). If the default
 bundle file isn't adequate, you can specify an alternate file
 using the –cacert option.
If this HTTPS server uses a certificate signed by a CA represented in
 the bundle, the certificate verification probably failed due to a
 problem with the certificate (it might be expired, or the name might
 not match the domain name in the URL).
If you'd like to turn off curl's verification of the certificate, use
 the -k (or –insecure) option.

三、非对称加密和数字证书

前面说过,HTTPS的数据传输是加密的。实际使用中,HTTPS利用的是对称与非对称加密算法结合的方式。

对称加密,就是通信双方使用一个密钥,该密钥既用于数据加密(发送方),也用于数据解密(接收方)。
非对称加密,使用两个密钥。发送方使用公钥(公开密钥)对数据进行加密,数据接收方使用私钥对数据进行解密。

实际操作中,单纯使用对称加密或单纯使用非对称加密都会存在一些问题,比如对称加密的密钥管理复杂;非对称加密的处理性能低、资源占用高等,因 此HTTPS结合了这两种方式。

HTTPS服务端在连接建立过程(ssl shaking握手协议)中,会将自身的公钥发送给客户端。客户端拿到公钥后,与服务端协商数据传输通道的对称加密密钥-对话密钥,随后的这个协商过程则 是基于非对称加密的(因为这时客户端已经拿到了公钥,而服务端有私钥)。一旦双方协商出对话密钥,则后续的数据通讯就会一直使用基于该对话密 钥的对称加密算法了。

上述过程有一个问题,那就是双方握手过程中,如何保障HTTPS服务端发送给客户端的公钥信息没有被篡改呢?实际应用中,HTTPS并非直接 传输公钥信息,而是使用携带公钥信息的数字证书来保证公钥的安全性和完整性。

数字证书,又称互联网上的"身份证",用于唯一标识一个组织或一个服务器的,这就好比我们日常生活中使用的"居民身份证",用于唯一标识一个 人。服务端将数字证书传输给客户端,客户端如何校验这个证书的真伪呢?我们知道居民身份证是由国家统一制作和颁发的,个人向户 口所在地公安机关申请,国家颁发的身份证才具有法律 效力,任何地方这个身份证都是有效和可被接纳的。大悦城的会员卡也是一种身份标识,但你若用大悦城的会员卡去买机票,对不起, 不卖。航空公司可不认大悦城的会员卡,只认居民身份证。网站的证书也是同样的道理。一般来说数字证书从受信的权威证书授权机构 (Certification Authority,证书授权机构)买来的(免费的很少)。一般浏览器在出厂时就内置了诸多知名CA(如Verisign、GoDaddy、美国国防部、 CNNIC等)的数字证书校验方法,只要是这些CA机构颁发的证书,浏览器都能校验。对于CA未知的证书,浏览器则会报错(就像上面那个截图一 样)。主流浏览器都有证书管理功能,但鉴于这些功能比较高级,一般用户是不用去关心的。

初步原理先讲到这,我们再回到上面的例子。

四、服务端私钥与证书

接上面的例子,我们来说说服务端私钥与证书的生成。

go的http.ListenAndServeTLS需要两个特别参数,一个是服务端的私钥 文件路径,另外一个是服务端的数字证书文件路径。在测试环境,我们没有必要花钱去购买什么证书,利用openssl工具,我们可以自己生成相 关私钥和自签发的数字证书。

openssl genrsa -out server.key 2048 用于生成服务端私钥文件server.key,后面的参数2048单位是bit,是私钥的长度。
openssl生成的私钥中包含了公钥的信息,我们可以根据私钥生成公钥:

$openssl rsa -in server.key -out server.key.public

我们也可以根据私钥直接生成自签发的数字证书:

$openssl req -new -x509 -key server.key -out server.crt -days 365

server.key和server.crt将作为ListenAndServeTLS的两个输入参数。

我们编写一个Go程序来尝试与这个HTTPS server建立连接并通信。

//gohttps/4-https/client1.go
package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    resp, err := http.Get("https://localhost:8081")
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

运行这个client,我们得到如下错误:

$go run client1.go
error: Get https://localhost:8081: x509: certificate signed by unknown authority

此时服务端也给出了错误日志提示:
2015/04/30 16:03:31 http: TLS handshake error from 127.0.0.1:62004: remote error: bad certificate

显然从客户端日志来看,go实现的Client端默认也是要对服务端传过来的数字证书进行校验的,但客户端提示:这个证书是由不知名CA签发 的!

我们可以修改一下client1.go的代码,让client端略过对证书的校验:

//gohttps/4-https/client2.go
package main

import (
    "crypto/tls"
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    tr := &http.Transport{
        TLSClientConfig:    &tls.Config{InsecureSkipVerify: true},
    }
    client := &http.Client{Transport: tr}
    resp, err := client.Get("https://localhost:8081")

    if err != nil {
        fmt.Println("error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

通过设置tls.Config的InsecureSkipVerify为true,client将不再对服务端的证书进行校验。执行后的结果 也证实了这一点:
$go run client2.go
Hi, This is an example of http service in golang!

五、对服务端的证书进行校验

多数时候,我们需要对服务端的证书进行校验,而不是像上面client2.go那样忽略这个校验。我大脑中的这个产品需要服务端和客户端双向 校验,我们先来看看如何能让client端实现对Server端证书的校验呢?

client端校验证书的原理是什么呢?回想前面我们提到的浏览器内置了知名CA的相关信息,用来校验服务端发送过来的数字证书。那么浏览器 存储的到底是CA的什么信息呢?其实是CA自身的数字证书(包含CA自己的公钥)。而且为了保证CA证书的真实性,浏览器是在出厂时就内置了 这些CA证书的,而不是后期通过通信的方式获取的。CA证书就是用来校验由该CA颁发的数字证书的。

那么如何使用CA证书校验Server证书的呢?这就涉及到数字证书到底是什么了!

我们可以通过浏览器中的"https/ssl证书管理"来查看证书的内容,一般服务器证书都会包含诸如站点的名称和主机名、公钥、签发机构 (CA)名称和来自签发机构的签名等。我们重点关注这个来自签发机构的签名,因为对于证书的校验,就是使用客户端CA证书来验证服务端证书的签名是否这 个CA签的。

通过签名验证我们可以来确认两件事:
1、服务端传来的数字证书是由某个特定CA签发的(如果是self-signed,也无妨),数字证书中的签名类似于日常生活中的签名,首先 验证这个签名签的是Tony Bai,而不是Tom Bai, Tony Blair等。
2、服务端传来的数字证书没有被中途篡改过。这类似于"Tony Bai"有无数种写法,这里验证必须是我自己的那种写法,而不是张三、李四写的"Tony Bai"。

一旦签名验证通过,我们因为信任这个CA,从而信任这个服务端证书。由此也可以看出,CA机构的最大资本就是其信用度。

CA在为客户签发数字证书时是这样在证书上签名的:

数字证书由两部分组成:
1、C:证书相关信息(对象名称+过期时间+证书发布者+证书签名算法….)
2、S:证书的数字签名

其中的数字签名是通过公式S = F(Digest(C))得到的。

Digest为摘要函数,也就是 md5、sha-1或sha256等单向散列算法,用于将无限输入值转换为一个有限长度的“浓缩”输出值。比如我们常用md5值来验证下载的大文件是否完 整。大文件的内容就是一个无限输入。大文件被放在网站上用于下载时,网站会对大文件做一次md5计算,得出一个128bit的值作为大文件的 摘要一同放在网站上。用户在下载文件后,对下载后的文件再进行一次本地的md5计算,用得出的值与网站上的md5值进行比较,如果一致,则大 文件下载完好,否则下载过程大文件内容有损坏或源文件被篡改。

F为签名函数。CA自己的私钥是唯一标识CA签名的,因此CA用于生成数字证书的签名函数一定要以自己的私钥作为一个输入参数。在RSA加密 系统中,发送端的解密函数就是一个以私钥作 为参数的函数,因此常常被用作签名函数使用。签名算法是与证书一并发送给接收 端的,比如apple的一个服务的证书中关于签名算法的描述是“带 RSA 加密的 SHA-256 ( 1.2.840.113549.1.1.11 )”。因此CA用私钥解密函数作为F,对C的摘要进行运算得到了客户数字证书的签名,好比大学毕业证上的校长签名,所有毕业证都是校长签发的。

接收端接收服务端数字证书后,如何验证数字证书上携带的签名是这个CA的签名呢?接收端会运用下面算法对数字证书的签名进行校验:
F'(S) ?= Digest(C)

接收端进行两个计算,并将计算结果进行比对:
1、首先通过Digest(C),接收端计算出证书内容(除签名之外)的摘要。
2、数字证书携带的签名是CA通过CA密钥加密摘要后的结果,因此接收端通过一个解密函数F'对S进行“解密”。RSA系统中,接收端使用 CA公钥对S进行“解密”,这恰是CA用私钥对S进行“加密”的逆过程。

将上述两个运算的结果进行比较,如果一致,说明签名的确属于该CA,该证书有效,否则要么证书不是该CA的,要么就是中途被人篡改了。

但对于self-signed(自签发)证书来说,接收端并没有你这个self-CA的数字证书,也就是没有CA公钥,也就没有办法对数字证 书的签名进行验证。因此如果要编写一个可以对self-signed证书进行校验的接收端程序的话,首先我们要做的就是建立一个属于自己的 CA,用该CA签发我们的server端证书,并将该CA自身的数字证书随客户端一并发布。

这让我想起了在《搭建自己的ngrok服务》一文中为ngrok服务端、客户端生成证书的那几个步骤,我们来重温并分析一下每一步都在做什么。

(1)openssl genrsa -out rootCA.key 2048
(2)openssl req -x509 -new -nodes -key rootCA.key -subj "/CN=*.tunnel.tonybai.com" -days 5000 -out rootCA.pem

(3)openssl genrsa -out device.key 2048
(4)openssl req -new -key device.key -subj "/CN=*.tunnel.tonybai.com" -out device.csr
(5)openssl x509 -req -in device.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out device.crt -days 5000

(6)cp rootCA.pem assets/client/tls/ngrokroot.crt
(7)cp device.crt assets/server/tls/snakeoil.crt
(8)cp device.key assets/server/tls/snakeoil.key

自己搭建ngrok服务,客户端要验证服务端证书,我们需要自己做CA,因此步骤(1)和步骤(2)就是生成CA自己的相关信息。
步骤(1) ,生成CA自己的私钥 rootCA.key
步骤(2),根据CA自己的私钥生成自签发的数字证书,该证书里包含CA自己的公钥。

步骤(3)~(5)是用来生成ngrok服务端的私钥和数字证书(由自CA签发)。
步骤(3),生成ngrok服务端私钥。
步骤(4),生成Certificate Sign Request,CSR,证书签名请求。
步骤(5),自CA用自己的CA私钥对服务端提交的csr进行签名处理,得到服务端的数字证书device.crt。

步骤(6),将自CA的数字证书同客户端一并发布,用于客户端对服务端的数字证书进行校验。
步骤(7)和步骤(8),将服务端的数字证书和私钥同服务端一并发布。

接下来我们来验证一下客户端对服务端数字证书进行验证(gohttps/5-verify-server-cert)!

首先我们来建立我们自己的CA,需要生成一个CA私钥和一个CA的数字证书:

$openssl genrsa -out ca.key 2048
Generating RSA private key, 2048 bit long modulus
……….+++
………………………….+++
e is 65537 (0×10001)

$openssl req -x509 -new -nodes -key ca.key -subj "/CN=tonybai.com" -days 5000 -out ca.crt

接下来,生成server端的私钥,生成数字证书请求,并用我们的ca私钥签发server的数字证书:

openssl genrsa -out server.key 2048
Generating RSA private key, 2048 bit long modulus
….+++
…………………….+++
e is 65537 (0×10001)

$openssl req -new -key server.key -subj "/CN=localhost" -out server.csr

$openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 5000
Signature ok
subject=/CN=localhost
Getting CA Private Key

现在我们的工作目录下有如下一些私钥和证书文件:
CA:
    私钥文件 ca.key
    数字证书 ca.crt

Server:
    私钥文件 server.key
    数字证书 server.crt

接下来,我们就来完成我们的程序。

Server端的程序几乎没有变化:

// gohttps/5-verify-server-cert/server.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w,
        "Hi, This is an example of http service in golang!")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServeTLS(":8081",
        "server.crt", "server.key", nil)
}

client端程序变化较大,由于client端需要验证server端的数字证书,因此client端需要预先加载ca.crt,以用于服务端数字证书的校验:

// gohttps/5-verify-server-cert/client.go
package main

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    pool := x509.NewCertPool()
    caCertPath := "ca.crt"

    caCrt, err := ioutil.ReadFile(caCertPath)
    if err != nil {
        fmt.Println("ReadFile err:", err)
        return
    }
    pool.AppendCertsFromPEM(caCrt)

    tr := &http.Transport{
        TLSClientConfig: &tls.Config{RootCAs: pool},
    }
    client := &http.Client{Transport: tr}
    resp, err := client.Get("https://localhost:8081")
    if err != nil {
        fmt.Println("Get error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

运行server和client:

$go run server.go

go run client.go
Hi, This is an example of http service in golang!

六、对客户端的证书进行校验(双向证书校验)

服务端可以要求对客户端的证书进行校验,以更严格识别客户端的身份,限制客户端的访问。

要对客户端数字证书进行校验,首先客户端需要先有自己的证书。我们以上面的例子为基础,生成客户端的私钥与证书。

$openssl genrsa -out client.key 2048
Generating RSA private key, 2048 bit long modulus
………………..+++
………………..+++
e is 65537 (0×10001)
$openssl req -new -key client.key -subj "/CN=tonybai_cn" -out client.csr
$openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 5000
Signature ok
subject=/CN=tonybai_cn
Getting CA Private Key

接下来我们来改造我们的程序,首先是server端。

首先server端需要要求校验client端的数字证书,并且加载用于校验数字证书的ca.crt,因此我们需要对server进行更加灵活的控制:

// gohttps/6-dual-verify-certs/server.go
package main

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io/ioutil"
    "net/http"
)

type myhandler struct {
}

func (h *myhandler) ServeHTTP(w http.ResponseWriter,
                   r *http.Request) {
    fmt.Fprintf(w,
        "Hi, This is an example of http service in golang!\n")
}

func main() {
    pool := x509.NewCertPool()
    caCertPath := "ca.crt"

    caCrt, err := ioutil.ReadFile(caCertPath)
    if err != nil {
        fmt.Println("ReadFile err:", err)
        return
    }
    pool.AppendCertsFromPEM(caCrt)

    s := &http.Server{
        Addr:    ":8081",
        Handler: &myhandler{},
        TLSConfig: &tls.Config{
            ClientCAs:  pool,
            ClientAuth: tls.RequireAndVerifyClientCert,
        },
    }

    err = s.ListenAndServeTLS("server.crt", "server.key")
    if err != nil {
        fmt.Println("ListenAndServeTLS err:", err)
    }
}

可以看出代码通过将tls.Config.ClientAuth赋值为tls.RequireAndVerifyClientCert来实现Server强制校验client端证书。ClientCAs是用来校验客户端证书的ca certificate。

Client端变化也很大,需要加载client.key和client.crt用于server端连接时的证书校验:

// gohttps/6-dual-verify-certs/client.go

package main
import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    pool := x509.NewCertPool()
    caCertPath := "ca.crt"

    caCrt, err := ioutil.ReadFile(caCertPath)
    if err != nil {
        fmt.Println("ReadFile err:", err)
        return
    }
    pool.AppendCertsFromPEM(caCrt)

    cliCrt, err := tls.LoadX509KeyPair("client.crt", "client.key")
    if err != nil {
        fmt.Println("Loadx509keypair err:", err)
        return
    }

    tr := &http.Transport{
        TLSClientConfig: &tls.Config{
            RootCAs:      pool,
            Certificates: []tls.Certificate{cliCrt},
        },
    }
    client := &http.Client{Transport: tr}
    resp, err := client.Get("https://localhost:8081")
    if err != nil {
        fmt.Println("Get error:", err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

好了,让我们来试着运行一下这两个程序,结果如下:

$go run server.go
2015/04/30 22:13:33 http: TLS handshake error from 127.0.0.1:53542:
tls: client's certificate's extended key usage doesn't permit it to be
used for client authentication

$go run client.go
Get error: Get https://localhost:8081: remote error: handshake failure

失败了!从server端的错误日志来看,似乎是client端的client.crt文件不满足某些条件。

根据server端的错误日志,搜索了Golang的源码,发现错误出自crypto/tls/handshake_server.go。

k := false
for _, ku := range certs[0].ExtKeyUsage {
    if ku == x509.ExtKeyUsageClientAuth {
        ok = true
        break
    }
}
if !ok {
    c.sendAlert(alertHandshakeFailure)
    return nil, errors.New("tls: client's certificate's extended key usage doesn't permit it to be used for client authentication")
}

大致判断是证书中的ExtKeyUsage信息应该包含clientAuth。翻看openssl的相关资料,了解到自CA签名的数字证书中包含的都是一些basic的信息,根本没有ExtKeyUsage的信息。我们可以用命令来查看一下当前client.crt的内容:

$ openssl x509 -text -in client.crt -noout
Certificate:
    Data:
        Version: 1 (0×0)
        Serial Number:
            d6:e3:f6:fa:ae:65:ed:df
        Signature Algorithm: sha1WithRSAEncryption
        Issuer: CN=tonybai.com
        Validity
            Not Before: Apr 30 14:11:34 2015 GMT
            Not After : Jan  6 14:11:34 2029 GMT
        Subject: CN=tonybai_cn
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
            RSA Public Key: (2048 bit)
                Modulus (2048 bit):
                    00:e4:12:22:50:75:ae:b2:8a:9e:56:d5:f3:7d:31:
                    7b:aa:75:5d:3f:90:05:4e:ff:ed:9a:0a:2a:75:15:
                    … …
                Exponent: 65537 (0×10001)
    Signature Algorithm: sha1WithRSAEncryption
        76:3b:31:3e:9d:b0:66:ad:c0:03:d4:19:c6:f2:1a:52:91:d6:
        13:31:3a:c5:d5:58:ea:42:1d:b7:33:b8:43:a8:a8:28:91:ac:
         … …

而偏偏golang的tls又要校验ExtKeyUsage,如此我们需要重新生成client.crt,并在生成时指定extKeyUsage。经过摸索,可以用如下方法重新生成client.crt:

1、创建文件client.ext
内容:
extendedKeyUsage=clientAuth

2、重建client.crt

$openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -extfile client.ext -out client.crt -days 5000
Signature ok
subject=/CN=tonybai_cn
Getting CA Private Key

再通过命令查看一下新client.crt:

看到输出的文本中多了这么几行:
        X509v3 extensions:
            X509v3 Extended Key Usage:
                TLS Web Client Authentication

这说明client.crt的extended key usage已经添加成功了。我们再来执行一下server和client:

$ go run client.go
Hi, This is an example of http service in golang!

client端证书验证成功,也就是说双向证书验证均ok了。

七、小结

通过上面的例子可以看出,使用golang开发https相关程序十分便利,Golang标准库已经实现了TLS 1.2版本协议。上述所有example代码均放在我的github上的experiments/gohttps中。

Blog站点被黑以及问题解决过程

2 Comments

记得前些日子,我在Blog评论里发现有人说我的Blog站点被黑:

"YOUR SITE HAS BEEN HACKED – THERE ARE PARASITE PAGES IN http://tonybai.com/dl SECURE YOUR SITE!!!"

粗浅检查了一番,没有发现什么异常,也就没把这事当回事儿。

昨天上Gmail(由于需要搭梯子,不经常登录),发现一位网友发来mail说我的站点被入侵了,还附上了google search结果的截图:

接着我也发现了google webmaster发来的mail,同样是警告我的博客站点被黑,并给出了两个可疑URL:

http://tonybai.com/dl

http://tonybai.com/dl/call-of-duty-4-modern-warfare-crack-download-tpb.html

我自己访问了一下上述URL,我靠!果然被黑了。

以前blog站点无论是搭建在dreamhost上还是朋友的主机上时都未出现过被黑的情况,这次在DO上居然被黑,之前没有解决类似问题的经验,这次只能从头摸索。

看了几篇解决wordpress被黑问题的文章,都推荐先安装几个安全插件对site进行扫描,于是我就试了两个:iThemes Security和Wordfence Security。前者似乎有问题,安装后,dashboard页一片空白。Wordfence Security还好,只是每次scan都无法finish,也就无法得到到底哪些wordpress文件被感染的结果。

插件不可靠,只能自己“手工”解决了。

首先到server上利用find , ls等命令对比时间,发现是否有哪些文件的最近访问时间戳与其他文件有差异。不过search了半天,也没发现半点痕迹。

网上还推荐用文件比对工具,比对现在的wordpress文件与backup的文件异同。多亏有backup插件的备份包,于是下载了20150326和20150409的backup zip,使用beyond compare进行目录比对。不比不知道,一比吓一跳啊:index.php文件时间戳相同,但内容居然不同

0409中的index.php的头部居然多了一段代码:

<?php $V3a3xH8="JQAgHfEmQKV+JuR5Y38ZdWofSxp4PSPn00uzTC

….

….

($CdFxbnu0g($nGXNegRe($dvXZv9($cDjofDA))));?>

显然这就是入侵代码了。删除这段代码,重启apache2,试试再访问以下上述那两个URL。结果是令人悲伤的,页面居然还能正常打开和显示。我第一时间想到的是浏览器和apache2的缓存。

强制刷新brower,无用。

查找apache2关于Cache的配置,发现一个:/etc/apache2/mods-available/cache_disk.conf

其内容:
<IfModule mod_cache_disk.c>

        # cache cleaning is done by htcacheclean, which can be configured in
        # /etc/default/apache2
        #
        # For further information, see the comments in that file,
        # /usr/share/doc/apache2/README.Debian, and the htcacheclean(8)
        # man page.

        # This path must be the same as the one in /etc/default/apache2
        CacheRoot /var/cache/apache2/mod_cache_disk

        # This will also cache local documents. It usually makes more sense to
        # put this into the configuration for just one virtual host.
        #CacheEnable disk /

    # The result of CacheDirLevels * CacheDirLength must not be higher than
    # 20. Moreover, pay attention on file system limits. Some file systems
    # do not support more than a certain number of inodes and
    # subdirectories (e.g. 32000 for ext3)
    CacheDirLevels 2
    CacheDirLength 1

</IfModule>

查看CacheRoot,发现/var/cache/apache2/mod_cache_disk下是空的。显然并未缓存。

难道还有其他位置为hacked了?难道0326的backup也是被hack过的?

于是我翻箱倒柜,在电脑里发现了20150101的backup,用这个Backup和0409又对比了一次,这回发现了另外一个被hack的文件:.htaccess。

.htaccess中多了这么一行代码:
RewriteRule ^dl/(.*)$ wp-add.php [L]

原来入侵的人或程序总共在我的主机上做了多处修改,这里总结一下:

1、.htaccess中增加一行规则
2、添加wp-add.php
3、修改了index.php
4、修改了wp-includes/theme-compat/header.php
5、修改了wp-content/themes/xx/header.php和footer.php

我ls了一下0409下的文件:

-rw-r–r–    1 tony  staff      4343 11 28 04:01 wp-activate.php
-rw-r–r–    1 tony  staff      1991 11 28 04:01 wp-add.php
drwxr-xr-x   89 tony  staff      3026  4  9 11:00 wp-admin/
-rw-r–r–    1 tony  staff     40243 11 28 04:01 wp-app.php

可以看出入侵代码在添加文件之后,对文件时间做了调整,让简单的时间戳对比无法揪出这个罪魁。

去除以上入侵代码后,上述可以网址就无法访问了。

在google webmaster提交request,期望google 早日将搜索结果中的"此网站可能遭到黑客入侵"标签去掉。

之后将密码修改了一遍,希望后续能免疫。

后记:
根据朋友建议,将blog的文件用git管理起来,并push到bitbucket的private repository中,这样一旦再被hack,恢复起来也较为方便。
步骤如下:
1、在/var/www目录下git init
2、git add ./
3、git commit -m”initial import” ./
4、git remote add origin https://user@bitbucket.org/user/blog.git
5、git push origin master

搭建自己的ngrok服务

2 Comments

在国内开发微信公众号企业号以及做前端开发的朋友想必对ngrok都不陌生吧,就目前来看,ngrok可是最佳的在内网调试微信服务的tunnel工 具。记得今年春节前,ngrok.com提供的服务还一切正常呢,但春节后似乎就一切不正常了。ngrok.com无法访问,ngrok虽然能连上 ngrok.com提供的服务,但微信端因为无法访问ngrok.com,导致消息一直无法发送到我们的服务地址上,比如xxxx.ngrok.com。 这一切都表明,ngork被墙了。没有了ngrok tunnel,一切开始变得困难且没有效率起来。内网到外部主机部署和调试是一件慢的让人想骂街的事情。

ngrok不能少。ngrok以及其服务端ngrokd都是开源的,之前我也知道通过源码可以自搭建ngrok服务。请求搜索引擎后,发现国内有个朋友已经搭建了一个www.tunnel.mobi的ngrok公共服务,与ngrok.com类似,我也实验了一下。

编写一个ngrok.cfg,内容如下:

server_addr: "tunnel.mobi:44433"
trust_host_root_certs: true

用ngrok最新客户端1.7版本执行如下命令:

$ngrok -subdomain tonybaiexample -config=ngrok.cfg 80

可以顺利建立一个tunnel,用于本机向外部提供"tonybaiexample.tunnel.mobi"服务。

Tunnel Status                 online
Version                       1.7/1.7
Forwarding                    http://tonybaiexample.tunnel.mobi -> 127.0.0.1:80
Forwarding                    https://tonybaiexample.tunnel.mobi -> 127.0.0.1:80
Web Interface                 127.0.0.1:4040
# Conn                        0
Avg Conn Time                 0.00ms

而且国内的ngrok服务显然要远远快于ngrok.com提供的服务,消息瞬间即达。

但这是在公网上直接访问的结果。放在公司内部,我看到的却是另外一个结果:

Tunnel Status                 reconnecting
Version                       1.7/
Web Interface                 127.0.0.1:4040
# Conn                        0
Avg Conn Time                 0.00ms

我们无法从内网建立tunnel,意味着依旧不方便和低效,因为很多基础服务都在内网部署,内外网之间的交互十分不便。但内网连不上tunnel.mobi也是个事实,且无法知道原因,因为看不到server端的连接错误日志。

于是我决定自建一个ngrok服务。

一、准备工作

搭建ngrok服务需要在公网有一台vps,去年年末曾经在Amazon申请了一个体验主机EC2,有公网IP一个,这次就打算用这个主机作为ngrokd服务端。

需要一个自己的域名。已有域名的,可以建立一个子域名,用于关联ngrok服务,这样也不会干扰原先域名提供的服务。(不用域名的方式也许可以,但我没有试验过。)

搭建的参考资料主要来自下面三个:
1) ngrok的官方SELFHOST指南:https://github.com/inconshreveable/ngrok/blob/master/docs/SELFHOSTING.md
2) 国外一哥们的博客:http://www.svenbit.com/2014/09/run-ngrok-on-your-own-server/
3) "海运的博客"中的一篇文章:http://www.haiyun.me/archives/1012.html

二、实操步骤

我的AWS EC2实例安装的是Ubuntu Server 14.04 x86_64,并安装了golang 1.4(go version go1.4 linux/amd64)。Golang是编译ngrokd和ngrok所必须的,建议直接从golang官方下载对应平台的二进制安装包(国内可以从 golangtc.com上下载,速度慢些罢了)。

1、下载ngrok源码

(GOPATH=~/goproj)
$ mkdir ~/goproj/src/github.com/inconshreveable
$ git clone https://github.com/inconshreveable/ngrok.git
$ export GOPATH=~/goproj/src/github.com/inconshreveable/ngrok

2、生成自签名证书

使用ngrok.com官方服务时,我们使用的是官方的SSL证书。自建ngrokd服务,我们需要生成自己的证书,并提供携带该证书的ngrok客户端。

证书生成过程需要一个NGROK_BASE_DOMAIN。 以ngrok官方随机生成的地址693c358d.ngrok.com为例,其NGROK_BASE_DOMAIN就是"ngrok.com",如果你要 提供服务的地址为"example.tunnel.tonybai.com",那NGROK_BASE_DOMAIN就应该 是"tunnel.tonybai.com"。

我们这里以NGROK_BASE_DOMAIN="tunnel.tonybai.com"为例,生成证书的命令如下:

$ cd ~/goproj/src/github.com/inconshreveable/ngrok
$ openssl genrsa -out rootCA.key 2048
$ openssl req -x509 -new -nodes -key rootCA.key -subj "/CN=
tunnel.tonybai.com" -days 5000 -out rootCA.pem
$ openssl genrsa -out device.key 2048
$ openssl req -new -key device.key -subj "/CN=
tunnel.tonybai.com" -out device.csr
$ openssl x509 -req -in device.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out device.crt -days 5000

执行完以上命令,在ngrok目录下就会新生成6个文件:

-rw-rw-r– 1 ubuntu ubuntu 1001 Mar 14 02:22 device.crt
-rw-rw-r– 1 ubuntu ubuntu  903 Mar 14 02:22 device.csr
-rw-rw-r– 1 ubuntu ubuntu 1679 Mar 14 02:22 device.key
-rw-rw-r– 1 ubuntu ubuntu 1679 Mar 14 02:21 rootCA.key
-rw-rw-r– 1 ubuntu ubuntu 1119 Mar 14 02:21 rootCA.pem
-rw-rw-r– 1 ubuntu ubuntu   17 Mar 14 02:22 rootCA.srl

ngrok通过bindata将ngrok源码目录下的assets目录(资源文件)打包到可执行文件(ngrokd和ngrok)中 去,assets/client/tls和assets/server/tls下分别存放着用于ngrok和ngrokd的默认证书文件,我们需要将它们替换成我们自己生成的:(因此这一步务必放在编译可执行文件之前)

cp rootCA.pem assets/client/tls/ngrokroot.crt
cp device.crt assets/server/tls/snakeoil.crt
cp device.key assets/server/tls/snakeoil.key

3、编译ngrokd和ngrok

在ngrok目录下执行如下命令,编译ngrokd:

$ make release-server

不过在我的AWS上,出现如下错误:

GOOS="" GOARCH="" go get github.com/jteeuwen/go-bindata/go-bindata
bin/go-bindata -nomemcopy -pkg=assets -tags=release \
        -debug=false \
        -o=src/ngrok/client/assets/assets_release.go \
        assets/client/…
make: bin/go-bindata: Command not found
make: *** [client-assets] Error 127

go-bindata被安装到了$GOBIN下了,go编译器找不到了。修正方法是将$GOBIN/go-bindata拷贝到当前ngrok/bin下。

$ cp /home/ubuntu/.bin/go14/bin/go-bindata ./bin

再次执行make release-server。

~/goproj/src/github.com/inconshreveable/ngrok$ make release-server
bin/go-bindata -nomemcopy -pkg=assets -tags=release \
        -debug=false \
        -o=src/ngrok/client/assets/assets_release.go \
        assets/client/…
bin/go-bindata -nomemcopy -pkg=assets -tags=release \
        -debug=false \
        -o=src/ngrok/server/assets/assets_release.go \
        assets/server/…
go get -tags 'release' -d -v ngrok/…
code.google.com/p/log4go (download)
go: missing Mercurial command. See http://golang.org/s/gogetcmd
package code.google.com/p/log4go: exec: "hg": executable file not found in $PATH
github.com/gorilla/websocket (download)
github.com/inconshreveable/go-update (download)
github.com/kardianos/osext (download)
github.com/kr/binarydist (download)
github.com/inconshreveable/go-vhost (download)
github.com/inconshreveable/mousetrap (download)
github.com/nsf/termbox-go (download)
github.com/mattn/go-runewidth (download)
github.com/rcrowley/go-metrics (download)
Fetching https://gopkg.in/yaml.v1?go-get=1
Parsing meta tags from https://gopkg.in/yaml.v1?go-get=1 (status code 200)
get "gopkg.in/yaml.v1": found meta tag main.metaImport{Prefix:"gopkg.in/yaml.v1", VCS:"git", RepoRoot:"https://gopkg.in/yaml.v1"} at https://gopkg.in/yaml.v1?go-get=1
gopkg.in/yaml.v1 (download)
make: *** [deps] Error 1

又出错!提示找不到hg,原来是aws上没有安装hg。install hg后(sudo apt-get install mercurial),再编译。

$ make release-server
bin/go-bindata -nomemcopy -pkg=assets -tags=release \
        -debug=false \
        -o=src/ngrok/client/assets/assets_release.go \
        assets/client/…
bin/go-bindata -nomemcopy -pkg=assets -tags=release \
        -debug=false \
        -o=src/ngrok/server/assets/assets_release.go \
        assets/server/…
go get -tags 'release' -d -v ngrok/…
code.google.com/p/log4go (download)
go install -tags 'release' ngrok/main/ngrokd

同样编译ngrok:

$ make release-client
bin/go-bindata -nomemcopy -pkg=assets -tags=release \
        -debug=false \
        -o=src/ngrok/client/assets/assets_release.go \
        assets/client/…
bin/go-bindata -nomemcopy -pkg=assets -tags=release \
        -debug=false \
        -o=src/ngrok/server/assets/assets_release.go \
        assets/server/…
go get -tags 'release' -d -v ngrok/…
go install -tags 'release' ngrok/main/ngrok

AWS上ngrokd和ngrok被安装到了$GOBIN下。

三、调试

1、启动ngrokd

$ ngrokd -domain="tunnel.tonybai.com" -httpAddr=":8080" -httpsAddr=":8081"
[03/14/15 04:47:24] [INFO] [registry] [tun] No affinity cache specified
[03/14/15 04:47:24] [INFO] [metrics] Reporting every 30 seconds
[03/14/15 04:47:24] [INFO] Listening for public http connections on [::]:8080
[03/14/15 04:47:24] [INFO] Listening for public https connections on [::]:8081
[03/14/15 04:47:24] [INFO] Listening for control and proxy connections on [::]:4443

… …

2、公网连接ngrokd

将生成的ngrok下载到自己的电脑上。

创建一个配置文件ngrok.cfg,内容如下:

server_addr: "tunnel.tonybai.com:4443"
trust_host_root_certs: false

执行ngrok:
$ ngrok -subdomain example -config=ngrok.cfg 80

Tunnel Status                 reconnecting
Version                       1.7/
Web Interface                 127.0.0.1:4040
# Conn                        0
Avg Conn Time                 0.00ms

连接失败。此刻我的电脑是在公网上。查看ngrokd的日志,没有发现连接到达Server端。试着在本地ping tunnel.tonybai.com这个地址,发现地址不通。难道是DNS设置的问题。之前我只是设置了"*.tunnel.tonybai.com"的A地址,并未设置"tunnel.tonybai.com"。于是到DNS管理页面,添加了"tunnel.tonybai.com"的A记录。

待DNS记录刷新OK后,再次启动ngrok:

Tunnel Status online
Version 1.7/1.7
Forwarding http://epower.tunnel.tonybai.com:8080 -> 127.0.0.1:80
Forwarding https://epower.tunnel.tonybai.com:8080 -> 127.0.0.1:80
Web Interface 127.0.0.1:4040
# Conn 0
Avg Conn Time 0.00ms

这回连接成功了!

3、内网连接ngrokd

将ngrok拷贝到内网的一台PC上,这台PC设置了公司的代理。

按照同样的步骤启动ngrok:

$ ngrok -subdomain example -config=ngrok.cfg 80

Tunnel Status                 reconnecting
Version                       1.7/
Web Interface                 127.0.0.1:4040
# Conn                        0
Avg Conn Time                 0.00ms

不巧,怎么又失败了!从Server端来看,还是没有收到客户端的连接,显然是连接没有打通公司内网。从我自己的squid代理服务器来看,似乎只有443端口的请求被公司代理服务器允许通过,4443则无法出去。

1426301143.558 9294 10.10.126.101 TCP_MISS/000 366772 CONNECT api.equinox.io:443 – DEFAULT_PARENT/proxy.xxx.com -   通过了
1426301144.441 27 10.10.126.101 TCP_MISS/000 1185 CONNECT tunnel.tonybai.com:4443 – DEFAULT_PARENT/proxy.xxx.com -  似乎没有通过

只能修改server监听端口了。将-tunnelAddr由4443改为443(注意AWS上需要修改防火墙的端口规则,这个是实时生效的,无需重启实例):

$ sudo ngrokd -domain="tunnel.tonybai.com" -httpAddr=":8080" -httpsAddr=":8081" -tunnelAddr=":443"
[03/14/15 04:47:24] [INFO] [registry] [tun] No affinity cache specified
[03/14/15 04:47:24] [INFO] [metrics] Reporting every 30 seconds
[03/14/15 04:47:24] [INFO] Listening for public http connections on [::]:8080
[03/14/15 04:47:24] [INFO] Listening for public https connections on [::]:8081
[03/14/15 04:47:24] [INFO] Listening for control and proxy connections on [::]:443

… …

将ngrok.cfg中的地址改为443:

server_addr: "tunnel.tonybai.com:443"

再次执行ngrok客户端:

Tunnel Status                 online
Version                       1.7/1.7
Forwarding                    http://epower.tunnel.tonybai.com:8080 -> 127.0.0.1:80
Forwarding                    https://epower.tunnel.tonybai.com:8080 -> 127.0.0.1:80
Web Interface                 127.0.0.1:4040
# Conn                        0
Avg Conn Time                 0.00ms

这回成功连上了。

4、80端口

是否大功告成了呢?我们看看ngrok的结果,总感觉哪里不对呢?噢,转发的地址怎么是8080端口呢?为何不是80?微信公众号/企业号可只是支持80端口啊!

我们还需要修改一下Server端的参数,将-httpAddr从8080改为80。

$ sudo ngrokd -domain="tunnel.tonybai.com" -httpAddr=":80" -httpsAddr=":8081" -tunnelAddr=":443"

这回再用ngrok连接一下:
Tunnel Status                 online
Version                       1.7/1.7
Forwarding                    http://epower.tunnel.tonybai.com -> 127.0.0.1:80
Forwarding                    https://epower.tunnel.tonybai.com -> 127.0.0.1:80
Web Interface                 127.0.0.1:4040
# Conn                        0
Avg Conn Time                 0.00ms

这回与我们的需求匹配上了。

5、测试

在内网的PC上建立一个简单的http server 程序:hello

//hello.go
package main

import "net/http"

func main() {
    http.HandleFunc("/", hello)
    http.ListenAndServe(":80", nil)
}

func hello(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello!"))
}

$ go build -o hello hello.go
$ sudo ./hello

通过公网浏览器访问一下“http://epower.tunnel.tonybai.com”这个地址,如果你看到浏览器返回"hello!"字样,那么你的ngrokd服务就搭建成功了!

四、注意事项

客户端ngrok.cfg中server_addr后的值必须严格与-domain以及证书中的NGROK_BASE_DOMAIN相同,否则Server端就会出现如下错误日志:

[03/13/15 09:55:46] [INFO] [tun:15dd7522] New connection from 54.149.100.42:38252
[03/13/15 09:55:46] [DEBG] [tun:15dd7522] Waiting to read message
[03/13/15 09:55:46] [WARN] [tun:15dd7522] Failed to read message: remote error: bad certificate
[03/13/15 09:55:46] [DEBG] [tun:15dd7522] Closing

理解Golang包导入

2 Comments

Golang使用包(package)这种语法元素来组织源码,所有语法可见性均定义在package这个级别,与Java 、python等语言相比,这算不上什么创新,但与C传统的include相比,则是显得“先进”了许多。

Golang中包的定义和使用看起来十分简单:

通过package关键字定义包:
  
    package xxx

使用import关键字,导入要使用的标准库包或第三方依赖包。

   import "a/b/c"
   import "fmt"

   c.Func1()
   fmt.Println("Hello, World")

很多Golang初学者看到上面代码,都会想当然的将import后面的"c"、"fmt"当成包名,将其与c.Func1()和 fmt.Println()中的c和fmt认作为同一个语法元素:包名。但在深入Golang后,很多人便会发现事实上并非如此。比如在使用实时分布式消 息平台nsq提供的go client api时:

我们导入的路径如下:

   import “github.com/bitly/go-nsq”

但在使用其提供的export functions时,却用nsq做前缀包名:

   q, _ := nsq.NewConsumer("write_test", "ch", config)

人们不禁要问:import后面路径中的最后一个元素到底代表的是啥? 是包名还是仅仅是一个路径?我们一起通过试验来理解一下。  实验环境:darwin_amd64 , go 1.4

初始试验环境目录结果如下:

GOPATH = /Users/tony/Test/Go/pkgtest/
pkgtest/
    pkg/
    src/
       libproj1/
           foo/
              foo1.go
       app1/
           main.go

   
一、编译时使用的是包源码还是.a

我们知道一个非main包在编译后会生成一个.a文件(在临时目录下生成,除非使用go install安装到$GOROOT或$GOPATH下,否则你看不到.a),用于后续可执行程序链接使用。

比如Go标准库中的包对应的源码部分路径在:$GOROOT/src,而标准库中包编译后的.a文件路径在$GOROOT/pkg/darwin_amd64下。一个奇怪的问题在我脑袋中升腾起来,编译时,编译器到底用的是.a还是源码?

我们先以用户自定义的package为例做个小实验。

$GOPATH/src/
    libproj1/foo/
            – foo1.go
    app1
            – main.go

//foo1.go
package foo

import "fmt"

func Foo1() {
    fmt.Println("Foo1")
}

// main.go
package main

import (
    "libproj1/foo"
)

func main() {
    foo.Foo1()
}

执行go install libproj1/foo,Go编译器编译foo包,并将foo.a安装到$GOPATH/pkg/darwin_amd64/libproj1下。
编译app1:go build app1,在app1目录下生成app1*可执行文件,执行app1,我们得到一个初始预期结果:

$./app1
Foo1

现在我们无法看出使用的到底是foo的源码还是foo.a,因为目前它们的输出都是一致的。我们修改一下foo1.go的代码:

//foo1.go
package foo

import "fmt"

func Foo1() {
    fmt.Println("Foo1 – modified")
}

重新编译执行app1,我们得到结果如下:

$./app1
Foo1 – modified

实际测试结果告诉我们:(1)在使用第三方包的时候,当源码和.a均已安装的情况下,编译器链接的是源码。

那么是否可以只链接.a,不用第三方包源码呢?我们临时删除掉libproj1目录,但保留之前install的libproj1/foo.a文件。

我们再次尝试编译app1,得到如下错误:

$go build app1
main.go:5:2: cannot find package "libproj1/foo" in any of:
    /Users/tony/.Bin/go14/src/libproj1/foo (from $GOROOT)
    /Users/tony/Test/Go/pkgtest/src/libproj1/foo (from $GOPATH)

编译器还是去找源码,而不是.a,因此我们要依赖第三方包,就必须搞到第三方包的源码,这也是Golang包管理的一个特点。

其实通过编译器的详细输出我们也可得出上面结论。我们在编译app1时给编译器传入-x -v选项:

$go build -x -v app1
WORK=/var/folders/2h/xr2tmnxx6qxc4w4w13m01fsh0000gn/T/go-build797811168
libproj1/foo
mkdir -p $WORK/libproj1/foo/_obj/
mkdir -p $WORK/libproj1/
cd /Users/tony/Test/Go/pkgtest/src/libproj1/foo
/Users/tony/.Bin/go14/pkg/tool/darwin_amd64/6g -o $WORK/libproj1/foo.a -trimpath $WORK -p libproj1/foo -complete -D _/Users/tony/Test/Go/pkgtest/src/libproj1/foo -I $WORK -pack ./foo1.go ./foo2.go
app1
mkdir -p $WORK/app1/_obj/
mkdir -p $WORK/app1/_obj/exe/
cd /Users/tony/Test/Go/pkgtest/src/app1
/Users/tony/.Bin/go14/pkg/tool/darwin_amd64/6g -o $WORK/app1.a -trimpath $WORK -p app1 -complete -D _/Users/tony/Test/Go/pkgtest/src/app1 -I $WORK -I /Users/tony/Test/Go/pkgtest/pkg/darwin_amd64 -pack ./main.go
cd .
/Users/tony/.Bin/go14/pkg/tool/darwin_amd64/6l -o $WORK/app1/_obj/exe/a.out -L $WORK -L /Users/tony/Test/Go/pkgtest/pkg/darwin_amd64 -extld=clang $WORK/app1.a
mv $WORK/app1/_obj/exe/a.out app1

可以看到编译器6g首先在临时路径下编译出依赖包foo.a,放在$WORK/libproj1下。但我们在最后6l链接器的执行语句中并未显式看到app1链接的是$WORK/libproj1下的foo.a。但是从6l链接器的-L参数来看:-L $WORK -L /Users/tony/Test/Go/pkgtest/pkg/darwin_amd64,我们发现$WORK目录放在了前面,我们猜测6l首先搜索到的时$WORK下面的libproj1/foo.a。

为了验证我们的推论,我们按照编译器输出,按顺序手动执行了一遍如上命令,但在最后执行6l命令时,去掉了-L $WORK:

/Users/tony/.Bin/go14/pkg/tool/darwin_amd64/6l -o $WORK/app1/_obj/exe/a.out -L /Users/tony/Test/Go/pkgtest/pkg/darwin_amd64 -extld=clang $WORK/app1.a

这样做的结果是:

$./app1
Foo1

编译器链接了$GOPATH/pkg下的foo.a。(2)到这里我们明白了所谓的使用第三方包源码,实际上是链接了以该最新源码编译的临时目录下的.a文件而已。

Go标准库中的包也是这样么?对于标准库,比如fmt而言,编译时,到底使用的时$GOROOT/src下源码还是$GOROOT/pkg下已经编译好的.a呢?

我们不妨也来试试,一个最简单的hello world例子:
//main.go
import "fmt"

func main() {
    fmt.Println("Hello, World")
}

我们先将$GOROOT/src/fmt目录rename 为fmtbak,看看go compiler有何反应?
go build -x -v ./

$go build -x -v ./
WORK=/var/folders/2h/xr2tmnxx6qxc4w4w13m01fsh0000gn/T/go-build957202426
main.go:4:8: cannot find package "fmt" in any of:
    /Users/tony/.Bin/go14/src/fmt (from $GOROOT)
    /Users/tony/Test/Go/pkgtest/src/fmt (from $GOPATH)
 
找不到fmt包了。显然标准库在编译时也是必须要源码的。不过与自定义包不同的是,即便你修改了fmt包的源码(未重新编译GO安装包),用户源码编译时,也不会尝试重新编译fmt包的,依旧只是在链接时链接已经编译好的fmt.a。通过下面的gc输出可以验证这点:

$go build -x -v ./
WORK=/var/folders/2h/xr2tmnxx6qxc4w4w13m01fsh0000gn/T/go-build773440756
app1
mkdir -p $WORK/app1/_obj/
mkdir -p $WORK/app1/_obj/exe/
cd /Users/tony/Test/Go/pkgtest/src/app1
/Users/tony/.Bin/go14/pkg/tool/darwin_amd64/6g -o $WORK/app1.a -trimpath $WORK -p app1 -complete -D _/Users/tony/Test/Go/pkgtest/src/app1 -I $WORK -pack ./main.go
cd .
/Users/tony/.Bin/go14/pkg/tool/darwin_amd64/6l -o $WORK/app1/_obj/exe/a.out -L $WORK -extld=clang $WORK/app1.a
mv $WORK/app1/_obj/exe/a.out app1

可以看出,编译器的确并未尝试编译标准库中的fmt源码。

二、目录名还是包名?

从第一节的实验中,我们得知了编译器在编译过程中依赖的是包源码的路径,这为后续的实验打下了基础。下面我们再来看看,Go语言中import后面路径中最后的一个元素到底是包名还是路径名?

本次实验目录结构:
$GOPATH
    src/
       libproj2/
             foo/
               foo1.go
       app2/
             main.go

按照Golang语言习惯,一个go package的所有源文件放在同一个目录下,且该目录名与该包名相同,比如libproj1/foo目录下的package为foo,foo1.go、 foo2.go…共同组成foo package的源文件。但目录名与包名也可以不同,我们就来试试不同的。

我们建立libproj2/foo目录,其中的foo1.go代码如下:

//foo1.go
package bar

import "fmt"

func Bar1() {
    fmt.Println("Bar1")
}

注意:这里package名为bar,与目录名foo完全不同。

接下来就给app2带来了难题:该如何import bar包呢?

我们假设import路径中的最后一个元素是包名,而非路径名。

//app2/main.go

package main

import (
    "libproj2/bar"
)

func main() {
    bar.Bar1()
}

编译app2:

$go build -x -v app2
WORK=/var/folders/2h/xr2tmnxx6qxc4w4w13m01fsh0000gn/T/go-build736904327
main.go:5:2: cannot find package "libproj2/bar" in any of:
    /Users/tony/.Bin/go14/src/libproj2/bar (from $GOROOT)
    /Users/tony/Test/Go/pkgtest/src/libproj2/bar (from $GOPATH)

编译失败,在两个路径下无法找到对应libproj2/bar包。

我们的假设错了,我们把它改为路径:

//app2/main.go

package main

import (
    "libproj2/foo"
)

func main() {
    bar.Bar1()
}

再编译执行:

$go build app2
$app2
Bar1

这回编译顺利通过,执行结果也是OK的。这样我们得到了结论:(3)import后面的最后一个元素应该是路径,就是目录,并非包名

go编译器在这些路径(libproj2/foo)下找bar包。这样看来,go语言的惯例只是一个特例,即恰好目录名与包名一致罢了。也就是说下面例子中的两个foo含义不同:

import "libproj1/foo"

func main() {
    foo.Foo()
}

import中的foo只是一个文件系统的路径罢了。而下面foo.Foo()中的foo则是包名。而这个包是在libproj1/foo目录下的源码中找到的。

再类比一下标准库包fmt。

import "fmt"
fmt.Println("xxx")

这里上下两行中虽然都是“fmt",但同样含义不同,一个是路径 ,对于标准库来说,是$GOROOT/src/fmt这个路径。而第二行中的fmt则是包名。gc会在$GOROOT/src/fmt路径下找到fmt包的源文件。

三、import m "lib/math"

Go language specification中关于import package时列举的一个例子如下:

Import declaration          Local name of Sin

import   "lib/math"         math.Sin
import m "lib/math"         m.Sin
import . "lib/math"         Sin

我们看到import m "lib/math"  m.Sin一行。我们说过lib/math是路径,import语句用m替代lib/math,并在代码中通过m访问math包中的导出函数Sin。

那m到底是包名还是路径呢?既然能通过m访问Sin,那m肯定是包名了,Right!那import m "lib/math"该如何理解呢? 

根据上面一、二两节中得出的结论,我们尝试理解一下m:(4)m指代的是lib/math路径下唯一的那个包

一个目录下是否可以存在两个包呢?我们来试试。

我们在libproj1/foo下新增一个go源文件,bar1.go:

package bar

import "fmt"

func Bar1() {
    fmt.Println("Bar1")
}

我们重新构建一下这个目录下的包:

$go build libproj1/foo
can't load package: package libproj1/foo: found packages bar1.go (bar) and foo1.go (foo) in /Users/tony/Test/Go/pkgtest/src/libproj1/foo

我们收到了错误提示,编译器在这个路径下发现了两个包,这是不允许的。

我们再作个实验,来验证我们对m含义的解释。

我们建立app3目录,其main.go的源码如下:

//main.go
package main

import m "libproj2/foo"

func main() {
    m.Bar1()
}

libproj2/foo路径下的包的包名为bar,按照我们的推论,m指代的就是bar这个包,通过m我们可以访问bar的Bar1导出函数。

编译并执行上面main.go:

$go build app3
$app3
Bar1

执行结果与我们推论完全一致。

附录:6g, 6l文档位置:

6g – $GOROOT/src/cmd/gc/doc.go
6l – $GOROOT/src/cmd/ld/doc.go

近期遇到的3个Golang代码问题

1 Comment

这两周来业余时间都在用Golang写代码,现在处于这样一个状态:除了脚本,就是Golang了。反正能用golang实现的,都用golang写。

Golang语言相对成熟了,但真正写起来,还是要注意一些“坑”的,下面是这周遇到的三个问题,这里分享出来,希望能对遇到同样问题的童鞋有所帮助。

一、误用定时器,狂占CPU

golang中有一个通过channel实现timeout或tick timer的非常idiomatic的方法,代码如下:

func worker(start chan bool) {
        for {
                timeout := time.After(30 * time.Second)
                 select {
                         // … do some stuff
                         case <- timeout:
                                 return
                 }
        }
}

func worker(start chan bool) {
        for {
                heartbeat := time.Tick(30 * time.Second)
                 select {
                         // … do some stuff
                         case <- heartbeat:
                                 return
                 }
        }
}

没错,就像上面这两个例子,如果你单独执行它们,你不会发现任何问题,但是当你将这样的代码放到一个7 * 24小时的Service中,并且timeout间隔或heartbeat间隔为更短时间,比如1s时,问题就出现了。

我的程序最初就是用上面的代码实现了一个timewheel,通过放置在一个单独goroutine中的定时器检测timewheel是否有到期的 timer。程序跑在后台运行的很好,直到有一天晚上我无意中执行了一下top,我发现这个service居然站用了40%多的CPU负荷。最初我怀疑是 不是代码中有死循环,但仔细巡查一遍代码后,没有发现死循环的痕迹,算法逻辑也没问题。

于是重启了一下这个service,发现cpu占用降了下来。出去去了趟卫生间,回来继续用top观察,不好,这个service占用了1%的CPU,再 过一会升到2%,观察一段时间后,发现这个service对cpu的占用率随着时间的推移而增加。gdb attach了相应的进程号,stack多是go runtime的调度。

再次回到代码,发现可能存在问题的只有这里的tick。我的tick间隔是1s。这样每1s都会创建一个runtime timer,而通过runtime的源码来看,这些timer都扔给了runtime调度(一个heap)。时间长了,就会有超多的timer需要 runtime调度,不耗CPU才怪。

于是做了如下修改:

func worker(start chan bool) {
        heartbeat := time.Tick(1 * time.Second)
         for {
               
                 select {
                         // … do some stuff
                         case <- heartbeat:
                                 return
                 }
        }
}

重新编译执行service,观察了一天,cpu再也没有升高过。

二、小心list.List的Delete逻辑

其实这是一个在哪种语言中都会遇到的初级问题,这里只是给大家提个醒罢了。不多说了,上代码:

从一个list.List中删除一个element,一般逻辑是:

l := list.New()
… …
for e := l.Front(); e != nil; e = e.Next() {
        if e.Value.(int) == someValue {
                l.Remove(e)
                return or break
        }
}

但是如果list里有重复元素,且代码要遍历整个list删除某个值为somevalue的元素呢?上面的一般方法是由逻辑缺陷的,例子:

func foo(i int) {
        l := list.New()
        for i := 0; i < 9; i++ {
                l.PushBack(i)
        }
        l.PushBack(6)

        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }

        for e := l.Front(); e != nil; e = e.Next() {
                if e.Value.(int) == i {
                        l.Remove(e)
                }
        }

        fmt.Printf("\n")
        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }
        fmt.Printf("\n")
}

func main() {
        foo(6)
}

该程序试图删除list中的所有值为6的element,但执行结果却是:

go run testlist.go
0123456786
012345786

list中尾部的那个6没有被删除,程序似乎在删除完第一个6之后就不再继续循环了。事实也是这样:

当l.Remove(e)执行后,e.Next()被置为了nil,这样循环条件不再满足,循环终止。

为此,对于这样的程序,下面的方法才是正确的:

func main() {
        bar(6)
}

func bar(i int) {
        l := list.New()
        for i := 0; i < 9; i++ {
                l.PushBack(i)
        }
        l.PushBack(6)

        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }

        var next *list.Element
        for e := l.Front(); e != nil; {
                if e.Value.(int) == i {
                        next = e.Next()
                        l.Remove(e)
                        e = next
                } else {
                        e = e.Next()
                }
        }

        fmt.Printf("\n")
        for e := l.Front(); e != nil; e = e.Next() {
                fmt.Print(e.Value.(int))
        }
        fmt.Printf("\n")
}

执行结果:
$ go run testlist.go
0123456786
01234578

三、要给template起个正确的名字

编写一个Web程序,需要用到html/template。

… …
t := template.New("My Reporter")
t, err = t.ParseFiles("views/report.html")
if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
}

t.Execute(w, UserInfo{xx: XX})

结果一执行却crash了:

[martini] PANIC: runtime error: invalid memory address or nil pointer dereference
/usr/local/go/src/runtime/panic.go:387 (0×16418)
/usr/local/go/src/runtime/panic.go:42 (0x1573e)
/usr/local/go/src/runtime/sigpanic_unix.go:26 (0x1bb50)
/usr/local/go/src/html/template/template.go:59 (0x7ed64)
/usr/local/go/src/html/template/template.go:75 (0x7ef0d)
/Users/tony/Test/GoToolsProjects/src/git.oschina.net/bigwhite/web/app.go:104 (0x2db0)
    reportHandler: t.Execute(w, UserInfo{xx: XXX})

问题在t.Execute这行,单独把template代码摘出来放在一个测试代码中:

//testtmpl.go
type UserInfo struct {
        Name string
}

func main() {
        t := template.New("My Reporter")
        t, err := t.ParseFiles("views/report.html")
        if err != nil {
                fmt.Println("parse error")
                return
        }

        err = t.Execute(os.Stdout, UserInfo{Name: "tonybai"})
        if err != nil {
                fmt.Println("exec error", err)
        }
        return
}

执行结果:
go run testtmpl.go
exec error template: My Reporter: "My Reporter" is an incomplete or empty template; defined templates are: "report.html"

看起来似乎template对象与模板名字对不上导致的错误啊。修改一下:

t := template.New("report.html")

执行结果:

<html>
<head>
</head>
<body>
    Hello, tonybai
</body>
</html>

这回对了,看来template的名字在与ParseFiles一起使用时不是随意取的,务必要与模板文件名字相同。

ParseFiles支持解析多个文件,如果是传入多个文件该咋办?godoc说了,template名字与第一个文件名相同即可。

一个有关Golang变量作用域的坑

10 Comments

临近下班前编写和调试一段Golang代码,但运行结果始终与期望不符,怪异的很,下班前依旧无果。代码Demo如下:

//testpointer.go
package main

import (
        "fmt"
)

var p *int

func foo() (*int, error) {
        var i int = 5
        return &i, nil
}

func bar() {
        //use p
        fmt.Println(*p)
}

func main() {
        p, err := foo()
        if err != nil {
                fmt.Println(err)
                return
        }
        bar()
        fmt.Println(*p)
}

这段代码原意是定义一个包内全局变量p,用foo()的返回值对p进行初始化,在bar中使用p。预期结果:bar()和main()中均输出5。但编译执行后的结果却是:

$go run testpointer.go
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x0 pc=0x20d1]

goroutine 1 [running]:
main.bar()
    /Users/tony/Test/Go/testpointer.go:17 +0xd1
main.main()
    /Users/tony/Test/Go/testpointer.go:26 +0x11c

goroutine 2 [runnable]:
runtime.forcegchelper()
    /usr/local/go/src/runtime/proc.go:90
runtime.goexit()
    /usr/local/go/src/runtime/asm_amd64.s:2232 +0×1

goroutine 3 [runnable]:
runtime.bgsweep()
    /usr/local/go/src/runtime/mgc0.go:82
runtime.goexit()
    /usr/local/go/src/runtime/asm_amd64.s:2232 +0×1

goroutine 4 [runnable]:
runtime.runfinq()
    /usr/local/go/src/runtime/malloc.go:712
runtime.goexit()
    /usr/local/go/src/runtime/asm_amd64.s:2232 +0×1
exit status 2

晚饭后,继续调试这段代码。怎么还crash了!代码看似半点问题都没有,难道是Go编译器的问题,我用的可是最新的1.4,切换回1.3.3,问题依旧啊。看来还是代码的问题,但问题在哪里呢?加上些打印语句再看看:

func bar() {
        //use p
        fmt.Printf("%p, %T\n", p, p) //output:
0x14dc80, 0×0, *int
        fmt.Println(*p) //Crash!!!
}

func main() {
        fmt.Printf("%p, %T\n", p, p) //output: 0x14dc80, 0×0, *int
        p, err := foo()
        if err != nil {
                fmt.Println(err)
                return
        }
        fmt.Printf("%p, %T\n", p, p) //output: 0x2081c6020, 0x20818a258, *int
        bar()
        fmt.Println(*p)
}

通过打印输出,发现从foo函数中返回的p(0x2081c6020)与全局变量的p(0x14dc80)居然不是一个地址,也就是说不是一个变量。而且 从bar()中的调试输出来看,全局变量p在foo函数返回时并未被赋值为foo中变量i的地址,而依然是一个nil值,从而导致程序Crash。

好了,废话不说了,该是揭晓真相的时候了。问题就在于":="。在main这个作用域中,我们使用了

p, err := foo()

最初的理解是golang会定义新变量err,p为初始定义的那个全局变量。但实际情况是,对于使用:=定义的变量,如果新变量p与那个同名已定义变量 (这里就是那个全局变量p)不在一个作用域中时,那么golang会新定义这个变量p,遮盖住全局变量p,这就是导致这个问题的真凶。

我们将main函数改为:

func main() {
        var err error
        p, err = foo()
        if err != nil {
                fmt.Println(err)
                return
        }
        bar()
}

则执行结果就完全符合预期了。

Older Entries