この記事では App Engine Standard Environment(2nd gen) を用いて Kibana へのプロキシを作成する方法について紹介しています。
Elastic Cloud の一番安いプランで一つデプロイを持っているのですが、今回そこで可視化しているダッシュボードを不特定多数に公開したくなりました。
そこで困ったのが、Elastic Cloud には Anonymous ユーザ機能がない・・・。
ダッシュボードにアクセスさせるにはユーザにログインしてもらう必要があり、しかしパスワードとかは勝手に変更されないようにしなければならないので、みなさんどうやってるのか調べた結果、
- nginx や Apache でリバースプロキシ
- Bearer basic ヘッダーで自動でログイン
- https://{ユーザ名}:{パスワード}@yoursubdomain.yourdomain/kibana では自動ログインできないので、ヘッダーで送る必要がある(ただ、ヘッダーの方がユーザパスワードを iframe の url に書かなくていいので安心できそう
- パスワード変更のリクエストをピンポイントで弾く設定を入れる
として対応しているようでした。
この方針で問題ないのですが、自分の場合以下の問題が発生。
- nginx 立てたくない
- https なサイトに iframe でダッシュボードを表示したいので nginx を https 化することが必須
- Let's encrypt とかで自動化したとしても、念のため定期的に証明書の確認しなきゃいけないのが嫌だった
- nginx 立てるお金がない
- f1-micro 等無料枠の仮想マシンに建てるにしても証明書管理したくなかった
- Google App Engine Flexible なら Docker で http なリバプロ立てておけば Google が勝手に https な URL を作ってくれる(*.appspot.com)けど、Flexible Environment には無料枠がない
と言うわけで、App Engine Standard Environment で Go で適当なプロキシ作ることにしました。
特定のヘッダーにのみ気をつけて GET/POST プロキシさせるだけできれいに表示できた。
(けど、なんか他にいい方法ないですかね??)
package main
import (
"context"
"time"
"cloud.google.com/go/profiler"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.AppEngine = true
// Stackdriver Profiler
profiler.Start(profiler.Config{})
// login/logout は触れさせない
// router.Any("/login", proxy.Proxy)
// router.Any("/logout", proxy.Proxy)
router.Any("/bundles/*uri", proxy.Proxy)
router.Any("/built_assets/*uri", proxy.Proxy)
router.Any("/node_modules/*uri", proxy.Proxy)
router.Any("/translations/*uri", proxy.Proxy)
router.Any("/ui/*uri", proxy.Proxy)
router.Any("/api/*uri", proxy.Proxy)
router.Any("/s/yourspace/*uri", proxy.Proxy) // FIXME 「yourspace」と言う名前のスペース以外触れさせない
router.Run()
}
package proxy
import (
"encoding/base64"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const KibanaURL = "https://yourkibana.us-west-2.aws.found.io:9243" // FIXME
var Authorization = "Basic " + base64.StdEncoding.EncodeToString([]byte("youruser:yourpassword")) // FIXME
func replaceDomain(url string) string {
return strings.ReplaceAll(url, "https://yourgaeproject.appspot.com", KibanaURL) // FIXME
}
func Proxy(ctx *gin.Context) {
method := ctx.Request.Method
httpClient, err := httput.NewClient(ctx, time.Duration(20)*time.Second)
if err != nil {
ctx.Status(http.StatusInternalServerError)
return
}
if strings.EqualFold(method, "GET") {
ProxyGet(ctx, httpClient)
} else if strings.EqualFold(method, "POST") {
ProxyPost(ctx, httpClient)
} else {
ctx.Status(http.StatusMethodNotAllowed)
return
}
}
func ProxyGet(ctx *gin.Context, httpClient *httput.HTTPClient) {
uri := ctx.Request.URL.String()
if strings.Contains(uri, "/api/spaces/space") { // スペース一覧の表示を禁止
ctx.Status(http.StatusForbidden)
return
}
if strings.HasPrefix(uri, "/s/") && !strings.Contains(uri, "/yourspace/") { // FIXME yourspace スペース以外のスペースの表示を禁止
ctx.Status(http.StatusForbidden)
return
}
isStatic := strings.HasSuffix(uri, ".js") || strings.HasSuffix(uri, ".css") || strings.HasSuffix(uri, ".svg")
if isStatic {
staticContent, err := redisut.Get(ctx, uri).Result()
if err == nil { // Redis に入ってればそれを使う
if strings.HasSuffix(uri, ".js") {
ctx.Writer.Header().Add("Content-Type", "text/javascript")
} else if strings.HasSuffix(uri, ".css") {
ctx.Writer.Header().Add("Content-Type", "text/css")
} else if strings.HasSuffix(uri, ".svg") {
ctx.Writer.Header().Add("Content-Type", "image/svg+xml")
}
ctx.String(http.StatusOK, staticContent)
return
}
}
headers := make(map[string]string)
headers["Authorization"] = Authorization
if ctx.Request.Header.Get("kbn-version") != "" {
headers["kbn-version"] = ctx.Request.Header.Get("kbn-version")
}
if ctx.Request.Header.Get("User-Agent") != "" {
headers["User-Agent"] = ctx.Request.Header.Get("User-Agent")
}
if ctx.Request.Header.Get("Origin") != "" {
headers["Origin"] = replaceDomain(ctx.Request.Header.Get("Origin"))
}
if ctx.Request.Header.Get("Referer") != "" {
headers["Referer"] = replaceDomain(ctx.Request.Header.Get("Referer"))
}
resp, err := httpClient.GetURLResponse(ctx, KibanaURL+uri, "GET", headers)
if err != nil {
ctx.Status(http.StatusInternalServerError)
return
}
if resp.Header.Get("Location") != "" {
ctx.Redirect(resp.StatusCode, resp.Header.Get("Location"))
return
}
if resp.Header.Get("kbn-license-sig") != "" {
ctx.Writer.Header().Add("kbn-license-sig", resp.Header.Get("kbn-license-sig"))
}
if resp.Header.Get("kbn-name") != "" {
ctx.Writer.Header().Add("kbn-name", resp.Header.Get("kbn-name"))
}
if resp.Header.Get("kbn-xpack-sig") != "" {
ctx.Writer.Header().Add("kbn-xpack-sig", resp.Header.Get("kbn-xpack-sig"))
}
if resp.Header.Get("Content-Type") != "" {
ctx.Writer.Header().Add("Content-Type", resp.Header.Get("Content-Type"))
}
if resp.Header.Get("x-cloud-request-id") != "" {
ctx.Writer.Header().Add("x-cloud-request-id", resp.Header.Get("x-cloud-request-id"))
}
if resp.Header.Get("x-found-handling-cluster") != "" {
ctx.Writer.Header().Add("x-found-handling-cluster", resp.Header.Get("x-found-handling-cluster"))
}
if resp.Header.Get("x-found-handling-instance") != "" {
ctx.Writer.Header().Add("x-found-handling-instance", resp.Header.Get("x-found-handling-instance"))
}
if resp.Header.Get("x-found-handling-server") != "" {
ctx.Writer.Header().Add("x-found-handling-server", resp.Header.Get("x-found-handling-server"))
}
body, err := httput.GetBody(ctx, resp.Body, nil)
if err != nil {
ctx.Status(http.StatusInternalServerError)
return
}
if isStatic {
redisut.SetNX(ctx, uri, body, redisut.StaticFilesTTL)
}
ctx.String(resp.StatusCode, body)
}
func ProxyPost(ctx *gin.Context, httpClient *httput.HTTPClient) {
uri := ctx.Request.URL.String()
if strings.Contains(uri, "/internal/security/") { // パスワード等プロフィールの変更を禁止
ctx.Status(http.StatusForbidden)
return
}
if strings.HasPrefix(uri, "/s/") && !strings.Contains(uri, "/yourspace/") { // FIXME yourspace スペース以外の表示を禁止
ctx.Status(http.StatusForbidden)
return
}
headers := make(map[string]string)
headers["Authorization"] = Authorization
if ctx.Request.Header.Get("kbn-version") != "" {
headers["kbn-version"] = ctx.Request.Header.Get("kbn-version")
}
if ctx.Request.Header.Get("User-Agent") != "" {
headers["User-Agent"] = ctx.Request.Header.Get("User-Agent")
}
if ctx.Request.Header.Get("Content-Length") != "" {
headers["Content-Length"] = ctx.Request.Header.Get("Content-Length")
}
if ctx.Request.Header.Get("Content-Type") != "" {
headers["Content-Type"] = ctx.Request.Header.Get("Content-Type")
}
if ctx.Request.Header.Get("Origin") != "" {
headers["Origin"] = replaceDomain(ctx.Request.Header.Get("Origin"))
}
if ctx.Request.Header.Get("Referer") != "" {
headers["Referer"] = replaceDomain(ctx.Request.Header.Get("Referer"))
}
resp, err := httpClient.PostBodyURLResponse(ctx, KibanaURL+uri, ctx.Request.Body, headers)
if err != nil {
ctx.Status(http.StatusInternalServerError)
return
}
if resp.Header.Get("Location") != "" {
ctx.Redirect(resp.StatusCode, resp.Header.Get("Location"))
return
}
if resp.Header.Get("kbn-license-sig") != "" {
ctx.Writer.Header().Add("kbn-license-sig", resp.Header.Get("kbn-license-sig"))
}
if resp.Header.Get("kbn-name") != "" {
ctx.Writer.Header().Add("kbn-name", resp.Header.Get("kbn-name"))
}
if resp.Header.Get("kbn-xpack-sig") != "" {
ctx.Writer.Header().Add("kbn-xpack-sig", resp.Header.Get("kbn-xpack-sig"))
}
if resp.Header.Get("Content-Type") != "" {
ctx.Writer.Header().Add("Content-Type", resp.Header.Get("Content-Type"))
}
if resp.Header.Get("x-cloud-request-id") != "" {
ctx.Writer.Header().Add("x-cloud-request-id", resp.Header.Get("x-cloud-request-id"))
}
if resp.Header.Get("x-found-handling-cluster") != "" {
ctx.Writer.Header().Add("x-found-handling-cluster", resp.Header.Get("x-found-handling-cluster"))
}
if resp.Header.Get("x-found-handling-instance") != "" {
ctx.Writer.Header().Add("x-found-handling-instance", resp.Header.Get("x-found-handling-instance"))
}
if resp.Header.Get("x-found-handling-server") != "" {
ctx.Writer.Header().Add("x-found-handling-server", resp.Header.Get("x-found-handling-server"))
}
body, err := httput.GetBody(ctx, resp.Body, nil)
if err != nil {
ctx.Status(http.StatusInternalServerError)
return
}
ctx.String(resp.StatusCode, body)
}
(httput は GoでGET/POST を参照してください)
もちろん nginx とか立てた方が性能はいいと思います。
redis へのキャッシュも実際にファイルの更新あるかどうか関係なく時間決め打ちで適当なのでとりあえず動けばいい人向きです。