Shogo's Blog

たぶんプログラミングとかについて書いていくブログ

ngrokみたいなHTTPプロキシを書いてみた

開発中のWebアプリをみんなに試してほしいけど、 サーバなんてなくて開発環境がローカルにしか無くて公開できないということは、 開発初期段階だとよくあることだと思います。 もちろん本格的にやるならテスト用にサーバを建てるべきですが、 小さなものならngrokを使うと簡単です。 ngrokの公開サーバへのHTTPリクエストをローカルにリレーして、 ローカルのサーバをお手がるに公開できるサービスです。

びっくりするほど簡単に公開できて便利ですが、 一応oAuthで制限とかかけたいなーとかカスタマイズしてみたくなってきたので、 似たようなものを自作できないかといろいろ遊んでみました。

その結果、HTTP2 over Websocketみたいな謎なものが出来上がってしまったというお話です。

HTTP2 over Websocketというアイデア

ngrokっぽいものを実現するためには、 サーバが受け取ったHTTPリクエストをローカルの環境に転送する必要があります。 ご存知のとおり通常のHTTPではサーバ側からのプッシュ配信が難しいので、Websocketを使うのが良さそうです。 しかし、複数のコネクションで並列にやってくるHTTPリクエストを、一本のWebsocketに束ねる必要があり、 上手く制御するのは大変そうです。

さて、HTTP2は一つのTCPコネクションで複数のリクエストを並行処理する仕様があります。 「複数のリクエストを一本に束ねる」という点ではなんか似ているので、なんだか流用できそうな気がしてきました。 Golangならきっと上手いことinterfaceを実装すれば、なんとかできるのではとやってみました。

実装

HTTP2は暗号化や複雑なフロー制御を行っていますが、 外から見ればnet.Connインターフェースに読み書きしている何かに過ぎません。 そして、websocket.Connnet.Connを実装しているので、そのままHTTP2のライブラリに渡せるはずです。

そうしてできたのが以下のサーバです。

server.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package main

import (
  "errors"
  "log"
  "net/http"
  "net/http/httputil"
  "sync"

  "golang.org/x/net/http2"
  "golang.org/x/net/websocket"
)

type transport struct {
  m      sync.Mutex
  t      http.RoundTripper
  closed chan struct{}
}

var t *transport

func main() {
  t = &transport{}
  s := websocket.Server{Handler: websocket.Handler(Handler)}
  http.Handle("/", s)
  go http.ListenAndServe(":3000", nil)
  http.ListenAndServe(":3001", &httputil.ReverseProxy{
      Transport: t,
      Director: func(req *http.Request) {
      },
  })
}

func Handler(ws *websocket.Conn) {
  log.Println("start new connection")
  t2 := &http2.Transport{}
  conn, err := t2.NewClientConn(ws)
  if err != nil {
      log.Println(err)
      return
  }

  t.m.Lock()
  if t.t != nil {
      t.m.Unlock()
      log.Println("already connected.")
      return
  }
  t.t = conn
  t.m.Unlock()
  <-t.closed
  log.Println("close connection")
}

func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
  t.m.Lock()
  t2 := t.t
  t.m.Unlock()
  if t2 == nil {
      return nil, errors.New("connection not found")
  }
  res, err := t2.RoundTrip(req)
  if err != nil {
      log.Println(err)
      t.m.Lock()
      t.t = nil
      t.m.Unlock()
      t.closed <- struct{}{}
      return nil, err
  }
  return res, nil
}

複数Websocketのコネクションが張られた場合の処理が少し煩雑ですが、思いのほか短くかけました。 3001番ポートに来たリクエストをWebsocket経由で転送します。 Websocketは3000番ポートで待ち受けです。

これにアクセスするためのクライアントがこちら。

client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
  "log"
  "net/http/httputil"
  "net/url"

  "golang.org/x/net/http2"
  "golang.org/x/net/websocket"
)

func main() {
  origin := "http://localhost:3000/"
  u := "ws://localhost:3000/"
  ws, err := websocket.Dial(u, "", origin)
  if err != nil {
      log.Fatal(err)
  }

  target, _ := url.Parse("http://localhost:8000/")

  s := &http2.Server{}
  s.ServeConn(ws, &http2.ServeConnOpts{
      Handler: httputil.NewSingleHostReverseProxy(target),
  })
}

Websocket経由でリクエストを受け付け、それを8000番ポートに転送します。 こちらも非常に短くかけました。 サーバーとクライアントを立ち上げてhttp://localhost:3001/にアクセスすると、 http://localhost:8000/の内容が見れるはずです。

ngrok1.xについて

ところでngrokの旧バージョンはソースコードが公開されているから、こっちを使ったほうが早い? でも、開発中止って書かれてて不安になる。

まとめ

ローカルのサーバをお手軽に公開するためのngrokというサービスを紹介しました。 自作のためのアイデアとして、http2 over websocketを試してみました。

設定の読み込みとかエラー処理とかセキュリティ周りとかいろいろ足りてない部分はありますが、 たったあれだけのコードで、ヘッダの圧縮転送、リクエストの並行処理等のHTTP2の機能が使えるのは面白いですね。

もうちょっと手を加えて多少は使えるものにしてみたいですね。

Comments