Browse Source

tsnet: add ListenFunnel

This lets a tsnet binary share a server out over Tailscale Funnel.

Signed-off-by: David Crawshaw <[email protected]>
Signed-off-by: Maisem Ali <[email protected]>
Signed-off-by: Shayne Sweeney <[email protected]>
Maisem Ali 3 years ago
parent
commit
ccdd534e81
6 changed files with 243 additions and 171 deletions
  1. 1 22
      cmd/tailscale/cli/serve.go
  2. 0 24
      cmd/tailscale/cli/serve_test.go
  3. 23 0
      ipn/serve.go
  4. 33 0
      ipn/serve_test.go
  5. 16 106
      tsnet/example/tsnet-funnel/tsnet-funnel.go
  6. 170 19
      tsnet/tsnet.go

+ 1 - 22
cmd/tailscale/cli/serve.go

@@ -21,10 +21,8 @@ import (
 	"strings"
 
 	"github.com/peterbourgon/ff/v3/ffcli"
-	"golang.org/x/exp/slices"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn/ipnstate"
-	"tailscale.com/tailcfg"
 	"tailscale.com/util/mak"
 	"tailscale.com/version"
 )
@@ -679,7 +677,7 @@ func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error {
 	if err != nil {
 		return fmt.Errorf("getting client status: %w", err)
 	}
-	if err := checkHasAccess(st.Self.Capabilities); err != nil {
+	if err := ipn.CheckFunnelAccess(st.Self.Capabilities); err != nil {
 		return err
 	}
 	dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
@@ -702,22 +700,3 @@ func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error {
 	}
 	return nil
 }
-
-// checkHasAccess checks three things: 1) an invite was used to join the
-// Funnel alpha; 2) HTTPS is enabled; 3) the node has the "funnel" attribute.
-// If any of these are false, an error is returned describing the problem.
-//
-// The nodeAttrs arg should be the node's Self.Capabilities which should contain
-// the attribute we're checking for and possibly warning-capabilities for Funnel.
-func checkHasAccess(nodeAttrs []string) error {
-	if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) {
-		return errors.New("Funnel not available; an invite is required to join the alpha. See https://tailscale.com/kb/1223/tailscale-funnel/.")
-	}
-	if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoHTTPS) {
-		return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/kb/1153/enabling-https/.")
-	}
-	if !slices.Contains(nodeAttrs, tailcfg.NodeAttrFunnel) {
-		return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/kb/1223/tailscale-funnel/.")
-	}
-	return nil
-}

+ 0 - 24
cmd/tailscale/cli/serve_test.go

@@ -48,30 +48,6 @@ func TestCleanMountPoint(t *testing.T) {
 	}
 }
 
-func TestCheckHasAccess(t *testing.T) {
-	tests := []struct {
-		caps    []string
-		wantErr bool
-	}{
-		{[]string{}, true}, // No "funnel" attribute
-		{[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true},
-		{[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true},
-		{[]string{tailcfg.NodeAttrFunnel}, false},
-	}
-	for _, tt := range tests {
-		err := checkHasAccess(tt.caps)
-		switch {
-		case err != nil && tt.wantErr,
-			err == nil && !tt.wantErr:
-			continue
-		case tt.wantErr:
-			t.Fatalf("got no error, want error")
-		case !tt.wantErr:
-			t.Fatalf("got error %v, want no error", err)
-		}
-	}
-}
-
 func TestServeConfigMutations(t *testing.T) {
 	// Stateful mutations, starting from an empty config.
 	type step struct {

+ 23 - 0
ipn/serve.go

@@ -4,8 +4,12 @@
 package ipn
 
 import (
+	"errors"
 	"net"
 	"net/netip"
+
+	"golang.org/x/exp/slices"
+	"tailscale.com/tailcfg"
 )
 
 // ServeConfigKey returns a StateKey that stores the
@@ -168,3 +172,22 @@ func (sc *ServeConfig) IsFunnelOn() bool {
 	}
 	return false
 }
+
+// CheckFunnelAccess checks three things: 1) an invite was used to join the
+// Funnel alpha; 2) HTTPS is enabled; 3) the node has the "funnel" attribute.
+// If any of these are false, an error is returned describing the problem.
+//
+// The nodeAttrs arg should be the node's Self.Capabilities which should contain
+// the attribute we're checking for and possibly warning-capabilities for Funnel.
+func CheckFunnelAccess(nodeAttrs []string) error {
+	if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) {
+		return errors.New("Funnel not available; an invite is required to join the alpha. See https://tailscale.com/kb/1223/tailscale-funnel/.")
+	}
+	if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoHTTPS) {
+		return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/kb/1153/enabling-https/.")
+	}
+	if !slices.Contains(nodeAttrs, tailcfg.NodeAttrFunnel) {
+		return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/kb/1223/tailscale-funnel/.")
+	}
+	return nil
+}

+ 33 - 0
ipn/serve_test.go

@@ -0,0 +1,33 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+package ipn
+
+import (
+	"testing"
+
+	"tailscale.com/tailcfg"
+)
+
+func TestCheckFunnelAccess(t *testing.T) {
+	tests := []struct {
+		caps    []string
+		wantErr bool
+	}{
+		{[]string{}, true}, // No "funnel" attribute
+		{[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true},
+		{[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true},
+		{[]string{tailcfg.NodeAttrFunnel}, false},
+	}
+	for _, tt := range tests {
+		err := CheckFunnelAccess(tt.caps)
+		switch {
+		case err != nil && tt.wantErr,
+			err == nil && !tt.wantErr:
+			continue
+		case tt.wantErr:
+			t.Fatalf("got no error, want error")
+		case !tt.wantErr:
+			t.Fatalf("got error %v, want no error", err)
+		}
+	}
+}

+ 16 - 106
tsnet/example/tsnet-funnel/tsnet-funnel.go

@@ -2,130 +2,40 @@
 // SPDX-License-Identifier: BSD-3-Clause
 
 // The tsnet-funnel server demonstrates how to use tsnet with Funnel.
+//
+// To use it, generate an auth key from the Tailscale admin panel and
+// run the demo with the key:
+//
+//	TS_AUTHKEY=<yourkey> go run tsnet-funnel.go
 package main
 
 import (
-	"context"
-	"crypto/tls"
-	"errors"
 	"flag"
 	"fmt"
 	"log"
-	"net"
 	"net/http"
-	"net/netip"
 
-	"tailscale.com/ipn"
 	"tailscale.com/tsnet"
+	"tailscale.com/types/logger"
 )
 
-var (
-	addr = flag.String("addr", ":443", "address to listen on")
-)
-
-func enableFunnel(ctx context.Context, s *tsnet.Server) error {
-	st, err := s.Up(ctx)
-	if err != nil {
-		return err
-	}
-	if len(st.CertDomains) == 0 {
-		return errors.New("tsnet: you must enable HTTPS in the admin panel to proceed")
-	}
-	domain := st.CertDomains[0]
-
-	hp := ipn.HostPort(net.JoinHostPort(domain, "443"))
-
-	srvConfig := &ipn.ServeConfig{
-		AllowFunnel: map[ipn.HostPort]bool{
-			hp: true,
-		},
-	}
-	lc, err := s.LocalClient()
-	if err != nil {
-		return err
-	}
-	return lc.SetServeConfig(ctx, srvConfig)
-}
-
 func main() {
 	flag.Parse()
-	s := new(tsnet.Server)
-	defer s.Close()
-	ctx := context.Background()
-	if err := enableFunnel(ctx, s); err != nil {
-		log.Fatal(err)
+	s := &tsnet.Server{
+		Dir:      "./funnel-demo-config.state",
+		Logf:     logger.Discard,
+		Hostname: "fun",
 	}
+	defer s.Close()
 
-	ln, err := s.Listen("tcp", *addr)
+	ln, err := s.ListenFunnel("tcp", ":443")
 	if err != nil {
 		log.Fatal(err)
 	}
 	defer ln.Close()
 
-	lc, err := s.LocalClient()
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	ln = tls.NewListener(ln, &tls.Config{
-		GetCertificate: lc.GetCertificate,
-	})
-	httpServer := &http.Server{
-		ConnContext: func(ctx context.Context, c net.Conn) context.Context {
-			if tc, ok := c.(*tls.Conn); ok {
-				// First unwrap the TLS connection to get the underlying
-				// net.Conn.
-				c = tc.NetConn()
-			}
-			// Then check if the underlying net.Conn is a FunnelConn.
-			if fc, ok := c.(*ipn.FunnelConn); ok {
-				ctx = context.WithValue(ctx, funnelKey{}, true)
-				ctx = context.WithValue(ctx, funnelSrcKey{}, fc.Src)
-			}
-			return ctx
-		},
-		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			if isFunnel(r.Context()) {
-				fmt.Fprintln(w, "<html><body><h1>Hello, internet!</h1>")
-				fmt.Fprintln(w, "<p>You are connected over the internet!</p>")
-				fmt.Fprintf(w, "<p>You are coming from %v</p></html>\n", funnelSrc(r.Context()))
-			} else {
-				fmt.Fprintln(w, "<html><body><h1>Hello, tailnet!</h1>")
-				fmt.Fprintln(w, "<p>You are connected over the tailnet!</p>")
-				who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
-				if err != nil {
-					log.Printf("WhoIs(%v): %v", r.RemoteAddr, err)
-					fmt.Fprintf(w, "<p>I do not know who you are</p>")
-				} else if len(who.Node.Tags) > 0 {
-					fmt.Fprintf(w, "<p>You are using a tagged node: %v</p>\n", who.Node.Tags)
-				} else {
-					fmt.Fprintf(w, "<p>You are %v</p>\n", who.UserProfile.DisplayName)
-				}
-				fmt.Fprintf(w, "<p>You are coming from %v</p></html>\n", r.RemoteAddr)
-			}
-		}),
-	}
-	log.Fatal(httpServer.Serve(ln))
-}
-
-// funnelKey is a context key used to indicate that a request is coming
-// over the internet.
-// It is not used by tsnet, but is used by this example to demonstrate
-// how to detect when a request is coming over the internet rather than
-// over the tailnet.
-type funnelKey struct{}
-
-// funnelSrcKey is a context key used to indicate the source of a
-// request.
-type funnelSrcKey struct{}
-
-// isFunnel reports whether the request is coming over the internet.
-func isFunnel(ctx context.Context) bool {
-	v, _ := ctx.Value(funnelKey{}).(bool)
-	return v
-}
-
-func funnelSrc(ctx context.Context) netip.AddrPort {
-	v, _ := ctx.Value(funnelSrcKey{}).(netip.AddrPort)
-	return v
+	err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintln(w, "<html><body><h1>Hello, internet!</h1>")
+	}))
+	log.Fatal(err)
 }

+ 170 - 19
tsnet/tsnet.go

@@ -9,6 +9,7 @@ package tsnet
 import (
 	"context"
 	crand "crypto/rand"
+	"crypto/tls"
 	"encoding/hex"
 	"errors"
 	"fmt"
@@ -415,9 +416,9 @@ func (s *Server) start() (reterr error) {
 		if err != nil {
 			return err
 		}
-		if err := os.MkdirAll(s.rootPath, 0700); err != nil {
-			return err
-		}
+	}
+	if err := os.MkdirAll(s.rootPath, 0700); err != nil {
+		return err
 	}
 	if fi, err := os.Stat(s.rootPath); err != nil {
 		return err
@@ -645,7 +646,7 @@ func networkForFamily(netBase string, is6 bool) string {
 //   - ("tcp", "", port)
 //
 // The netBase is "tcp" or "udp" (without any '4' or '6' suffix).
-func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort) (_ *listener, ok bool) {
+func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort, funnel bool) (_ *listener, ok bool) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	for _, a := range [2]netip.Addr{0: dst.Addr()} {
@@ -653,7 +654,7 @@ func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort) (_ *list
 			networkForFamily(netBase, dst.Addr().Is6()),
 			netBase,
 		} {
-			if ln, ok := s.listeners[listenKey{net, a, dst.Port()}]; ok {
+			if ln, ok := s.listeners[listenKey{net, a, dst.Port(), funnel}]; ok {
 				return ln, true
 			}
 		}
@@ -675,7 +676,7 @@ func (s *Server) getTCPHandlerForFunnelFlow(src netip.AddrPort, dstPort uint16)
 		}
 		dst = netip.AddrPortFrom(ipv6, dstPort)
 	}
-	ln, ok := s.listenerForDstAddr("tcp", dst)
+	ln, ok := s.listenerForDstAddr("tcp", dst, true)
 	if !ok {
 		return nil
 	}
@@ -683,7 +684,7 @@ func (s *Server) getTCPHandlerForFunnelFlow(src netip.AddrPort, dstPort uint16)
 }
 
 func (s *Server) getTCPHandlerForFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
-	ln, ok := s.listenerForDstAddr("tcp", dst)
+	ln, ok := s.listenerForDstAddr("tcp", dst, false)
 	if !ok {
 		return nil, true // don't handle, don't forward to localhost
 	}
@@ -691,7 +692,7 @@ func (s *Server) getTCPHandlerForFlow(src, dst netip.AddrPort) (handler func(net
 }
 
 func (s *Server) getUDPHandlerForFlow(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
-	ln, ok := s.listenerForDstAddr("udp", dst)
+	ln, ok := s.listenerForDstAddr("udp", dst, false)
 	if !ok {
 		return nil, true // don't handle, don't forward to localhost
 	}
@@ -760,6 +761,136 @@ func (s *Server) APIClient() (*tailscale.Client, error) {
 // Listen announces only on the Tailscale network.
 // It will start the server if it has not been started yet.
 func (s *Server) Listen(network, addr string) (net.Listener, error) {
+	return s.listen(network, addr, listenOnTailnet)
+}
+
+// ListenTLS announces only on the Tailscale network.
+// It returns a TLS listener wrapping the tsnet listener.
+// It will start the server if it has not been started yet.
+func (s *Server) ListenTLS(network string, addr string) (net.Listener, error) {
+	if network != "tcp" {
+		return nil, fmt.Errorf("ListenTLS(%q, %q): only tcp is supported", network, addr)
+	}
+	ctx := context.Background()
+	st, err := s.Up(ctx)
+	if err != nil {
+		return nil, err
+	}
+	if len(st.CertDomains) == 0 {
+		return nil, errors.New("tsnet: you must enable HTTPS in the admin panel to proceed")
+	}
+
+	lc, err := s.LocalClient() // do local client first before listening.
+	if err != nil {
+		return nil, err
+	}
+
+	ln, err := s.listen(network, addr, listenOnTailnet)
+	if err != nil {
+		return nil, err
+	}
+	return tls.NewListener(ln, &tls.Config{
+		GetCertificate: lc.GetCertificate,
+	}), nil
+}
+
+// FunnelOption is an option passed to ListenFunnel to configure the listener.
+type FunnelOption interface {
+	funnelOption()
+}
+
+type funnelOnly int
+
+func (funnelOnly) funnelOption() {}
+
+// FunnelOnly configures the listener to only respond to connections from Tailscale Funnel.
+// The local tailnet will not be able to connect to the listener.
+func FunnelOnly() FunnelOption { return funnelOnly(1) }
+
+// ListenFunnel announces on the public internet using Tailscale Funnel.
+//
+// It also by default listens on your local tailnet, so connections can
+// come from either inside or outside your network. To restrict connections
+// to be just from the internet, use the FunnelOnly option.
+//
+// Currently (2023-03-10), Funnel only supports TCP on ports 443, 8443, and 10000.
+// The supported host name is limited to that configured for the tsnet.Server.
+// As such, the standard way to create funnel is:
+//
+//	s.ListenFunnel("tcp", ":443")
+//
+// and the only other supported addrs currently are ":8443" and ":10000".
+//
+// It will start the server if it has not been started yet.
+func (s *Server) ListenFunnel(network string, addr string, opts ...FunnelOption) (net.Listener, error) {
+	if network != "tcp" {
+		return nil, fmt.Errorf("ListenFunnel(%q, %q): only tcp is supported", network, addr)
+	}
+	switch addr {
+	case ":443", ":8443", ":10000":
+	default:
+		return nil, fmt.Errorf(`ListenFunnel(%q, %q): only valid addrs are ":443", ":8443", and ":10000"`, network, addr)
+	}
+	ctx := context.Background()
+	st, err := s.Up(ctx)
+	if err != nil {
+		return nil, err
+	}
+	if len(st.CertDomains) == 0 {
+		return nil, errors.New("tsnet: you must enable HTTPS in the admin panel to proceed")
+	}
+	if err := ipn.CheckFunnelAccess(st.Self.Capabilities); err != nil {
+		return nil, err
+	}
+
+	lc, err := s.LocalClient() // do local client first before listening.
+	if err != nil {
+		return nil, err
+	}
+
+	// May not have funnel enabled. Enable it.
+	srvConfig, err := lc.GetServeConfig(ctx)
+	if err != nil {
+		return nil, err
+	}
+	if srvConfig == nil {
+		srvConfig = &ipn.ServeConfig{}
+	}
+	domain := st.CertDomains[0]
+	hp := ipn.HostPort(domain + addr) // valid only because of the strong restrictions on addr above
+	if !srvConfig.AllowFunnel[hp] {
+		mak.Set(&srvConfig.AllowFunnel, hp, true)
+		srvConfig.AllowFunnel[hp] = true
+		if err := lc.SetServeConfig(ctx, srvConfig); err != nil {
+			return nil, err
+		}
+	}
+
+	// Start a funnel listener.
+	lnOn := listenOnBoth
+	for _, opt := range opts {
+		if _, ok := opt.(funnelOnly); ok {
+			lnOn = listenOnFunnel
+		}
+	}
+	ln, err := s.listen(network, addr, lnOn)
+	if err != nil {
+		return nil, err
+	}
+	return tls.NewListener(ln, &tls.Config{
+		GetCertificate: lc.GetCertificate,
+	}), nil
+}
+
+type listenOn string
+
+const (
+	listenOnTailnet = listenOn("listen-on-tailnet")
+	listenOnFunnel  = listenOn("listen-on-funnel")
+	listenOnBoth    = listenOn("listen-on-both")
+)
+
+func (s *Server) listen(network, addr string, lnOn listenOn) (net.Listener, error) {
 	switch network {
 	case "", "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6":
 	default:
@@ -794,20 +925,37 @@ func (s *Server) Listen(network, addr string) (net.Listener, error) {
 		return nil, err
 	}
 
-	key := listenKey{network, bindHostOrZero, uint16(port)}
+	var keys []listenKey
+	switch lnOn {
+	case listenOnTailnet:
+		keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), false})
+	case listenOnFunnel:
+		keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), true})
+	case listenOnBoth:
+		keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), false})
+		keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), true})
+	}
+
 	ln := &listener{
 		s:    s,
-		key:  key,
+		keys: keys,
 		addr: addr,
 
 		conn: make(chan net.Conn),
 	}
 	s.mu.Lock()
-	if _, ok := s.listeners[key]; ok {
-		s.mu.Unlock()
-		return nil, fmt.Errorf("tsnet: listener already open for %s, %s", network, addr)
+	for _, key := range keys {
+		if _, ok := s.listeners[key]; ok {
+			s.mu.Unlock()
+			return nil, fmt.Errorf("tsnet: listener already open for %s, %s", network, addr)
+		}
+	}
+	if s.listeners == nil {
+		s.listeners = make(map[listenKey]*listener)
+	}
+	for _, key := range keys {
+		s.listeners[key] = ln
 	}
-	mak.Set(&s.listeners, key, ln)
 	s.mu.Unlock()
 	return ln, nil
 }
@@ -816,11 +964,12 @@ type listenKey struct {
 	network string
 	host    netip.Addr // or zero value for unspecified
 	port    uint16
+	funnel  bool
 }
 
 type listener struct {
 	s    *Server
-	key  listenKey
+	keys []listenKey
 	addr string
 	conn chan net.Conn
 }
@@ -837,10 +986,12 @@ func (ln *listener) Addr() net.Addr { return addr{ln} }
 func (ln *listener) Close() error {
 	ln.s.mu.Lock()
 	defer ln.s.mu.Unlock()
-	if v, ok := ln.s.listeners[ln.key]; ok && v == ln {
-		delete(ln.s.listeners, ln.key)
-		close(ln.conn)
+	for _, key := range ln.keys {
+		if v, ok := ln.s.listeners[key]; ok && v == ln {
+			delete(ln.s.listeners, key)
+		}
 	}
+	close(ln.conn)
 	return nil
 }
 
@@ -861,5 +1012,5 @@ func (ln *listener) Server() *Server { return ln.s }
 
 type addr struct{ ln *listener }
 
-func (a addr) Network() string { return a.ln.key.network }
+func (a addr) Network() string { return a.ln.keys[0].network }
 func (a addr) String() string  { return a.ln.addr }