From b797f773c7e5dda62054425cb3ccd157189fd1e8 Mon Sep 17 00:00:00 2001 From: Maisem Ali Date: Wed, 8 Mar 2023 12:36:41 -0800 Subject: [PATCH] ipn/ipnlocal: add support for funnel in tsnet Previously the part that handled Funnel connections was not aware of any listeners that tsnet.Servers might have had open so it would check against the ServeConfig and fail. Adding a ServeConfig for a TCP proxy was also not suitable in this scenario as that would mean creating two different listeners and have one forward to the other, which really meant that you could not have funnel and tailnet-only listeners on the same port. This also introduces the ipn.FunnelConn as a way for users to identify whether the call is coming over funnel or not. Currently it only holds the underlying conn and the target as presented in the "Tailscale-Ingress-Target" header. Signed-off-by: Maisem Ali --- ipn/ipnlocal/local.go | 22 ++++ ipn/ipnlocal/peerapi.go | 12 +- ipn/ipnlocal/serve.go | 15 ++- ipn/serve.go | 25 ++++ tsnet/example/tsnet-funnel/tsnet-funnel.go | 131 +++++++++++++++++++++ tsnet/tsnet.go | 22 ++++ 6 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 tsnet/example/tsnet-funnel/tsnet-funnel.go diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index c887c3490..574ee7cf7 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -150,6 +150,22 @@ type LocalBackend struct { shutdownCalled bool // if Shutdown has been called debugSink *capture.Sink + // getTCPHandlerForFunnelFlow returns a handler for an incoming TCP flow for + // the provided srcAddr and dstPort if one exists. + // + // srcAddr is the source address of the flow, not the address of the Funnel + // node relaying the flow. + // dstPort is the destination port of the flow. + // + // It returns nil if there is no known handler for this flow. + // + // This is specifically used to handle TCP flows for Funnel connections to tsnet + // servers. + // + // It is set once during initialization, and can be nil if SetTCPHandlerForFunnelFlow + // is never called. + getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn)) + // lastProfileID tracks the last profile we've seen from the ProfileManager. // It's used to detect when the user has changed their profile. lastProfileID ipn.ProfileID @@ -3117,6 +3133,12 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, prefs ipn.PrefsView, logf logger. return dcfg } +// SetTCPHandlerForFunnelFlow sets the TCP handler for Funnel flows. +// It should only be called before the LocalBackend is used. +func (b *LocalBackend) SetTCPHandlerForFunnelFlow(h func(src netip.AddrPort, dstPort uint16) (handler func(net.Conn))) { + b.getTCPHandlerForFunnelFlow = h +} + // SetVarRoot sets the root directory of Tailscale's writable // storage area . (e.g. "/var/lib/tailscale") // diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index f157427e1..e14bb3e6c 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -761,12 +761,12 @@ func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Reque bad("Tailscale-Ingress-Src header invalid; want ip:port") return } - target := r.Header.Get("Tailscale-Ingress-Target") + target := ipn.HostPort(r.Header.Get("Tailscale-Ingress-Target")) if target == "" { bad("Tailscale-Ingress-Target header not set") return } - if _, _, err := net.SplitHostPort(target); err != nil { + if _, _, err := net.SplitHostPort(string(target)); err != nil { bad("Tailscale-Ingress-Target header invalid; want host:port") return } @@ -779,13 +779,17 @@ func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Reque return nil, false } io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n\r\n") - return conn, true + return &ipn.FunnelConn{ + Conn: conn, + Src: srcAddr, + Target: target, + }, true } sendRST := func() { http.Error(w, "denied", http.StatusForbidden) } - h.ps.b.HandleIngressTCPConn(h.peerNode, ipn.HostPort(target), srcAddr, getConn, sendRST) + h.ps.b.HandleIngressTCPConn(h.peerNode, target, srcAddr, getConn, sendRST) } func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) { diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index faf63c22e..715aefede 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -281,9 +281,22 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer *tailcfg.Node, target ip sendRST() return } + dport := uint16(port16) + if b.getTCPHandlerForFunnelFlow != nil { + handler := b.getTCPHandlerForFunnelFlow(srcAddr, dport) + if handler != nil { + c, ok := getConn() + if !ok { + b.logf("localbackend: getConn didn't complete from %v to port %v", srcAddr, dport) + return + } + handler(c) + return + } + } // TODO(bradfitz): pass ingressPeer etc in context to HandleInterceptedTCPConn, // extend serveHTTPContext or similar. - b.HandleInterceptedTCPConn(uint16(port16), srcAddr, getConn, sendRST) + b.HandleInterceptedTCPConn(dport, srcAddr, getConn, sendRST) } func (b *LocalBackend) HandleInterceptedTCPConn(dport uint16, srcAddr netip.AddrPort, getConn func() (net.Conn, bool), sendRST func()) { diff --git a/ipn/serve.go b/ipn/serve.go index 4eca96637..745eb5dd7 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -3,6 +3,11 @@ package ipn +import ( + "net" + "net/netip" +) + // ServeConfigKey returns a StateKey that stores the // JSON-encoded ServeConfig for a config profile. func ServeConfigKey(profileID ProfileID) StateKey { @@ -29,6 +34,26 @@ type ServeConfig struct { // There is no implicit port 443. It must contain a colon. type HostPort string +// A FunnelConn wraps a net.Conn that is coming over a +// Funnel connection. It can be used to determine further +// information about the connection, like the source address +// and the target SNI name. +type FunnelConn struct { + // Conn is the underlying connection. + net.Conn + + // Target is what was presented in the "Tailscale-Ingress-Target" + // HTTP header. + Target HostPort + + // Src is the source address of the connection. + // This is the address of the client that initiated the + // connection, not the address of the Tailscale Funnel + // node which is relaying the connection. That address + // can be found in Conn.RemoteAddr. + Src netip.AddrPort +} + // WebServerConfig describes a web server's configuration. type WebServerConfig struct { Handlers map[string]*HTTPHandler // mountPoint => handler diff --git a/tsnet/example/tsnet-funnel/tsnet-funnel.go b/tsnet/example/tsnet-funnel/tsnet-funnel.go new file mode 100644 index 000000000..89584b232 --- /dev/null +++ b/tsnet/example/tsnet-funnel/tsnet-funnel.go @@ -0,0 +1,131 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// The tsnet-funnel server demonstrates how to use tsnet with Funnel. +package main + +import ( + "context" + "crypto/tls" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + "net/netip" + + "tailscale.com/ipn" + "tailscale.com/tsnet" +) + +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) + } + + ln, err := s.Listen("tcp", *addr) + 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 +} diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index c226a5852..7d963a4b1 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -519,6 +519,7 @@ func (s *Server) start() (reterr error) { if err != nil { return fmt.Errorf("NewLocalBackend: %v", err) } + lb.SetTCPHandlerForFunnelFlow(s.getTCPHandlerForFunnelFlow) lb.SetVarRoot(s.rootPath) logf("tsnet starting with hostname %q, varRoot %q", s.hostname, s.rootPath) s.lb = lb @@ -660,6 +661,27 @@ func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort) (_ *list return nil, false } +func (s *Server) getTCPHandlerForFunnelFlow(src netip.AddrPort, dstPort uint16) (handler func(net.Conn)) { + ipv4, ipv6 := s.TailscaleIPs() + var dst netip.AddrPort + if src.Addr().Is4() { + if !ipv4.IsValid() { + return nil + } + dst = netip.AddrPortFrom(ipv4, dstPort) + } else { + if !ipv6.IsValid() { + return nil + } + dst = netip.AddrPortFrom(ipv6, dstPort) + } + ln, ok := s.listenerForDstAddr("tcp", dst) + if !ok { + return nil + } + return ln.handle +} + func (s *Server) getTCPHandlerForFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { ln, ok := s.listenerForDstAddr("tcp", dst) if !ok {