ipn: add Funnel port check from nodeAttr
Signed-off-by: Maisem Ali <maisem@tailscale.com>pull/7534/head
parent
ccdd534e81
commit
3ff44b2307
|
@ -189,15 +189,11 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status,
|
||||||
// validateServePort returns --serve-port flag value,
|
// validateServePort returns --serve-port flag value,
|
||||||
// or an error if the port is not a valid port to serve on.
|
// or an error if the port is not a valid port to serve on.
|
||||||
func (e *serveEnv) validateServePort() (port uint16, err error) {
|
func (e *serveEnv) validateServePort() (port uint16, err error) {
|
||||||
// make sure e.servePort is uint16
|
// Make sure e.servePort is uint16.
|
||||||
port = uint16(e.servePort)
|
port = uint16(e.servePort)
|
||||||
if uint(port) != e.servePort {
|
if uint(port) != e.servePort {
|
||||||
return 0, fmt.Errorf("serve-port %d is out of range", 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
|
return port, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -677,7 +673,7 @@ func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting client status: %w", err)
|
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
|
return err
|
||||||
}
|
}
|
||||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||||
|
|
|
@ -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{
|
add(step{
|
||||||
command: cmd("--serve-port=8443 /abc proxy 3001"),
|
command: cmd("--serve-port=8443 /abc proxy 3001"),
|
||||||
want: &ipn.ServeConfig{
|
want: &ipn.ServeConfig{
|
||||||
|
@ -653,7 +649,7 @@ var fakeStatus = &ipnstate.Status{
|
||||||
BackendState: ipn.Running.String(),
|
BackendState: ipn.Running.String(),
|
||||||
Self: &ipnstate.PeerStatus{
|
Self: &ipnstate.PeerStatus{
|
||||||
DNSName: "foo.test.ts.net",
|
DNSName: "foo.test.ts.net",
|
||||||
Capabilities: []string{tailcfg.NodeAttrFunnel},
|
Capabilities: []string{tailcfg.NodeAttrFunnel, tailcfg.CapabilityFunnelPorts + "?ports=443,8443"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
77
ipn/serve.go
77
ipn/serve.go
|
@ -5,8 +5,12 @@ package ipn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
@ -173,13 +177,18 @@ func (sc *ServeConfig) IsFunnelOn() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFunnelAccess checks three things: 1) an invite was used to join the
|
// CheckFunnelAccess checks whether Funnel access is allowed for the given node
|
||||||
// Funnel alpha; 2) HTTPS is enabled; 3) the node has the "funnel" attribute.
|
// and port.
|
||||||
// If any of these are false, an error is returned describing the problem.
|
// 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 nodeAttrs arg should be the node's Self.Capabilities which should contain
|
||||||
// the attribute we're checking for and possibly warning-capabilities for Funnel.
|
// the attribute we're checking for and possibly warning-capabilities for
|
||||||
func CheckFunnelAccess(nodeAttrs []string) error {
|
// Funnel.
|
||||||
|
func CheckFunnelAccess(port uint16, nodeAttrs []string) error {
|
||||||
if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) {
|
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/.")
|
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) {
|
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 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,17 +9,24 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckFunnelAccess(t *testing.T) {
|
func TestCheckFunnelAccess(t *testing.T) {
|
||||||
|
portAttr := "https://tailscale.com/cap/funnel-ports?ports=443,8080-8090,8443,"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
port uint16
|
||||||
caps []string
|
caps []string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{[]string{}, true}, // No "funnel" attribute
|
{443, []string{portAttr}, true}, // No "funnel" attribute
|
||||||
{[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true},
|
{443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoInvite}, true},
|
||||||
{[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true},
|
{443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoHTTPS}, true},
|
||||||
{[]string{tailcfg.NodeAttrFunnel}, false},
|
{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 {
|
for _, tt := range tests {
|
||||||
err := CheckFunnelAccess(tt.caps)
|
err := CheckFunnelAccess(tt.port, tt.caps)
|
||||||
switch {
|
switch {
|
||||||
case err != nil && tt.wantErr,
|
case err != nil && tt.wantErr,
|
||||||
err == nil && !tt.wantErr:
|
err == nil && !tt.wantErr:
|
||||||
|
|
|
@ -1058,6 +1058,11 @@ type PortRange struct {
|
||||||
Last uint16
|
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}
|
var PortRangeAny = PortRange{0, 65535}
|
||||||
|
|
||||||
// NetPortRange represents a range of ports that's allowed for one or more IPs.
|
// 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
|
// resolution for Tailscale-controlled domains (the control server, log
|
||||||
// server, DERP servers, etc.)
|
// server, DERP servers, etc.)
|
||||||
CapabilityDebugTSDNSResolution = "https://tailscale.com/cap/debug-ts-dns-resolution"
|
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 (
|
const (
|
||||||
|
|
|
@ -22,7 +22,7 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
s := &tsnet.Server{
|
s := &tsnet.Server{
|
||||||
Dir: "./funnel-demo-config.state",
|
Dir: "./funnel-demo-config",
|
||||||
Logf: logger.Discard,
|
Logf: logger.Discard,
|
||||||
Hostname: "fun",
|
Hostname: "fun",
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
@ -767,7 +768,7 @@ func (s *Server) Listen(network, addr string) (net.Listener, error) {
|
||||||
// ListenTLS announces only on the Tailscale network.
|
// ListenTLS announces only on the Tailscale network.
|
||||||
// It returns a TLS listener wrapping the tsnet listener.
|
// It returns a TLS listener wrapping the tsnet listener.
|
||||||
// It will start the server if it has not been started yet.
|
// 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" {
|
if network != "tcp" {
|
||||||
return nil, fmt.Errorf("ListenTLS(%q, %q): only tcp is supported", network, addr)
|
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".
|
// and the only other supported addrs currently are ":8443" and ":10000".
|
||||||
//
|
//
|
||||||
// It will start the server if it has not been started yet.
|
// 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" {
|
if network != "tcp" {
|
||||||
return nil, fmt.Errorf("ListenFunnel(%q, %q): only tcp is supported", network, addr)
|
return nil, fmt.Errorf("ListenFunnel(%q, %q): only tcp is supported", network, addr)
|
||||||
}
|
}
|
||||||
switch addr {
|
host, portStr, err := net.SplitHostPort(addr)
|
||||||
case ":443", ":8443", ":10000":
|
if err != nil {
|
||||||
default:
|
return nil, err
|
||||||
return nil, fmt.Errorf(`ListenFunnel(%q, %q): only valid addrs are ":443", ":8443", and ":10000"`, network, addr)
|
|
||||||
}
|
}
|
||||||
|
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()
|
ctx := context.Background()
|
||||||
st, err := s.Up(ctx)
|
st, err := s.Up(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(st.CertDomains) == 0 {
|
if err := ipn.CheckFunnelAccess(uint16(port), st.Self.Capabilities); err != nil {
|
||||||
return nil, errors.New("tsnet: you must enable HTTPS in the admin panel to proceed")
|
|
||||||
}
|
|
||||||
if err := ipn.CheckFunnelAccess(st.Self.Capabilities); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lc, err := s.LocalClient() // do local client first before listening.
|
lc, err := s.LocalClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -857,7 +862,7 @@ func (s *Server) ListenFunnel(network string, addr string, opts ...FunnelOption)
|
||||||
srvConfig = &ipn.ServeConfig{}
|
srvConfig = &ipn.ServeConfig{}
|
||||||
}
|
}
|
||||||
domain := st.CertDomains[0]
|
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] {
|
if !srvConfig.AllowFunnel[hp] {
|
||||||
mak.Set(&srvConfig.AllowFunnel, hp, true)
|
mak.Set(&srvConfig.AllowFunnel, hp, true)
|
||||||
srvConfig.AllowFunnel[hp] = true
|
srvConfig.AllowFunnel[hp] = true
|
||||||
|
|
Loading…
Reference in New Issue