はじめに 夏の自由研究連載2021 の4日目で、TIG DXユニット真野です。
この記事では みんな 個人的に大好きなフォワードプロキシの概要と、Goでの既存のOSSライブラリを利用した実装例をまとめました。このテーマに決めた理由は以下です。
Goのnet/httpのクライアントは、https_proxy
の環境変数で差し込める(標準のデフォルトクライアントをそのまま利用する前提です)
差し込んだプロキシ側に、ロギングや認証やできればリトライを仕込めば色々役立つ事があるんじゃないかという調査
Goだとhttp.Clientは、RoundTripperというインタフェースを実装したTransportでカスタマイズ可能なので、実用だとこちらを利用したほうが良いと思います。
概念的にはサービスメッシュの文脈でのサイドカープロキシに近いものをイメージしています
自由研究という趣旨なので、そんなに実用性は考えず、興味ドリブンで手を動かそうと思いテーマに選びました。
フォワードプロキシとは 大きな括りではWebプロキシとも呼ばれることあるフォワードプロキシ(単にプロキシと呼ぶことも多いです)ですが、クライアントとWebサーバの中間に位置し、クライアントの要求を代理(proxy)してWebサーバにアクセスする存在です。ProxyとDockerと新人社員と時々わたし の記事にも詳しく書かれていますが、メリットとしてはキャッシュや接続先の通信の制限、ウィルススキャンを行うと言った余地をもたせることができる点でしょうか。Webエンジニアとしてのデメリットがあるとすると、利用するツール群でのプロキシ設定が大変だということがでしょうか。
たまにプロキシと書いてリバースプロキシ(私の周囲ではリバプロと略す人が多い)を指すブログ記事なども見かけますが、リバースプロキシとの差は、プロキシサーバがクライアント側にあるか、サーバ側にあるかの違いでしょう。今回は掲題にある通り、フォワードプロキシについてです.
自由研究でやりたいこと Goでローカル端末(同一プロセス)上にフォワードプロキシを立ち上げ、アプリ側には http_proxy
やhttps_proxy
の環境変数で先程のフォワードプロキシのFQDNを設定してアクセスさせること。
もし同一プロセス内に組み込む場合は、実現させるためにはローカルでフォワードプロキシのgoroutineを起動すること、フォワードプロキシのプロトコル(HTTPのCONNECTメソッドなど)を守って実装する必要があり、利用できるOSSなどを調査します。
リバースプロキシだと、net/http/httputil の ReverseProxy を利用すればかなり楽できそうなのですが、繰り返しますが今回構築するのはフォワードプロキシなので異なります。
実装 フォワードプロキシを実装するには、HTTP CONNECTメソッドなど所定のプロトコルを解釈させる必要があると思うのですが、elazarl/goproxy など有名なプロダクトがすでに存在したのでそちらを利用します。
goproxyという名前はgo module側のプロキシサーバ と勘違いしそうですが、それとは関係ないです。
goproxyはカスタマイズ可能なHTTPプロキシライブラリを提供するとREADMEに書いている通り、内部で利用するTransportなどが公開されているので自由度が高い印象を受けました。プロキシ自体は net/http
のハンドラーなので、コードもGoに慣れている人であれば比較的理解しやすいと思います。
goporxyをまずmain関数内で呼び出すミニマムな実装で試してみます。
package mainimport ( "crypto/tls" "fmt" "github.com/elazarl/goproxy" "io" "log" "net/http" "os" ) func main () { proxy := goproxy.NewProxyHttpServer() proxy.Tr = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true }, Proxy: nil } proxy.ConnectDial = nil proxy.Verbose = true go http.ListenAndServe(":8000" , proxy) resp, err := http.Get(os.Args[1 ]) if err != nil { log.Fatalln(err) } defer resp.Body.Close() all, err := io.ReadAll(resp.Body) if err != nil { log.Fatalln(err) } fmt.Println(string (all)) }
IPアドレスを取得する、 https://httpbin.org/ip というサイトにアクセスで試してみます。IP部分はなんとなく書き換えておきます。
> set http_proxy=http://localhost:8000 > set https_proxy=http://localhost:8000 > go run main.go http://httpbin.org/ip 2021/08/26 11:24:57 [001] INFO: Got request /ip httpbin.org GET http://httpbin.org/ip 2021/08/26 11:24:57 [001] INFO: Sending request GET http://httpbin.org/ip 2021/08/26 11:24:57 [001] INFO: Received response 200 OK 2021/08/26 11:24:57 [001] INFO: Copying response to client 200 OK [200] 2021/08/26 11:24:57 [001] INFO: Copied 30 bytes to client error=<nil> { "origin" : "111.98.xxx.x" } > go run main.go https://httpbin.org/ip 2021/08/26 11:26:16 [001] INFO: Running 0 CONNECT handlers 2021/08/26 11:26:16 [001] INFO: Accepting CONNECT to httpbin.org:443 "origin" : "111.98.90.1" }
上記のように実行してみると、proxy.Verbose = true
の設定をしていることもあり、goproxyでログ出力され、ローカルのフォワードプロキシを経由して通信されていることがわかります。
念の為、環境変数を外すると、直接外部に通信されることも確認します。
set http_proxy=set https_proxy=> go run main.go http://httpbin.org/ip { "origin" : "111.98.90.1" } > go run main.go https://httpbin.org/ip { "origin" : "111.98.90.1" }
goproxy側でログを出していないため、環境変数の有無で通信経路を変えられたようです。
goproxyで紹介されているユースケース READMEにはgoproxyの利用例もいくつか書かれていて興味深かったです。例えば、午前8時から午後17時までの時間帯にはアクセスを禁じる処理が紹介されていました。これは冗談寄りのアイデアだと思いますが、実用に近づけたユースケースを考えると障害テスト寄りのことを実現するときにも使えそうだなと思います。
proxy.OnRequest(goproxy.DstHostIs("www.reddit.com" )).DoFunc( func (r *http.Request,ctx *goproxy.ProxyCtx) (*http.Request,*http.Response) { if h,_,_ := time.Now().Clock(); h >= 8 && h <= 17 { return r,goproxy.NewResponse(r, goproxy.ContentTypeText,http.StatusForbidden, "Don't waste your time!" ) } return r,nil
もちろん、何かしらの認証やトレーサビリティに利用できそうな、リクエストヘッダへの差し込みも可能で、紹介されています。
proxy.OnRequest().DoFunc( func (r *http.Request,ctx *goproxy.ProxyCtx) (*http.Request,*http.Response) { r.Header.Set("X-GoProxy" ,"yxorPoG-X" ) return r,nil })
拡張ポイント 前の章で説明しましたが、goproxyを利用する場合は以下のような拡張ポイントが用意されています。
proxy.OnRequest(Some ReqConditions).HandleConnect(YourHandlerFunc()) proxy.OnRequest(Some ReqConditions).Do(YourReqHandlerFunc()) proxy.OnResponse(Some RespConditions).Do(YourRespHandlerFunc())
実装例はexamples フォルダに2021.08.26時点で14ほどの例があるので、大体何ができるかはここから追えると思います。
ミドルウェアでの拡張 goproxyもServeHTTPを実装されているため、よくあるmiddlewareでの拡張が可能です。
func exampleMiddleware (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { next.ServeHTTP(recorder, r) }) }
このmiddlewareを次のように呼び出します。
go http.ListenAndServe(":8000" , exampleMiddleware(proxy))
この形式であれば、既存資産のライブラリを流用しやすいかもしれません。
ミドルウェア拡張の注意 例えば、レスポンスコードを取得するために、 http.ResponseWriter をラップしたいときはよくあると思います。
type StatusRecorder struct { http.ResponseWriter Status int } func (r *StatusRecorder) WriteHeader(status int ) { r.Status = status r.ResponseWriter.WriteHeader(status) } func loggingMiddleware (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { recorder := &StatusRecorder{ResponseWriter: w} next.ServeHTTP(recorder, r) fmt.Println("★★★StatusCode" , recorder.Status) }) }
これをそのまま使うと、panicが発生するので注意です。
go http.ListenAndServe(":8000" , loggingMiddleware(proxy))
実行例 >go run main.go https://httpbin.org/ip 2021/08/26 12:22:16 http: panic serving [::1]:51340: httpserver does not support hijacking goroutine 5 [running]: net/http.(*conn).serve.func1() C:/Program Files/Go/src/net/http/server.go:1801 +0xb9 panic({0x7396a0, 0x7ee370}) C:/Program Files/Go/src/runtime/panic.go:1047 +0x266 github.com/elazarl/goproxy.(*ProxyHttpServer).handleHttps(0xc000119180, {0x7f5ee8, 0xc000226000}, 0xc000212000) C:/Users/manoj/go/pkg/mod/github.com/elazarl/goproxy@v0.0.0-20210801061803-8e322dfb79c4/https.go:84 +0x1479 github.com/elazarl/goproxy.(*ProxyHttpServer).ServeHTTP(0xc00011da30, {0x7f5ee8, 0xc000226000}, 0xc000212000) C:/Users/manoj/go/pkg/mod/github.com/elazarl/goproxy@v0.0.0-20210801061803-8e322dfb79c4/proxy.go:114 +0xd7 略
リクエストをラップするにはHijackインタフェースを実装する必要があるとのこと。そこで以下のレシーバーを追加します。
func (r *StatusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error ) { h, ok := r.ResponseWriter.(http.Hijacker) if !ok { return nil , nil , errors.New("hijack not supported" ) } return h.Hijack() }
そうするとステータスコードのロギングが成功します。
> go run main.go http: 2021 /08 /26 13 :16 :36 [001 ] INFO: Got request /ip httpbin.org GET http:2021 /08 /26 13 :16 :36 [001 ] INFO: Sending request GET http:2021 /08 /26 13 :16 :36 [001 ] INFO: Received response 200 OK2021 /08 /26 13 :16 :36 [001 ] INFO: Copying response to client 200 OK [200 ]2021 /08 /26 13 :16 :36 [001 ] INFO: Copied 30 bytes to client error =<nil >★★★StatusCode 200 { "origin" : "111.98.xx.x" }
注意ですが、HTTPS通信だとこの実装ではステータスが取れません。Man in The Middel Proxyの仕組みを構築する必要があるのでそこまでガンバるかどうかでしょうか(この制約が、圧倒的に使い勝手の面でhttp.Client側のTransportに比べて面倒だなと感じることができました)
>go run main.go https: 2021 /08 /26 13 :17 :39 [001 ] INFO: Running 0 CONNECT handlers2021 /08 /26 13 :17 :39 [001 ] INFO: Accepting CONNECT to httpbin.org:443 ★★★ 0 { "origin" : "111.98.xx.x" }
HTTPSの宛先では、★★★ 0
とステータスが取れていないことがわかります。
さいごに 環境変数(http_proxy, https_proxy, no_proxy)などで差し込めるフォワードプロキシをローカル(に近いところ)で利用して、何かしらの共通処理を用いれないかという自由研究でした。接続先のサーバレスポンスによって処理を切り替えたい(例えばリトライしたい)というときには、Man in The Middel Proxyの考慮が必要で(特別な証明書を準備し、クライアントに読み込ませる必要がある)、気軽に導入するにはハードルが高いです。
実用性に近い部分では、リクエスト側に何かしらエンリッチ(認証情報やトレース情報)するケースや、カオスエンジニアなどの障害テストを行うときには少し便利かもしれません。障害テストはプロキシという要素が1つ増えているので、どうしてもスタブを作るのが面倒な時にサポート用途に使えるかも? という具合でしょうか。