tsnet: generalize loopback listener to include SOCKS5

Some languages do not give you any useful access to the sockets
underlying their networking packages. E.g. java.net.http.HttpClient
provides no official access to its dialing logic.

...but everyone supports proxies. So add a SOCKS5 proxy on the listener
we are already running.

(The function being revamped is very new,
I only added it in the last week and it wasn't part of any release,
so I believe it is fine to redo its function signature.)

Signed-off-by: David Crawshaw <crawshaw@tailscale.com>
This commit is contained in:
David Crawshaw 2023-03-05 14:09:33 -08:00 committed by David Crawshaw
parent df2561f6a2
commit 387b68fe11
2 changed files with 140 additions and 83 deletions

View File

@ -38,6 +38,8 @@
"tailscale.com/logtail" "tailscale.com/logtail"
"tailscale.com/logtail/filch" "tailscale.com/logtail/filch"
"tailscale.com/net/memnet" "tailscale.com/net/memnet"
"tailscale.com/net/proxymux"
"tailscale.com/net/socks5"
"tailscale.com/net/tsdial" "tailscale.com/net/tsdial"
"tailscale.com/smallzstd" "tailscale.com/smallzstd"
"tailscale.com/types/logger" "tailscale.com/types/logger"
@ -91,22 +93,23 @@ type Server struct {
// If empty, the Tailscale default is used. // If empty, the Tailscale default is used.
ControlURL string ControlURL string
initOnce sync.Once initOnce sync.Once
initErr error initErr error
lb *ipnlocal.LocalBackend lb *ipnlocal.LocalBackend
netstack *netstack.Impl netstack *netstack.Impl
linkMon *monitor.Mon linkMon *monitor.Mon
rootPath string // the state directory rootPath string // the state directory
hostname string hostname string
shutdownCtx context.Context shutdownCtx context.Context
shutdownCancel context.CancelFunc shutdownCancel context.CancelFunc
localAPICred string // basic auth password for localAPITCPListener proxyCred string // SOCKS5 proxy auth for loopbackListener
localAPITCPListener net.Listener // optional loopback, restricted to PID localAPICred string // basic auth password for loopbackListener
localAPIListener net.Listener // in-memory, used by localClient loopbackListener net.Listener // optional loopback for localapi and proxies
localClient *tailscale.LocalClient // in-memory localAPIListener net.Listener // in-memory, used by localClient
logbuffer *filch.Filch localClient *tailscale.LocalClient // in-memory
logtail *logtail.Logger logbuffer *filch.Filch
logid string logtail *logtail.Logger
logid string
mu sync.Mutex mu sync.Mutex
listeners map[listenKey]*listener listeners map[listenKey]*listener
@ -145,34 +148,49 @@ func (s *Server) LocalClient() (*tailscale.LocalClient, error) {
return s.localClient, nil return s.localClient, nil
} }
// LoopbackLocalAPI returns a loopback ip:port listening for the "LocalAPI". // Loopback starts a routing server on a loopback address.
// //
// 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.
//
// The HTTP server also serves out the "LocalAPI" on /localapi.
// As the LocalAPI is powerful, access to endpoints requires BOTH passing a // As the LocalAPI is powerful, access to endpoints requires BOTH passing a
// "Sec-Tailscale: localapi" HTTP header and passing cred as a basic auth. // "Sec-Tailscale: localapi" HTTP header and passing localAPICred as 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 // If you only need to use the LocalAPI from Go, then prefer LocalClient
// as it does not require communication via TCP. // 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 { 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 var cred [16]byte
if _, err := crand.Read(cred[:]); err != nil { if _, err := crand.Read(cred[:]); err != nil {
return "", "", err return "", "", "", err
} }
s.localAPICred = hex.EncodeToString(cred[:]) s.localAPICred = hex.EncodeToString(cred[:])
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { 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() { go func() {
lah := localapi.NewHandler(s.lb, s.logf, s.logid) lah := localapi.NewHandler(s.lb, s.logf, s.logid)
lah.PermitWrite = true lah.PermitWrite = true
@ -180,13 +198,23 @@ func (s *Server) LoopbackLocalAPI() (addr string, cred string, err error) {
lah.RequiredPassword = s.localAPICred lah.RequiredPassword = s.localAPICred
h := &localSecHandler{h: lah, cred: 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) 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 { type localSecHandler struct {
@ -301,8 +329,8 @@ func (s *Server) Close() error {
if s.localAPIListener != nil { if s.localAPIListener != nil {
s.localAPIListener.Close() s.localAPIListener.Close()
} }
if s.localAPITCPListener != nil { if s.loopbackListener != nil {
s.localAPITCPListener.Close() s.loopbackListener.Close()
} }
s.mu.Lock() s.mu.Lock()

View File

@ -11,11 +11,13 @@
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"golang.org/x/net/proxy"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
"tailscale.com/net/netns" "tailscale.com/net/netns"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@ -99,48 +101,37 @@ func startControl(t *testing.T) (controlURL string) {
return controlURL return controlURL
} }
func TestConn(t *testing.T) { func startServer(t *testing.T, ctx context.Context, controlURL, hostname string) (*Server, netip.Addr) {
controlURL := startControl(t) t.Helper()
tmp := t.TempDir() tmp := filepath.Join(t.TempDir(), hostname)
tmps1 := filepath.Join(tmp, "s1") os.MkdirAll(tmp, 0755)
os.MkdirAll(tmps1, 0755) s := &Server{
s1 := &Server{ Dir: tmp,
Dir: tmps1,
ControlURL: controlURL, ControlURL: controlURL,
Hostname: "s1", Hostname: hostname,
Store: new(mem.Store), Store: new(mem.Store),
Ephemeral: true, 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,
}
defer s2.Close()
if !*verboseNodes { if !*verboseNodes {
s1.Logf = logger.Discard s.Logf = logger.Discard
s2.Logf = logger.Discard
} }
t.Cleanup(func() { s.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) status, err := s.Up(ctx)
defer cancel()
s1status, err := s1.Up(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
s1ip := s1status.TailscaleIPs[0] return s, status.TailscaleIPs[0]
if _, err := s2.Up(ctx); err != nil { }
t.Fatal(err)
} func TestConn(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")
lc2, err := s2.LocalClient() lc2, err := s2.LocalClient()
if err != nil { if err != nil {
@ -187,31 +178,19 @@ func TestConn(t *testing.T) {
} }
func TestLoopbackLocalAPI(t *testing.T) { func TestLoopbackLocalAPI(t *testing.T) {
controlURL := startControl(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
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() defer cancel()
if _, err := s1.Up(ctx); err != nil { controlURL := startControl(t)
t.Fatal(err) s1, _ := startServer(t, ctx, controlURL, "s1")
}
addr, cred, err := s1.LoopbackLocalAPI() addr, proxyCred, localAPICred, err := s1.Loopback()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if proxyCred == localAPICred {
t.Fatal("proxy password matches local API password, they should be different")
}
url := "http://" + addr + "/localapi/v0/status" url := "http://" + addr + "/localapi/v0/status"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
@ -245,7 +224,7 @@ func TestLoopbackLocalAPI(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
req.SetBasicAuth("", cred) req.SetBasicAuth("", localAPICred)
res, err = http.DefaultClient.Do(req) res, err = http.DefaultClient.Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -260,7 +239,7 @@ func TestLoopbackLocalAPI(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
req.Header.Set("Sec-Tailscale", "localapi") req.Header.Set("Sec-Tailscale", "localapi")
req.SetBasicAuth("", cred) req.SetBasicAuth("", localAPICred)
res, err = http.DefaultClient.Do(req) res, err = http.DefaultClient.Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -270,3 +249,53 @@ func TestLoopbackLocalAPI(t *testing.T) {
t.Errorf("GET /status returned %d, want 200", res.StatusCode) 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)
}
}