ソースを参照

ipn: add Funnel port check from nodeAttr

Signed-off-by: Maisem Ali <[email protected]>
Maisem Ali 3 年 前
コミット
3ff44b2307

+ 2 - 6
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, ".")

+ 1 - 5
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"},
 	},
 }
 

+ 71 - 6
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)
 }

+ 12 - 5
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:

+ 11 - 0
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 (

+ 1 - 1
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",
 	}

+ 17 - 12
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