发布于 2026-01-06 9 阅读
0

用 130 行代码构建你自己的 Ngrok DEV 的全球展示与讲述挑战赛,由 Mux 呈现:展示你的项目!

用 130 行代码构建你自己的 Ngrok

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

为你的应用运行本地开发服务器很常见,但如果你想让其他人访问它呢?可能是为了演示,或者调试 webhook 集成。如果你用过Ngrok(或者最初的localtunnel),你就知道我在说什么了。

今天我们将创建一个系统来模拟 Ngrok 的核心功能,通过公共 URL 公开本地主机 HTTP 服务器。

为了确保我们完全理解其工作原理,让我解释一下问题所在。通常情况下,当你在自己的计算机上运行服务器时,它只能被同一局域网内的其他计算机访问。然而,许多开发服务器会绑定到公共互联网127.0.0.1localhost这意味着只有你计算机上的客户端或浏览器才能访问它。无论哪种方式,有时你可能需要让其他方临时访问它,但它却无法从公共互联网访问。

过去,只有当你能访问运行 SSH 的服务器时才能实现这一点。你可以配置 SSH,将该服务器上的公网端口通过 SSH 连接“转发”到你计算机上的本地端口。这被称为远程端口转发,有时也称为 SSH 隧道。假设 SSH 配置正确,设置命令如下:



$ ssh -R 8080:localhost:3000 your-ssh-server.com


Enter fullscreen mode Exit fullscreen mode

这样一来,只要这条命令运行,其他人就可以通过浏览器访问your-ssh-server.com:8080你的本地服务器,并在指定端口上运行3000。大约在 2009 年,我注意到这项功能非常实用(尤其是在 Webhook 的上下文中),但令我恼火的是,你必须先正确配置 SSH 服务器才能使用它。即使在那时,我也一直觉得 SSH 命令很难记住如何使用进行转发。我设想的命令应该像这样简单:



$ localtunnel 3000


Enter fullscreen mode Exit fullscreen mode

于是我开始着手实现这个目标。我第一次尝试用 Twisted Python 构建系统失败了,因为我对多路复用还不太理解。所以最后我选择了封装 SSH。我localtunnel用 Ruby 写了一个客户端,它在后台运行上述 SSH 命令,使用我搭建的服务器。唯一的区别在于,我没有提供像 `/usr/local/bin` 这样的端口号8080,而是生成了一个子域名。localtunnel.com:8080你得到的不是 `/usr/local/bin`,而是 `/usr/local/bin`,wxyz.localtunnel.com这个子域名是你的隧道独有的,因为它是作为公共服务提供的。

为了实现这一点,我运行了一个 Twisted Web 服务器,并配合 SSH 服务器,它会根据子域名确定要使用的端口并进行代理。这仅适用于 HTTP,因为虚拟主机的概念最初就是为 HTTP 而发明的。通常情况下,建立 TCP 连接时,你并不知道使用的是哪个主机名。

我当时的情况大概是这样的:
Localtunnel拓扑结构

而且成功了!它变得相当受欢迎。然而,由于它略微复杂(Ruby+Python+OpenSSH),加上我太忙无暇维护,人们开始克隆它。有个傻瓜甚至把他的克隆版命名为“localtunnel”。

另一个家伙——与其说是混蛋,不如说是我的朋友和同事——决定克隆这个程序来学习 Go 语言。他把它命名为 Ngrok,并打算将其商业化。这让我很恼火,原因有很多,我就不细说了。但最终我还是接受了,因为他确实改进了这个想法,而且如果我有什么意见,他至少会听取我的想法。

总之,今天我们要探索一个简化版的系统,我们称之为 groktunnel。它移除了 SSH,并且利用两个库,我们只需大约 130 行 Go 代码就能构建整个系统。虽然仍然需要服务器来运行它,所以它不如托管服务方便,但这更多是出于教学目的。毕竟,groktunnel 实际上是 qmux 的一个演示,qmux 正是我提到的库之一。接下来让我解释一下这些库。

qmux 的作用是提供 SSH 协议的一个子集,用于在单个连接上复用多个连接。它弥补了我最初 Twisted 原型中的不足。你可以在我之前的文章中了解更多关于这类协议数量不多(但或许它们和 QUIC 一样,代表着互联网的未来)的信息

另一个库实际上是 Ngrok 的作者写的,名为go-vhost,它可以帮助解决虚拟主机的问题。理想情况下,当一个连接需要通过隧道转发时,我们可以将其完整地传递出去。这样,另一端就可以像直接接收连接一样开始读取它。但是,为了确定使用的主机名,我们必须先读取连接直到 Host 头部。然后,我们必须将读取到的信息添加到连接头部并传递给另一端。Alan 编写的 go-vhost 提供了一个抽象层,它提供了一个虚拟监听器,允许你接受特定虚拟主机的新连接,并像连接尚未被读取一样获取它。这很大程度上要归功于 Go 标准库基于接口的方法。

这就引出了我们将要为该系统编写的主要实用函数。两个对等体之间建立的 TCP 连接无法(轻易地)在程序之间传递,更不用说在主机之间传递了。我们并非真的“转发”连接,而是位于中间并中继字节。这就是所有代理、负载均衡器、网关或隧道的工作原理。它类似于将两个套接字连接在一起。我称之为套接字连接。如果可以避免任何协议层面的操作,那么构建代理就变成了在有连接传入时打开与后端的连接,然后将它们连接起来。

由于 Go 使用包的接口来建模连接io,我们可以利用这些io.Copy接口来简化从一个连接读取字节到另一个连接的过程,直到连接EOF被关闭。像这样的双向复制(完成后自动关闭)如下所示:



func join(a io.ReadWriteCloser, b io.ReadWriteCloser) {
    go io.Copy(b, a)
    io.Copy(a, b)
    a.Close()
    b.Close()
}


Enter fullscreen mode Exit fullscreen mode

这种极简方法的好处在于,它为连接设置了截止时间,以防一端未断开连接。可以看到,我们在后台将字节从一端复制a到另一端,然后等待字节从另一端复制到另一端,之后才确保在收到连接中断通知后两端都已关闭bbabEOF

我们还会定义一个辅助函数来生成随机子域名。在生产环境中,这至关重要。您肯定希望拥有极其庞大的子域名空间,但在这里,我们使用冒号 (:) 生成 10 个随机字母数字字符crypto/rand



func newSubdomain() string {
    b := make([]byte, 10)
    if _, err := rand.Read(b); err != nil {
        panic(err)
    }
    letters := []rune("abcdefghijklmnopqrstuvwxyz1234567890")
    r := make([]rune, 10)
    for i := range r {
        r[i] = letters[int(b[i])*len(letters)/256]
    }
    return string(r) + "."
}


Enter fullscreen mode Exit fullscreen mode

在简单的 Go 程序中,另一个常用的实用工具是致命函数。这有助于简化所有错误检查。



func fatal(err error) {
    if err != nil {
        log.Fatal(err)
    }
}


Enter fullscreen mode Exit fullscreen mode

现在我们可以开始编写main()包含客户端和服务端模式的代码了。不过,首先我们需要定义一些对两者都有用的标志:



var port = flag.String("p", "9999", "server port to use")
var host = flag.String("h", "vcap.me", "server hostname to use")
var addr = flag.String("b", "127.0.0.1", "ip to bind [server only]")
flag.Parse()


Enter fullscreen mode Exit fullscreen mode

默认主机名vcap.me是为了方便服务器在本地运行。由于localhost不支持子域名,但任何域名都可以解析到127.0.0.1,因此有一些公共域名的通配符子域名都可以解析到127.0.0.1。如果我们在实际服务器上运行此程序,则需要一个具有通配符子域名的域名,这些子域名可以解析到服务器 IP 地址,并且我们可能会使用端口号80而不是9999

现在我们从客户端开始,它非常简单。当有一个参数指定要公开的本地端口时,它就会被激活。



// client usage: groktunnel [-h=<server hostname>] <local port>
if flag.Arg(0) != "" {
    conn, err := net.Dial("tcp", net.JoinHostPort(*host, *port))
    fatal(err)
    client := httputil.NewClientConn(conn, bufio.NewReader(conn))
    req, err := http.NewRequest("GET", "/", nil)
    req.Host = net.JoinHostPort(*host, *port)
    fatal(err)
    client.Write(req)
    resp, _ := client.Read(req)
    fmt.Printf("port %s http available at:\n", flag.Arg(0))
    fmt.Printf("http://%s\n", resp.Header.Get("X-Public-Host"))
    c, _ := client.Hijack()
    sess := session.New(c)
    defer sess.Close()
    for {
        ch, err := sess.Accept()
        fatal(err)
        conn, err := net.Dial("tcp", "127.0.0.1:"+flag.Arg(0))
        fatal(err)
        go join(conn, ch)
    }
}


Enter fullscreen mode Exit fullscreen mode

这里我们通过 HTTP 建立隧道,因为我们无论如何都要提供 HTTP 服务,而且我们需要一种方法来告诉客户端生成的公共主机是什么,以便向用户显示 URL。可以看到,我们是从响应头中获取主机名的。

我们手动创建 HTTP 请求,是httputil.NewClientConn因为它允许我们劫持连接。这意味着它不再以 HTTP 方式处理,而是将其视为原始 TCP 连接。虽然我们也可以升级到 WebSocket,但这种方法同样有效,而且依赖项更少。

劫持成功后,我们创建一个 qmux 会话,通过该连接开始复用虚拟连接(或通道)。这就是隧道。我们不打开通道,只是在一个循环中等待传入的通道,并假定它们想要通过连接到我们的本地服务器加入。就是这样。

现在来看服务器端。首先,我们使用预设参数配置所有内容,包括一个 TCP 监听器,并将其封装在一个虚拟主机复用器中。服务器的核心代码已经准备就绪,serve()但它是异步运行的,因为我们会监听虚拟主机复用器的错误,直到服务器关闭。



// server usage: groktunnel [-h=<hostname>] [-b=<bind ip>]
l, err := net.Listen("tcp", net.JoinHostPort(*addr, *port))
fatal(err)
defer l.Close()
vmux, err := vhost.NewHTTPMuxer(l, 1*time.Second)
fatal(err)

go serve(vmux, *host, *port)

log.Printf("groktunnel server [%s] ready!\n", *host)
for {
    conn, err := vmux.NextError()
    fmt.Println(err)
    if conn != nil {
        conn.Close()
    }
}


Enter fullscreen mode Exit fullscreen mode

现在,这个系统的核心在于serve()



func serve(vmux *vhost.HTTPMuxer, host, port string) {
    ml, err := vmux.Listen(net.JoinHostPort(host, port))
    fatal(err)
    srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        publicHost := strings.TrimSuffix(net.JoinHostPort(newSubdomain()+host, port), ":80")
        pl, err := vmux.Listen(publicHost)
        fatal(err)
        w.Header().Add("X-Public-Host", publicHost)
        w.Header().Add("Connection", "close")
        w.WriteHeader(http.StatusOK)
        conn, _, _ := w.(http.Hijacker).Hijack()
        sess := session.New(conn)
        defer sess.Close()
        log.Printf("%s: start session", publicHost)
        go func() {
            for {
                conn, err := pl.Accept()
                if err != nil {
                    log.Println(err)
                    return
                }
                ch, err := sess.Open(context.Background())
                if err != nil {
                    log.Println(err)
                    return
                }
                go join(ch, conn)
            }
        }()
        sess.Wait()
        log.Printf("%s: end session", publicHost)
    })}
    srv.Serve(ml)
}


Enter fullscreen mode Exit fullscreen mode

这里我们使用传入的虚拟主机复用器创建一个新的监听器,用于监听裸域名(vcap.me)上的连接。然后我们创建一个HTTP处理程序来建立一个新的隧道。它首先生成一个新的主机名,并指示我们的虚拟主机复用器开始监听该主机名。我们将一直保留这个监听器。

然后我们开始写入请求头。记住,这是我们告诉客户端我们生成的主机名的方式。我们还会用它Connection: close来告诉对方我们即将完成并挂断连接,但在我们用200 OK状态刷新请求头之后,我们会像我们知道对方即将发生的那样劫持连接。我们创建一个新的 qmux 会话并启动一个新的 goroutine。

这个 goroutine 处理来自我们新虚拟主机监听器的连接。它的工作就是在一个循环中接受新连接,然后使用我们的 qmux 会话打开一个通道,最后将新连接加入到该通道中。

回到我们的 HTTP 处理程序中,我们调用Wait()会话,这将一直阻塞直到连接断开。此处理程序已部署到 HTTP 服务器实例中,该实例设置为服务于我们原始的虚拟主机监听器,用于监听裸vcap.me域名。

现在我们可以试一试了。您可以按照groktunnel README文件中的说明来查看其运行情况。

这个小程序提供了一个客户端和服务器,重现了原始的本地隧道系统。它不仅展示了隧道技术在连接复用中的应用,还展示了如何使用 Go 语言(主要使用标准库)轻松地实现各种功能。

如果你想隧道传输的是普通的 TCP 连接,而不是 HTTP 连接,这个程序会变得更加简单。不过,这留给读者自行探索。你可以在qmux项目的 demos 目录中找到完整的 groktunnel 源代码。希望它能激发你创造出一些酷炫的东西!

想第一时间收到更多类似文章,以及了解我的最新动态,请访问progrium.com订阅我的邮件列表✌️

文章来源:https://dev.to/progrium/building-your-own-ngrok-in-130-lines-2lif