From 5fa502b5dc37da622df0b491a9e5ac0e7fc0c1d4 Mon Sep 17 00:00:00 2001 From: Nick O'Neill Date: Wed, 23 Mar 2022 11:33:53 -0700 Subject: [PATCH] cmd/proxy-to-grafana: use grafana's authproxy to log in tailnet users (#4208) Signed-off-by: Nick O'Neill --- cmd/proxy-to-grafana/proxy-to-grafana.go | 154 +++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 cmd/proxy-to-grafana/proxy-to-grafana.go diff --git a/cmd/proxy-to-grafana/proxy-to-grafana.go b/cmd/proxy-to-grafana/proxy-to-grafana.go new file mode 100644 index 000000000..561097486 --- /dev/null +++ b/cmd/proxy-to-grafana/proxy-to-grafana.go @@ -0,0 +1,154 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// proxy-to-grafana is a reverse proxy which identifies users based on their +// originating Tailscale identity and maps them to corresponding Grafana +// users, creating them if needed. +// +// It uses Grafana's AuthProxy feature: +// https://grafana.com/docs/grafana/latest/auth/auth-proxy/ +// +// Set the TS_AUTHKEY environment variable to have this server automatically +// join your tailnet, or look for the logged auth link on first start. +// +// Use this Grafana configuration to enable the auth proxy: +// +// ``` +// [auth.proxy] +// enabled = true +// header_name = X-WEBAUTH-USER +// header_property = username +// auto_sign_up = true +// whitelist = 127.0.0.1 +// headers = Name:X-WEBAUTH-NAME +// enable_login_token = true +// ``` +package main + +import ( + "context" + "crypto/tls" + "flag" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "tailscale.com/client/tailscale" + "tailscale.com/tailcfg" + "tailscale.com/tsnet" +) + +var ( + hostname = flag.String("hostname", "", "Tailscale hostname to serve on, used as the base name for MagicDNS or subdomain in your domain alias for HTTPS.") + backendAddr = flag.String("backend-addr", "", "Address of the Grafana server served over HTTP, in host:port format. Typically localhost:nnnn.") + tailscaleDir = flag.String("state-dir", "./", "Alternate directory to use for Tailscale state storage. If empty, a default is used.") + useHTTPS = flag.Bool("use-https", false, "Serve over HTTPS via your *.ts.net subdomain if enabled in Tailscale admin.") +) + +func main() { + flag.Parse() + if *hostname == "" || strings.Contains(*hostname, ".") { + log.Fatal("missing or invalid --hostname") + } + if *backendAddr == "" { + log.Fatal("missing --backend-addr") + } + ts := &tsnet.Server{ + Dir: *tailscaleDir, + Hostname: *hostname, + } + + url, err := url.Parse(fmt.Sprintf("http://%s", *backendAddr)) + if err != nil { + log.Fatalf("couldn't parse backend address: %v", err) + } + + proxy := httputil.NewSingleHostReverseProxy(url) + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + modifyRequest(req) + } + + var ln net.Listener + if *useHTTPS { + ln, err = ts.Listen("tcp", ":443") + ln = tls.NewListener(ln, &tls.Config{ + GetCertificate: tailscale.GetCertificate, + }) + + go func() { + // wait for tailscale to start before trying to fetch cert names + for i := 0; i < 60; i++ { + st, err := tailscale.Status(context.Background()) + if err != nil { + log.Fatal(err) + } + log.Printf("tailscale status: %v", st.BackendState) + if st.BackendState == "Running" { + break + } + time.Sleep(time.Second) + } + + l80, err := ts.Listen("tcp", ":80") + if err != nil { + log.Fatal(err) + } + name, ok := tailscale.ExpandSNIName(context.Background(), *hostname) + if !ok { + log.Fatalf("can't get hostname for https redirect") + } + if err := http.Serve(l80, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, fmt.Sprintf("https://%s", name), http.StatusMovedPermanently) + })); err != nil { + log.Fatal(err) + } + }() + } else { + ln, err = ts.Listen("tcp", ":80") + } + if err != nil { + log.Fatal(err) + } + log.Printf("proxy-to-grafana running at %v, proxying to %v", ln.Addr(), *backendAddr) + log.Fatal(http.Serve(ln, proxy)) +} + +func modifyRequest(req *http.Request) { + // with enable_login_token set to true, we get a cookie that handles + // auth for paths that are not /login + if req.URL.Path != "/login" { + return + } + + user, err := getTailscaleUser(req.Context(), req.RemoteAddr) + if err != nil { + log.Printf("error getting Tailscale user: %v", err) + return + } + + req.Header.Set("X-Webauth-User", user.LoginName) + req.Header.Set("X-Webauth-Name", user.DisplayName) +} + +func getTailscaleUser(ctx context.Context, ipPort string) (*tailcfg.UserProfile, error) { + whois, err := tailscale.WhoIs(ctx, ipPort) + if err != nil { + return nil, fmt.Errorf("failed to identify remote host: %w", err) + } + if len(whois.Node.Tags) != 0 { + return nil, fmt.Errorf("tagged nodes are not users") + } + if whois.UserProfile == nil || whois.UserProfile.LoginName == "" { + return nil, fmt.Errorf("failed to identify remote user") + } + + return whois.UserProfile, nil +}