From ccdd534e81f2e2f615ddc6173ce29beca70e3a13 Mon Sep 17 00:00:00 2001 From: Maisem Ali Date: Fri, 10 Mar 2023 11:44:28 -0800 Subject: [PATCH] tsnet: add ListenFunnel This lets a tsnet binary share a server out over Tailscale Funnel. Signed-off-by: David Crawshaw Signed-off-by: Maisem Ali Signed-off-by: Shayne Sweeney --- cmd/tailscale/cli/serve.go | 23 +-- cmd/tailscale/cli/serve_test.go | 24 --- ipn/serve.go | 23 +++ ipn/serve_test.go | 33 ++++ tsnet/example/tsnet-funnel/tsnet-funnel.go | 122 ++----------- tsnet/tsnet.go | 189 ++++++++++++++++++--- 6 files changed, 243 insertions(+), 171 deletions(-) create mode 100644 ipn/serve_test.go diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 01ddd7372..21348ac4c 100644 --- a/cmd/tailscale/cli/serve.go +++ b/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 -} diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index 13795bde9..f0f17b8e4 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/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 { diff --git a/ipn/serve.go b/ipn/serve.go index 745eb5dd7..c88190c51 100644 --- a/ipn/serve.go +++ b/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 +} diff --git a/ipn/serve_test.go b/ipn/serve_test.go new file mode 100644 index 000000000..ec96ed099 --- /dev/null +++ b/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) + } + } +} diff --git a/tsnet/example/tsnet-funnel/tsnet-funnel.go b/tsnet/example/tsnet-funnel/tsnet-funnel.go index 89584b232..f536ba151 100644 --- a/tsnet/example/tsnet-funnel/tsnet-funnel.go +++ b/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= 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, "

Hello, internet!

") - fmt.Fprintln(w, "

You are connected over the internet!

") - fmt.Fprintf(w, "

You are coming from %v

\n", funnelSrc(r.Context())) - } else { - fmt.Fprintln(w, "

Hello, tailnet!

") - fmt.Fprintln(w, "

You are connected over the tailnet!

") - who, err := lc.WhoIs(r.Context(), r.RemoteAddr) - if err != nil { - log.Printf("WhoIs(%v): %v", r.RemoteAddr, err) - fmt.Fprintf(w, "

I do not know who you are

") - } else if len(who.Node.Tags) > 0 { - fmt.Fprintf(w, "

You are using a tagged node: %v

\n", who.Node.Tags) - } else { - fmt.Fprintf(w, "

You are %v

\n", who.UserProfile.DisplayName) - } - fmt.Fprintf(w, "

You are coming from %v

\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, "

Hello, internet!

") + })) + log.Fatal(err) } diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 7d963a4b1..a57a246f0 100644 --- a/tsnet/tsnet.go +++ b/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 }