diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 80746e053..a348285c7 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -38,6 +38,8 @@ import ( "tailscale.com/logtail" "tailscale.com/logtail/filch" "tailscale.com/net/memnet" + "tailscale.com/net/proxymux" + "tailscale.com/net/socks5" "tailscale.com/net/tsdial" "tailscale.com/smallzstd" "tailscale.com/types/logger" @@ -91,22 +93,23 @@ 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 - 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 + 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 + proxyCred string // SOCKS5 proxy auth for loopbackListener + localAPICred string // basic auth password for loopbackListener + loopbackListener net.Listener // optional loopback for localapi and proxies + 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 @@ -145,34 +148,49 @@ func (s *Server) LocalClient() (*tailscale.LocalClient, error) { return s.localClient, nil } -// LoopbackLocalAPI returns a loopback ip:port listening for the "LocalAPI". +// Loopback starts a routing server on a loopback address. // -// As the LocalAPI is powerful, access to endpoints requires BOTH passing a -// "Sec-Tailscale: localapi" HTTP header and passing cred as a basic auth. +// The server has multiple functions. +// +// It can be used as a SOCKS5 proxy onto the tailnet. +// Authentication is required with the username "tsnet" and +// the value of proxyCred used as the password. // -// It will start the server and the local client listener if they have not -// been started yet. +// The HTTP server also serves out the "LocalAPI" on /localapi. +// As the LocalAPI is powerful, access to endpoints requires BOTH passing a +// "Sec-Tailscale: localapi" HTTP header and passing localAPICred as basic auth. // // 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) { +func (s *Server) Loopback() (addr string, proxyCred, localAPICred string, err error) { if err := s.Start(); err != nil { - return "", "", err + return "", "", "", err } - if s.localAPITCPListener == nil { + if s.loopbackListener == nil { + var proxyCred [16]byte + if _, err := crand.Read(proxyCred[:]); err != nil { + return "", "", "", err + } + s.proxyCred = hex.EncodeToString(proxyCred[:]) + var cred [16]byte if _, err := crand.Read(cred[:]); err != nil { - return "", "", err + return "", "", "", err } s.localAPICred = hex.EncodeToString(cred[:]) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - return "", "", err + return "", "", "", err } - s.localAPITCPListener = ln + s.loopbackListener = ln + socksLn, httpLn := proxymux.SplitSOCKSAndHTTP(ln) + + // TODO: add HTTP proxy support. Probably requires factoring + // out the CONNECT code from tailscaled/proxy.go that uses + // httputil.ReverseProxy and adding auth support. go func() { lah := localapi.NewHandler(s.lb, s.logf, s.logid) lah.PermitWrite = true @@ -180,13 +198,23 @@ func (s *Server) LoopbackLocalAPI() (addr string, cred string, err error) { lah.RequiredPassword = s.localAPICred h := &localSecHandler{h: lah, cred: s.localAPICred} - if err := http.Serve(s.localAPITCPListener, h); err != nil { + if err := http.Serve(httpLn, h); err != nil { s.logf("localapi tcp serve error: %v", err) } }() + + s5s := &socks5.Server{ + Logf: logger.WithPrefix(s.logf, "socks5: "), + Dialer: s.dialer.UserDial, + Username: "tsnet", + Password: s.proxyCred, + } + go func() { + s.logf("SOCKS5 server exited: %v", s5s.Serve(socksLn)) + }() } - return s.localAPITCPListener.Addr().String(), s.localAPICred, nil + return s.loopbackListener.Addr().String(), s.proxyCred, s.localAPICred, nil } type localSecHandler struct { @@ -301,8 +329,8 @@ func (s *Server) Close() error { if s.localAPIListener != nil { s.localAPIListener.Close() } - if s.localAPITCPListener != nil { - s.localAPITCPListener.Close() + if s.loopbackListener != nil { + s.loopbackListener.Close() } s.mu.Lock() diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index 2058162f1..488c518d4 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -11,11 +11,13 @@ import ( "io" "net/http" "net/http/httptest" + "net/netip" "os" "path/filepath" "testing" "time" + "golang.org/x/net/proxy" "tailscale.com/ipn/store/mem" "tailscale.com/net/netns" "tailscale.com/tailcfg" @@ -99,48 +101,37 @@ func startControl(t *testing.T) (controlURL string) { return controlURL } -func TestConn(t *testing.T) { - controlURL := startControl(t) +func startServer(t *testing.T, ctx context.Context, controlURL, hostname string) (*Server, netip.Addr) { + t.Helper() - tmp := t.TempDir() - tmps1 := filepath.Join(tmp, "s1") - os.MkdirAll(tmps1, 0755) - s1 := &Server{ - Dir: tmps1, + tmp := filepath.Join(t.TempDir(), hostname) + os.MkdirAll(tmp, 0755) + s := &Server{ + Dir: tmp, ControlURL: controlURL, - Hostname: "s1", + Hostname: hostname, Store: new(mem.Store), Ephemeral: true, } - defer s1.Close() - - tmps2 := filepath.Join(tmp, "s1") - os.MkdirAll(tmps2, 0755) - s2 := &Server{ - Dir: tmps2, - ControlURL: controlURL, - Hostname: "s2", - Store: new(mem.Store), - Ephemeral: true, + if !*verboseNodes { + s.Logf = logger.Discard } - defer s2.Close() + t.Cleanup(func() { s.Close() }) - if !*verboseNodes { - s1.Logf = logger.Discard - s2.Logf = logger.Discard + status, err := s.Up(ctx) + if err != nil { + t.Fatal(err) } + return s, status.TailscaleIPs[0] +} +func TestConn(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - s1status, err := s1.Up(ctx) - if err != nil { - t.Fatal(err) - } - s1ip := s1status.TailscaleIPs[0] - if _, err := s2.Up(ctx); err != nil { - t.Fatal(err) - } + controlURL := startControl(t) + s1, s1ip := startServer(t, ctx, controlURL, "s1") + s2, _ := startServer(t, ctx, controlURL, "s2") lc2, err := s2.LocalClient() if err != nil { @@ -187,31 +178,19 @@ func TestConn(t *testing.T) { } 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) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if _, err := s1.Up(ctx); err != nil { - t.Fatal(err) - } + controlURL := startControl(t) + s1, _ := startServer(t, ctx, controlURL, "s1") - addr, cred, err := s1.LoopbackLocalAPI() + addr, proxyCred, localAPICred, err := s1.Loopback() if err != nil { t.Fatal(err) } + if proxyCred == localAPICred { + t.Fatal("proxy password matches local API password, they should be different") + } url := "http://" + addr + "/localapi/v0/status" req, err := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -245,7 +224,7 @@ func TestLoopbackLocalAPI(t *testing.T) { if err != nil { t.Fatal(err) } - req.SetBasicAuth("", cred) + req.SetBasicAuth("", localAPICred) res, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) @@ -260,7 +239,7 @@ func TestLoopbackLocalAPI(t *testing.T) { t.Fatal(err) } req.Header.Set("Sec-Tailscale", "localapi") - req.SetBasicAuth("", cred) + req.SetBasicAuth("", localAPICred) res, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) @@ -270,3 +249,53 @@ func TestLoopbackLocalAPI(t *testing.T) { t.Errorf("GET /status returned %d, want 200", res.StatusCode) } } + +func TestLoopbackSOCKS5(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + controlURL := startControl(t) + s1, s1ip := startServer(t, ctx, controlURL, "s1") + s2, _ := startServer(t, ctx, controlURL, "s2") + + addr, proxyCred, _, err := s2.Loopback() + if err != nil { + t.Fatal(err) + } + + ln, err := s1.Listen("tcp", ":8081") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + auth := &proxy.Auth{User: "tsnet", Password: proxyCred} + socksDialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct) + if err != nil { + t.Fatal(err) + } + + w, err := socksDialer.Dial("tcp", fmt.Sprintf("%s:8081", s1ip)) + if err != nil { + t.Fatal(err) + } + + r, err := ln.Accept() + if err != nil { + t.Fatal(err) + } + + want := "hello" + if _, err := io.WriteString(w, want); err != nil { + t.Fatal(err) + } + + got := make([]byte, len(want)) + if _, err := io.ReadAtLeast(r, got, len(got)); err != nil { + t.Fatal(err) + } + t.Logf("got: %q", got) + if string(got) != want { + t.Errorf("got %q, want %q", got, want) + } +}