diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 21348ac4c..996131720 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -189,15 +189,11 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, // validateServePort returns --serve-port flag value, // or an error if the port is not a valid port to serve on. func (e *serveEnv) validateServePort() (port uint16, err error) { - // make sure e.servePort is uint16 + // Make sure e.servePort is uint16. port = uint16(e.servePort) if uint(port) != e.servePort { return 0, fmt.Errorf("serve-port %d is out of range", e.servePort) } - // make sure e.servePort is 443, 8443 or 10000 - if port != 443 && port != 8443 && port != 10000 { - return 0, fmt.Errorf("serve-port %d is invalid; must be 443, 8443 or 10000", e.servePort) - } return port, nil } @@ -677,7 +673,7 @@ func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("getting client status: %w", err) } - if err := ipn.CheckFunnelAccess(st.Self.Capabilities); err != nil { + if err := ipn.CheckFunnelAccess(srvPort, st.Self.Capabilities); err != nil { return err } dnsName := strings.TrimSuffix(st.Self.DNSName, ".") diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index f0f17b8e4..145bbd8c0 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/cmd/tailscale/cli/serve_test.go @@ -119,10 +119,6 @@ func TestServeConfigMutations(t *testing.T) { }, }, }) - add(step{ // invalid port - command: cmd("--serve-port=9999 /abc proxy 3001"), - wantErr: anyErr(), - }) add(step{ command: cmd("--serve-port=8443 /abc proxy 3001"), want: &ipn.ServeConfig{ @@ -653,7 +649,7 @@ var fakeStatus = &ipnstate.Status{ BackendState: ipn.Running.String(), Self: &ipnstate.PeerStatus{ DNSName: "foo.test.ts.net", - Capabilities: []string{tailcfg.NodeAttrFunnel}, + Capabilities: []string{tailcfg.NodeAttrFunnel, tailcfg.CapabilityFunnelPorts + "?ports=443,8443"}, }, } diff --git a/ipn/serve.go b/ipn/serve.go index c88190c51..466a4f1f7 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -5,8 +5,12 @@ package ipn import ( "errors" + "fmt" "net" "net/netip" + "net/url" + "strconv" + "strings" "golang.org/x/exp/slices" "tailscale.com/tailcfg" @@ -173,13 +177,18 @@ 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. +// CheckFunnelAccess checks whether Funnel access is allowed for the given node +// and port. +// It checks: +// 1. an invite was used to join the Funnel alpha +// 2. HTTPS is enabled on the Tailnet +// 3. the node has the "funnel" nodeAttr +// 4. the port is allowed for Funnel // // 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 { +// the attribute we're checking for and possibly warning-capabilities for +// Funnel. +func CheckFunnelAccess(port uint16, 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/.") } @@ -189,5 +198,61 @@ func CheckFunnelAccess(nodeAttrs []string) error { 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 + return checkFunnelPort(port, nodeAttrs) +} + +// checkFunnelPort checks whether the given port is allowed for Funnel. +// It uses the tailcfg.CapabilityFunnelPorts nodeAttr to determine the allowed +// ports. +func checkFunnelPort(wantedPort uint16, nodeAttrs []string) error { + deny := func(allowedPorts string) error { + if allowedPorts == "" { + return fmt.Errorf("port %d is not allowed for funnel", wantedPort) + } + return fmt.Errorf("port %d is not allowed for funnel; allowed ports are: %v", wantedPort, allowedPorts) + } + var portsStr string + for _, attr := range nodeAttrs { + if !strings.HasPrefix(attr, tailcfg.CapabilityFunnelPorts) { + continue + } + u, err := url.Parse(attr) + if err != nil { + return deny("") + } + portsStr = u.Query().Get("ports") + if portsStr == "" { + return deny("") + } + u.RawQuery = "" + if u.String() != tailcfg.CapabilityFunnelPorts { + return deny("") + } + } + wantedPortString := strconv.Itoa(int(wantedPort)) + for _, ps := range strings.Split(portsStr, ",") { + if ps == "" { + continue + } + first, last, ok := strings.Cut(ps, "-") + if !ok { + if first == wantedPortString { + return nil + } + continue + } + fp, err := strconv.ParseUint(first, 10, 16) + if err != nil { + continue + } + lp, err := strconv.ParseUint(last, 10, 16) + if err != nil { + continue + } + pr := tailcfg.PortRange{First: uint16(fp), Last: uint16(lp)} + if pr.Contains(wantedPort) { + return nil + } + } + return deny(portsStr) } diff --git a/ipn/serve_test.go b/ipn/serve_test.go index ec96ed099..d08aba047 100644 --- a/ipn/serve_test.go +++ b/ipn/serve_test.go @@ -9,17 +9,24 @@ import ( ) func TestCheckFunnelAccess(t *testing.T) { + portAttr := "https://tailscale.com/cap/funnel-ports?ports=443,8080-8090,8443," tests := []struct { + port uint16 caps []string wantErr bool }{ - {[]string{}, true}, // No "funnel" attribute - {[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true}, - {[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true}, - {[]string{tailcfg.NodeAttrFunnel}, false}, + {443, []string{portAttr}, true}, // No "funnel" attribute + {443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoInvite}, true}, + {443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoHTTPS}, true}, + {443, []string{portAttr, tailcfg.NodeAttrFunnel}, false}, + {8443, []string{portAttr, tailcfg.NodeAttrFunnel}, false}, + {8321, []string{portAttr, tailcfg.NodeAttrFunnel}, true}, + {8083, []string{portAttr, tailcfg.NodeAttrFunnel}, false}, + {8091, []string{portAttr, tailcfg.NodeAttrFunnel}, true}, + {3000, []string{portAttr, tailcfg.NodeAttrFunnel}, true}, } for _, tt := range tests { - err := CheckFunnelAccess(tt.caps) + err := CheckFunnelAccess(tt.port, tt.caps) switch { case err != nil && tt.wantErr, err == nil && !tt.wantErr: diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index c273a876f..c755d1951 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1058,6 +1058,11 @@ type PortRange struct { Last uint16 } +// Contains reports whether port is in pr. +func (pr PortRange) Contains(port uint16) bool { + return port >= pr.First && port <= pr.Last +} + var PortRangeAny = PortRange{0, 65535} // NetPortRange represents a range of ports that's allowed for one or more IPs. @@ -1818,6 +1823,12 @@ const ( // resolution for Tailscale-controlled domains (the control server, log // server, DERP servers, etc.) CapabilityDebugTSDNSResolution = "https://tailscale.com/cap/debug-ts-dns-resolution" + + // CapabilityFunnelPorts specifies the ports that the Funnel is available on. + // The ports are specified as a comma-separated list of port numbers or port + // ranges (e.g. "80,443,8080-8090") in the ports query parameter. + // e.g. https://tailscale.com/cap/funnel-ports?ports=80,443,8080-8090 + CapabilityFunnelPorts = "https://tailscale.com/cap/funnel-ports" ) const ( diff --git a/tsnet/example/tsnet-funnel/tsnet-funnel.go b/tsnet/example/tsnet-funnel/tsnet-funnel.go index f536ba151..ed5136fec 100644 --- a/tsnet/example/tsnet-funnel/tsnet-funnel.go +++ b/tsnet/example/tsnet-funnel/tsnet-funnel.go @@ -22,7 +22,7 @@ import ( func main() { flag.Parse() s := &tsnet.Server{ - Dir: "./funnel-demo-config.state", + Dir: "./funnel-demo-config", Logf: logger.Discard, Hostname: "fun", } diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index a57a246f0..b7ff8a1b4 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -21,6 +21,7 @@ import ( "net/netip" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -767,7 +768,7 @@ func (s *Server) Listen(network, addr string) (net.Listener, error) { // 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) { +func (s *Server) ListenTLS(network, addr string) (net.Listener, error) { if network != "tcp" { return nil, fmt.Errorf("ListenTLS(%q, %q): only tcp is supported", network, addr) } @@ -822,28 +823,32 @@ func FunnelOnly() FunnelOption { return funnelOnly(1) } // 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) { +func (s *Server) ListenFunnel(network, 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) + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + if host != "" { + return nil, fmt.Errorf("ListenFunnel(%q, %q): host must be empty", network, addr) + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return nil, err } + 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 { + if err := ipn.CheckFunnelAccess(uint16(port), st.Self.Capabilities); err != nil { return nil, err } - lc, err := s.LocalClient() // do local client first before listening. + lc, err := s.LocalClient() if err != nil { return nil, err } @@ -857,7 +862,7 @@ func (s *Server) ListenFunnel(network string, addr string, opts ...FunnelOption) srvConfig = &ipn.ServeConfig{} } domain := st.CertDomains[0] - hp := ipn.HostPort(domain + addr) // valid only because of the strong restrictions on addr above + hp := ipn.HostPort(domain + ":" + portStr) if !srvConfig.AllowFunnel[hp] { mak.Set(&srvConfig.AllowFunnel, hp, true) srvConfig.AllowFunnel[hp] = true