ipn: add Funnel port check from nodeAttr

Signed-off-by: Maisem Ali <maisem@tailscale.com>
This commit is contained in:
Maisem Ali 2023-03-11 08:45:40 -08:00 committed by Maisem Ali
parent ccdd534e81
commit 3ff44b2307
7 changed files with 115 additions and 35 deletions

View File

@ -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, ".")

View File

@ -119,10 +119,6 @@ type step struct {
},
},
})
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 @@ type fakeLocalServeClient struct {
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"},
},
}

View File

@ -5,8 +5,12 @@
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)
}

View File

@ -9,17 +9,24 @@
)
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:

View File

@ -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 @@ type Oauth2Token struct {
// 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 (

View File

@ -22,7 +22,7 @@
func main() {
flag.Parse()
s := &tsnet.Server{
Dir: "./funnel-demo-config.state",
Dir: "./funnel-demo-config",
Logf: logger.Discard,
Hostname: "fun",
}

View File

@ -21,6 +21,7 @@
"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