少し凝ったことを(簡単に)できるリバースプロキシサーバーをGoで書いてみた

リバースプロキシで少し凝ったことをする際、Nginx+Luaで頑張るなど、いくつか手法があります。しかし、(個人の意見ですが)どれも使い勝手がいいとは言えません。そこで、「使い勝手のよく、少し凝ったことのできるリバースプロキシサーバー」を作ってみました。

実装はGoで、レポジトリはここです。

github.com

名前はScript+ProxyでScriproxyです。安直ですね。

特徴

  • スクリプトを用いて、アップストリームサーバー(プロキシ先)を動的に設定できるリバースプロキシサーバーです。リクエストヘッダーの値や、URLクエリの値、リクエストパスなどをスクリプト内で用いることができます。
  • Tengoを使っており、Goのような直感的な文法でスクリプトを書くことができます。また、よくできた標準ライブラリもあります。
  • アップストリームを指定するだけでなく、リクエストヘッダーの値やクエリの値、リクエストパスを書き換えることもできます。

インストール

$ go install github.com/kkty/scriproxy

使い方

$ scriproxy --help
Usage:
  scriproxy [OPTIONS]

Application Options:
      --script=    The path to a tengo script for rewriting requests
      --libraries= The tengo libraries used in the script, separated by commas

Help Options:
  -h, --help       Show this help message

--scriptコマンドライン引数で、スクリプトへのパスを指定します。スクリプトの例は、次のセクションで紹介します。

Tengoを内部で使っています. Tengoには質の良い標準ライブラリがあり、それをスクリプト内で用いることもできます。その際は、--librariesコマンドライン引数で、使用しているライブラリを指定する必要があります。例えば、textライブラリとfmtライブラリを用いたい場合には--libraries=text,fmtのようにします。

つまり、具体的な起動コマンドは以下のようになります。

$ scriproxy --script /path/to/script.go --libraries fmt,text

スクリプト例

(実際のシチュエーションを想定したものというよりも、)Scriproxyによって何が可能なのかがわかりやすいものを紹介します。もちろん、これらを組み合わせて更に複雑なこともできます。

スクリプト内で使える値や関数については次のセクションで補足しています。

シンプルなプロキシ

// req.urlは、プロキシ先のURLに対応します
req.url.scheme = "https"
req.url.host = "example.com"

// req.hostは、リクエストヘッダの`Host: ...`に設定される値に対応します
// 通常はこれを設定する必要があります(多くの場合、req.url.hostと同じ値になります)
req.host = "example.com"

このスクリプトによって、プロキシサーバーに対して送られたリクエストはhttps://example.comに流されます。リクエストヘッダの値やクエリパラメータの値は変更されずにプロキシ先に送られます。

クエリパラメータを用いる場合

アップストリームサーバーの選択にクエリパラメータを用いることができます。

// クエリパラメータから値を取得する
req.url.host = req.url.query.get("host")
req.url.scheme = req.url.query.get("scheme")

// アップストリームサーバーへのリクエストにはそれらのパラメータが含まれないようにする
req.url.query.del("host")
req.url.query.del("scheme")

// これは多くのケースで必要!
req.host = req.url.host

このスクリプトを用いると、 /foo?host=example.com&scheme=httpへのリクエストはhttp://example.com/fooに、/foo?host=example.org&scheme=httpsへのリクエストはhttps://example.org/fooへと流されます。

Hostヘッダーを用いる場合

リクエストヘッダーのHostの値を用いてプロキシ先を指定することもできます。

// `--libraries=text`をコマンドライン引数で指定する必要があることに注意!
text := import("text")

// "example.com.local"や"example.com.secure.local"のような値がHostヘッダーとして指定されていることを前提としている

splitted := text.split(req.host, ".")
l := len(splitted)

if splitted[l-2] == "secure" {
  req.url.scheme = "https"
  req.url.host = text.join(splitted[:l-2], ".")
} else {
  req.url.scheme = "http"
  req.url.host = text.join(splitted[:l-1], ".")
}

req.host = req.url.host

(Scriproxyに対するリクエストの)リクエストヘッダにHead: example.com.secure.localが指定されていればhttps://example.comに、example.com.localが指定されていればhttp://example.comにリクエストがプロキシされます。

リクエストヘッダを用いる場合

Host以外のリクエストヘッダを用いることもできます。

// `--libraries=text`をコマンドライン引数で指定する必要があることに注意!
text := import("text")

// "User-Agent"ではなく"user-agent"とすることも可能
// 関数の挙動は https://golang.org/pkg/net/http/#Header.Get に沿っている
ua := req.header.get("user-agent")
ua = text.to_lower(ua)

if text.contains(ua, "iphone") {
  req.url.host = "example.com"
} else {
  req.url.host = "example.org"
}

// ユーザーエージェントの値を上書き
req.header.set("user-agent", "my-proxy")

req.url.scheme = "https"
req.host = req.url.host

このスクリプトを用いると、iPhoneからのリクエストはhttps://example.orgに、それ以外のリクエストはhttps://example.comに流されます。

req.header.set("host", "...")は機能しません。Hostヘッダーを書き換える際にはreq.hostを書き換える必要があります。これは、Goのhttp.Requestの挙動に沿っています。

補足

  • req.host, req.url.scheme, req.url.host and req.url.pathが値として使え、書き換えることもできます。
  • req.url.query.get("key"), req.url.query.set("key", "value"), req.url.query.del("key")の4つの関数がクエリパラメータの参照/書き換えに使用できます。
  • req.header.get("key"), req.header.set("key", "value"), req.header.add("key", "value"), req.header.del("key")の4つの関数がリクエストヘッダの参照/書き換えに使用できます。
  • 以上の値/関数の挙動はGoのhttp.Requestと似せています。
  • 4コアマシンで秒間数千リクエスト通せるはずです。
  • ロギングの機能も入れています。