From 30a37622b4875ae3d9701dede949bb5c5912a41c Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Mon, 1 Mar 2021 19:00:37 -0800 Subject: [PATCH] cmd/hello: break out local HTTP client into client/tailscale Signed-off-by: Brad Fitzpatrick --- client/tailscale/tailscale.go | 90 ++++++++++++++++++++++++++++++++++ cmd/hello/hello.go | 92 +++++++++-------------------------- 2 files changed, 113 insertions(+), 69 deletions(-) create mode 100644 client/tailscale/tailscale.go diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go new file mode 100644 index 000000000..69fc84eb1 --- /dev/null +++ b/client/tailscale/tailscale.go @@ -0,0 +1,90 @@ +// Copyright (c) 2021 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. + +// Package tailscale contains Tailscale client code. +package tailscale + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "strconv" + + "tailscale.com/safesocket" + "tailscale.com/tailcfg" +) + +// tsClient does HTTP requests to the local Tailscale daemon. +var tsClient = &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if addr != "local-tailscaled.sock:80" { + return nil, fmt.Errorf("unexpected URL address %q", addr) + } + // On macOS, when dialing from non-sandboxed program to sandboxed GUI running + // a TCP server on a random port, find the random port. For HTTP connections, + // we don't send the token. It gets added in an HTTP Basic-Auth header. + if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil { + var d net.Dialer + return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port)) + } + return safesocket.ConnectDefault() + }, + }, +} + +// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon. +// +// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4. +// +// The hostname must be "local-tailscaled.sock", even though it +// doesn't actually do any DNS lookup. The actual means of connecting to and +// authenticating to the local Tailscale daemon vary by platform. +// +// DoLocalRequest may mutate the request to add Authorization headers. +func DoLocalRequest(req *http.Request) (*http.Response, error) { + if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil { + req.SetBasicAuth("", token) + } + return tsClient.Do(req) +} + +// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port. +func WhoIs(ctx context.Context, remoteAddr string) (*tailcfg.WhoIsResponse, error) { + var ip string + if net.ParseIP(remoteAddr) != nil { + ip = remoteAddr + } else { + var err error + ip, _, err = net.SplitHostPort(remoteAddr) + if err != nil { + return nil, fmt.Errorf("invalid remoteAddr %q", remoteAddr) + } + } + req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/whois?ip="+url.QueryEscape(ip), nil) + if err != nil { + return nil, err + } + res, err := DoLocalRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + slurp, _ := ioutil.ReadAll(res.Body) + if res.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %s: %s", res.Status, slurp) + } + r := new(tailcfg.WhoIsResponse) + if err := json.Unmarshal(slurp, r); err != nil { + if max := 200; len(slurp) > max { + slurp = slurp[:max] + } + return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", slurp) + } + return r, nil +} diff --git a/cmd/hello/hello.go b/cmd/hello/hello.go index 61e6d15ac..fc7343150 100644 --- a/cmd/hello/hello.go +++ b/cmd/hello/hello.go @@ -10,19 +10,15 @@ import ( _ "embed" "encoding/json" "flag" - "fmt" "html/template" "io/ioutil" "log" "net" "net/http" - "net/url" "os" - "strconv" "strings" - "tailscale.com/safesocket" - "tailscale.com/tailcfg" + "tailscale.com/client/tailscale" ) var ( @@ -37,7 +33,7 @@ var embeddedTemplate string func main() { flag.Parse() if *testIP != "" { - res, err := whoIs(*testIP) + res, err := tailscale.WhoIs(context.Background(), *testIP) if err != nil { log.Fatal(err) } @@ -46,7 +42,14 @@ func main() { e.Encode(res) return } - if !devMode() { + if devMode() { + // Parse it optimistically + var err error + tmpl, err = template.New("home").Parse(embeddedTemplate) + if err != nil { + log.Printf("ignoring template error in dev mode: %v", err) + } + } else { if embeddedTemplate == "" { log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+") } @@ -76,24 +79,24 @@ func main() { log.Fatal(<-errc) } -func slurpHTML() string { - slurp, err := ioutil.ReadFile("hello.tmpl.html") - if err != nil { - log.Fatal(err) - } - return string(slurp) -} - func devMode() bool { return *httpsAddr == "" && *httpAddr != "" } func getTmpl() (*template.Template, error) { if devMode() { - return template.New("home").Parse(slurpHTML()) + tmplData, err := ioutil.ReadFile("hello.tmpl.html") + if os.IsNotExist(err) { + log.Printf("using baked-in template in dev mode; can't find hello.tmpl.html in current directory") + return tmpl, nil + } + return template.New("home").Parse(string(tmplData)) } return tmpl, nil } -var tmpl *template.Template // not used in dev mode, initialized by main after flag parse +// tmpl is the template used in prod mode. +// In dev mode it's only used if the template file doesn't exist on disk. +// It's initialized by main after flag parsing. +var tmpl *template.Template type tmplData struct { DisplayName string // "Foo Barberson" @@ -117,11 +120,6 @@ func root(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) return } - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - http.Error(w, "no remote addr", 500) - return - } tmpl, err := getTmpl() if err != nil { w.Header().Set("Content-Type", "text/plain") @@ -129,7 +127,7 @@ func root(w http.ResponseWriter, r *http.Request) { return } - who, err := whoIs(ip) + who, err := tailscale.WhoIs(r.Context(), r.RemoteAddr) var data tmplData if err != nil { if devMode() { @@ -143,11 +141,12 @@ func root(w http.ResponseWriter, r *http.Request) { IP: "100.1.2.3", } } else { - log.Printf("whois(%q) error: %v", ip, err) + log.Printf("whois(%q) error: %v", r.RemoteAddr, err) http.Error(w, "Your Tailscale works, but we failed to look you up.", 500) return } } else { + ip, _, _ := net.SplitHostPort(r.RemoteAddr) data = tmplData{ DisplayName: who.UserProfile.DisplayName, LoginName: who.UserProfile.LoginName, @@ -168,48 +167,3 @@ func firstLabel(s string) string { } return s } - -// tsSockClient does HTTP requests to the local Tailscale daemon. -// The hostname in the HTTP request is ignored. -var tsSockClient = &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - // On macOS, when dialing from non-sandboxed program to sandboxed GUI running - // a TCP server on a random port, find the random port. For HTTP connections, - // we don't send the token. It gets added in an HTTP Basic-Auth header. - if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil { - var d net.Dialer - return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port)) - } - return safesocket.ConnectDefault() - }, - }, -} - -func whoIs(ip string) (*tailcfg.WhoIsResponse, error) { - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/whois?ip="+url.QueryEscape(ip), nil) - if err != nil { - return nil, err - } - if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil { - req.SetBasicAuth("", token) - } - res, err := tsSockClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - slurp, _ := ioutil.ReadAll(res.Body) - if res.StatusCode != 200 { - return nil, fmt.Errorf("HTTP %s: %s", res.Status, slurp) - } - r := new(tailcfg.WhoIsResponse) - if err := json.Unmarshal(slurp, r); err != nil { - if max := 200; len(slurp) > max { - slurp = slurp[:max] - } - return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", slurp) - } - return r, nil -}