cmd/hello: break out local HTTP client into client/tailscale

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2021-03-01 19:00:37 -08:00
parent f647e3daaf
commit 30a37622b4
2 changed files with 113 additions and 69 deletions

View File

@ -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
}

View File

@ -10,19 +10,15 @@
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt"
"html/template" "html/template"
"io/ioutil" "io/ioutil"
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"strconv"
"strings" "strings"
"tailscale.com/safesocket" "tailscale.com/client/tailscale"
"tailscale.com/tailcfg"
) )
var ( var (
@ -37,7 +33,7 @@
func main() { func main() {
flag.Parse() flag.Parse()
if *testIP != "" { if *testIP != "" {
res, err := whoIs(*testIP) res, err := tailscale.WhoIs(context.Background(), *testIP)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -46,7 +42,14 @@ func main() {
e.Encode(res) e.Encode(res)
return 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 == "" { if embeddedTemplate == "" {
log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+") log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+")
} }
@ -76,24 +79,24 @@ func main() {
log.Fatal(<-errc) 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 devMode() bool { return *httpsAddr == "" && *httpAddr != "" }
func getTmpl() (*template.Template, error) { func getTmpl() (*template.Template, error) {
if devMode() { 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 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 { type tmplData struct {
DisplayName string // "Foo Barberson" DisplayName string // "Foo Barberson"
@ -117,11 +120,6 @@ func root(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
return return
} }
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(w, "no remote addr", 500)
return
}
tmpl, err := getTmpl() tmpl, err := getTmpl()
if err != nil { if err != nil {
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
@ -129,7 +127,7 @@ func root(w http.ResponseWriter, r *http.Request) {
return return
} }
who, err := whoIs(ip) who, err := tailscale.WhoIs(r.Context(), r.RemoteAddr)
var data tmplData var data tmplData
if err != nil { if err != nil {
if devMode() { if devMode() {
@ -143,11 +141,12 @@ func root(w http.ResponseWriter, r *http.Request) {
IP: "100.1.2.3", IP: "100.1.2.3",
} }
} else { } 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) http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
return return
} }
} else { } else {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
data = tmplData{ data = tmplData{
DisplayName: who.UserProfile.DisplayName, DisplayName: who.UserProfile.DisplayName,
LoginName: who.UserProfile.LoginName, LoginName: who.UserProfile.LoginName,
@ -168,48 +167,3 @@ func firstLabel(s string) string {
} }
return s 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
}