Skip to content

Commit

Permalink
tsnet: add a LocalAPI listener on loopback, with basic auth
Browse files Browse the repository at this point in the history
This is for use by LocalAPI clients written in other languages that
don't appear to be able to talk HTTP over a socket (e.g.
java.net.http.HttpClient).

Signed-off-by: David Crawshaw <crawshaw@tailscale.com>
  • Loading branch information
crawshaw committed Feb 28, 2023
1 parent e3211ff commit 768df4f
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 39 deletions.
17 changes: 8 additions & 9 deletions ipn/localapi/localapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "server has no local backend", http.StatusInternalServerError)
return
}
if r.Referer() != "" || r.Header.Get("Origin") != "" || !validHost(r.Host) {
if r.Referer() != "" || r.Header.Get("Origin") != "" || !h.validHost(r.Host) {
metricInvalidRequests.Add(1)
http.Error(w, "invalid localapi request", http.StatusForbidden)
return
Expand Down Expand Up @@ -180,21 +180,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}

// validLocalHost allows either localhost or loopback IP hosts on platforms
// that use token security.
var validLocalHost = runtime.GOOS == "darwin" || runtime.GOOS == "ios" || runtime.GOOS == "android"
// validLocalHostForTesting allows loopback handlers without RequiredPassword for testing.
var validLocalHostForTesting = false

// validHost reports whether h is a valid Host header value for a LocalAPI request.
func validHost(h string) bool {
func (h *Handler) validHost(hostname string) bool {
// The client code sends a hostname of "local-tailscaled.sock".
switch h {
switch hostname {
case "", apitype.LocalAPIHost:
return true
}
if !validLocalHost {
return false
if !validLocalHostForTesting && h.RequiredPassword == "" {
return false // only allow localhost with basic auth or in tests
}
host, _, err := net.SplitHostPort(h)
host, _, err := net.SplitHostPort(hostname)
if err != nil {
return false
}
Expand Down
14 changes: 7 additions & 7 deletions ipn/localapi/localapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,28 @@ func TestValidHost(t *testing.T) {
}{
{"", true},
{apitype.LocalAPIHost, true},
{"localhost:9109", validLocalHost},
{"127.0.0.1:9110", validLocalHost},
{"[::1]:9111", validLocalHost},
{"localhost:9109", false},
{"127.0.0.1:9110", false},
{"[::1]:9111", false},
{"100.100.100.100:41112", false},
{"10.0.0.1:41112", false},
{"37.16.9.210:41112", false},
}

for _, test := range tests {
t.Run(test.host, func(t *testing.T) {
if got := validHost(test.host); got != test.valid {
h := &Handler{}
if got := h.validHost(test.host); got != test.valid {
t.Errorf("validHost(%q)=%v, want %v", test.host, got, test.valid)
}
})
}
}

func TestSetPushDeviceToken(t *testing.T) {
origValidLocalHost := validLocalHost
validLocalHost = true
validLocalHostForTesting = true
defer func() {
validLocalHost = origValidLocalHost
validLocalHostForTesting = false
}()

h := &Handler{
Expand Down
98 changes: 82 additions & 16 deletions tsnet/tsnet.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package tsnet

import (
"context"
crand "crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -88,19 +90,22 @@ type Server struct {
// If empty, the Tailscale default is used.
ControlURL string

initOnce sync.Once
initErr error
lb *ipnlocal.LocalBackend
netstack *netstack.Impl
linkMon *monitor.Mon
localAPIListener net.Listener
rootPath string // the state directory
hostname string
shutdownCtx context.Context
shutdownCancel context.CancelFunc
localClient *tailscale.LocalClient
logbuffer *filch.Filch
logtail *logtail.Logger
initOnce sync.Once
initErr error
lb *ipnlocal.LocalBackend
netstack *netstack.Impl
linkMon *monitor.Mon
rootPath string // the state directory
hostname string
shutdownCtx context.Context
shutdownCancel context.CancelFunc
localAPICred string // basic auth password for localAPITCPListener
localAPITCPListener net.Listener // optional loopback, restricted to PID
localAPIListener net.Listener // in-memory, used by localClient
localClient *tailscale.LocalClient // in-memory
logbuffer *filch.Filch
logtail *logtail.Logger
logid string

mu sync.Mutex
listeners map[listenKey]*listener
Expand Down Expand Up @@ -139,6 +144,64 @@ func (s *Server) LocalClient() (*tailscale.LocalClient, error) {
return s.localClient, nil
}

// LoopbackLocalAPI returns a loopback ip:port listening for the "LocalAPI".
//
// As the LocalAPI is powerful, access to endpoints requires BOTH passing a
// "Sec-Tailscale: localapi" HTTP header and passing cred as a basic auth.
//
// It will start the server and the local client listener if they have not
// been started yet.
//
// If you only need to use the LocalAPI from Go, then prefer LocalClient
// as it does not require communication via TCP.
func (s *Server) LoopbackLocalAPI() (addr string, cred string, err error) {
if err := s.Start(); err != nil {
return "", "", err
}

if s.localAPITCPListener == nil {
var cred [16]byte
if _, err := crand.Read(cred[:]); err != nil {
return "", "", err
}
s.localAPICred = hex.EncodeToString(cred[:])

ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", "", err
}
s.localAPITCPListener = ln

go func() {
lah := localapi.NewHandler(s.lb, s.logf, s.logid)
lah.PermitWrite = true
lah.PermitRead = true
lah.RequiredPassword = s.localAPICred
h := &localSecHandler{h: lah, cred: s.localAPICred}

if err := http.Serve(s.localAPITCPListener, h); err != nil {
s.logf("localapi tcp serve error: %v", err)
}
}()
}

return s.localAPITCPListener.Addr().String(), s.localAPICred, nil
}

type localSecHandler struct {
h http.Handler
cred string
}

func (h *localSecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Sec-Tailscale") != "localapi" {
w.WriteHeader(403)
io.WriteString(w, "missing 'Sec-Tailscale: localapi' header")
return
}
h.h.ServeHTTP(w, r)
}

// Start connects the server to the tailnet.
// Optional: any calls to Dial/Listen will also call Start.
func (s *Server) Start() error {
Expand Down Expand Up @@ -240,6 +303,9 @@ func (s *Server) Close() error {
if s.localAPIListener != nil {
s.localAPIListener.Close()
}
if s.localAPITCPListener != nil {
s.localAPITCPListener.Close()
}

s.mu.Lock()
defer s.mu.Unlock()
Expand Down Expand Up @@ -325,7 +391,7 @@ func (s *Server) start() (reterr error) {
if err := lpc.Validate(logtail.CollectionNode); err != nil {
return fmt.Errorf("logpolicy.Config.Validate for %v: %w", cfgPath, err)
}
logid := lpc.PublicID.String()
s.logid = lpc.PublicID.String()

s.logbuffer, err = filch.New(filepath.Join(s.rootPath, "tailscaled"), filch.Options{ReplaceStderr: false})
if err != nil {
Expand Down Expand Up @@ -399,7 +465,7 @@ func (s *Server) start() (reterr error) {
if s.Ephemeral {
loginFlags = controlclient.LoginEphemeral
}
lb, err := ipnlocal.NewLocalBackend(logf, logid, s.Store, s.dialer, eng, loginFlags)
lb, err := ipnlocal.NewLocalBackend(logf, s.logid, s.Store, s.dialer, eng, loginFlags)
if err != nil {
return fmt.Errorf("NewLocalBackend: %v", err)
}
Expand Down Expand Up @@ -435,7 +501,7 @@ func (s *Server) start() (reterr error) {
go s.printAuthURLLoop()

// Run the localapi handler, to allow fetching LetsEncrypt certs.
lah := localapi.NewHandler(lb, logf, logid)
lah := localapi.NewHandler(lb, logf, s.logid)
lah.PermitWrite = true
lah.PermitRead = true

Expand Down
105 changes: 98 additions & 7 deletions tsnet/tsnet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ import (
"flag"
"fmt"
"io"
"path/filepath"
"os"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"tailscale.com/ipn/store/mem"
"tailscale.com/net/netns"
"tailscale.com/tailcfg"
"tailscale.com/tstest/integration"
"tailscale.com/net/netns"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/types/logger"
)
Expand Down Expand Up @@ -63,7 +64,7 @@ func TestListenerPort(t *testing.T) {
var verboseDERP = flag.Bool("verbose-derp", false, "if set, print DERP and STUN logs")
var verboseNodes = flag.Bool("verbose-nodes", false, "if set, print tsnet.Server logs")

func TestConn(t *testing.T) {
func startControl(t *testing.T) (controlURL string) {
// Corp#4520: don't use netns for tests.
netns.SetEnabled(false)
t.Cleanup(func() {
Expand All @@ -81,14 +82,19 @@ func TestConn(t *testing.T) {
control.HTTPTestServer = httptest.NewUnstartedServer(control)
control.HTTPTestServer.Start()
t.Cleanup(control.HTTPTestServer.Close)
controlURL := control.HTTPTestServer.URL
controlURL = control.HTTPTestServer.URL
t.Logf("testcontrol listening on %s", controlURL)
return controlURL
}

func TestConn(t *testing.T) {
controlURL := startControl(t)

tmp := t.TempDir()
tmps1 := filepath.Join(tmp, "s1")
os.MkdirAll(tmps1, 0755)
s1 := &Server{
Dir: tmps1,
Dir: tmps1,
ControlURL: controlURL,
Hostname: "s1",
Store: new(mem.Store),
Expand All @@ -99,7 +105,7 @@ func TestConn(t *testing.T) {
tmps2 := filepath.Join(tmp, "s1")
os.MkdirAll(tmps2, 0755)
s2 := &Server{
Dir: tmps2,
Dir: tmps2,
ControlURL: controlURL,
Hostname: "s2",
Store: new(mem.Store),
Expand Down Expand Up @@ -167,3 +173,88 @@ func TestConn(t *testing.T) {
t.Errorf("got %q, want %q", got, want)
}
}

func TestLoopbackLocalAPI(t *testing.T) {
controlURL := startControl(t)

tmp := t.TempDir()
tmps1 := filepath.Join(tmp, "s1")
os.MkdirAll(tmps1, 0755)
s1 := &Server{
Dir: tmps1,
ControlURL: controlURL,
Hostname: "s1",
Store: new(mem.Store),
Ephemeral: true,
}
defer s1.Close()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if _, err := s1.Up(ctx); err != nil {
t.Fatal(err)
}

addr, cred, err := s1.LoopbackLocalAPI()
if err != nil {
t.Fatal(err)
}

url := "http://" + addr + "/localapi/v0/status"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
t.Fatal(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 403 {
t.Errorf("GET %s returned %d, want 403 without Sec- header", url, res.StatusCode)
}

req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Sec-Tailscale", "localapi")
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 401 {
t.Errorf("GET %s returned %d, want 401 without basic auth", url, res.StatusCode)
}

req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth("", cred)
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 403 {
t.Errorf("GET %s returned %d, want 403 without Sec- header", url, res.StatusCode)
}

req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Sec-Tailscale", "localapi")
req.SetBasicAuth("", cred)
res, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 200 {
t.Errorf("GET /status returned %d, want 200", res.StatusCode)
}
}

0 comments on commit 768df4f

Please sign in to comment.