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 {