フューチャー技術ブログ

ローカルフォワードプロキシでHTTP要求時に機能横断な処理を差し込み

はじめに

夏の自由研究連載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_proxyhttps_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 main

import (
"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]) // 引数で渡されたURLにアクセス
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

# HTTP側のエンドポイントにアクセス
> 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"
}

# HTTPS側のエンドポイントにアクセス
> 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=

# HTTP, HTTPSでアクセス(ローカルのプロキシに経由していないことを確認)
> 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を利用する場合は以下のような拡張ポイントが用意されています。

// Add handlers to httpsHandlers
proxy.OnRequest(Some ReqConditions).HandleConnect(YourHandlerFunc())

// Add handlers to reqHandlers
proxy.OnRequest(Some ReqConditions).Do(YourReqHandlerFunc())

// Add handlers to respHandlers
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://httpbin.org/ip
2021/08/26 13:16:36 [001] INFO: Got request /ip httpbin.org GET http://httpbin.org/ip
2021/08/26 13:16:36 [001] INFO: Sending request GET http://httpbin.org/ip
2021/08/26 13:16:36 [001] INFO: Received response 200 OK
2021/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://httpbin.org/ip
2021/08/26 13:17:39 [001] INFO: Running 0 CONNECT handlers
2021/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つ増えているので、どうしてもスタブを作るのが面倒な時にサポート用途に使えるかも? という具合でしょうか。