github.com/gravitational/teleport/api@v0.0.0-20240507183017-3110591cbafc/utils/route.go (about)

     1  // Copyright 2023 Gravitational, Inc
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package utils
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"net"
    21  	"slices"
    22  	"unicode/utf8"
    23  
    24  	"github.com/google/uuid"
    25  	"github.com/gravitational/trace"
    26  
    27  	"github.com/gravitational/teleport/api/utils/aws"
    28  )
    29  
    30  // SSHRouteMatcher is a helper used to decide if an ssh dial request should match
    31  // a given server. This is broken out of proxy.Router as a standalone helper in order
    32  // to let other parts of teleport easily find matching servers when generating
    33  // error messages or building access requests.
    34  type SSHRouteMatcher struct {
    35  	cfg            SSHRouteMatcherConfig
    36  	ips            []string
    37  	matchServerIDs bool
    38  }
    39  
    40  // SSHRouteMatcherConfig configures an SSHRouteMatcher.
    41  type SSHRouteMatcherConfig struct {
    42  	// Host is the target host that we want to route to.
    43  	Host string
    44  	// Port is an optional target port. If empty or zero
    45  	// it will match servers listening on any port.
    46  	Port string
    47  	// Resolver can be set to override default hostname lookup
    48  	// behavior (used in tests).
    49  	Resolver HostResolver
    50  	// CaseInsensitive enabled case insensitive routing when true.
    51  	CaseInsensitive bool
    52  }
    53  
    54  // HostResolver provides an interface matching the net.Resolver.LookupHost method. Typically
    55  // only used as a means of overriding dns resolution behavior in tests.
    56  type HostResolver interface {
    57  	// LookupHost performs a hostname lookup.  See net.Resolver.LookupHost for details.
    58  	LookupHost(ctx context.Context, host string) (addrs []string, err error)
    59  }
    60  
    61  var errEmptyHost = errors.New("cannot route to empty target host")
    62  
    63  // NewSSHRouteMatcherFromConfig sets up an ssh route matcher from the supplied configuration.
    64  func NewSSHRouteMatcherFromConfig(cfg SSHRouteMatcherConfig) (*SSHRouteMatcher, error) {
    65  	if cfg.Host == "" {
    66  		return nil, trace.Wrap(errEmptyHost)
    67  	}
    68  
    69  	if cfg.Resolver == nil {
    70  		cfg.Resolver = net.DefaultResolver
    71  	}
    72  
    73  	m := newSSHRouteMatcher(cfg)
    74  	return &m, nil
    75  }
    76  
    77  // NewSSHRouteMatcher builds a new matcher for ssh routing decisions.
    78  func NewSSHRouteMatcher(host, port string, caseInsensitive bool) SSHRouteMatcher {
    79  	return newSSHRouteMatcher(SSHRouteMatcherConfig{
    80  		Host:            host,
    81  		Port:            port,
    82  		CaseInsensitive: caseInsensitive,
    83  		Resolver:        net.DefaultResolver,
    84  	})
    85  }
    86  
    87  func newSSHRouteMatcher(cfg SSHRouteMatcherConfig) SSHRouteMatcher {
    88  	_, err := uuid.Parse(cfg.Host)
    89  	dialByID := err == nil || aws.IsEC2NodeID(cfg.Host)
    90  
    91  	ips, _ := cfg.Resolver.LookupHost(context.Background(), cfg.Host)
    92  
    93  	return SSHRouteMatcher{
    94  		cfg:            cfg,
    95  		ips:            ips,
    96  		matchServerIDs: dialByID,
    97  	}
    98  }
    99  
   100  // RouteableServer is an interface describing the subset of the types.Server interface
   101  // required to make a routing decision.
   102  type RouteableServer interface {
   103  	GetName() string
   104  	GetHostname() string
   105  	GetAddr() string
   106  	GetUseTunnel() bool
   107  	GetPublicAddrs() []string
   108  }
   109  
   110  // RouteToServer checks if this route matcher wants to route to the supplied server.
   111  func (m *SSHRouteMatcher) RouteToServer(server RouteableServer) bool {
   112  	return m.RouteToServerScore(server) > 0
   113  }
   114  
   115  const (
   116  	notMatch      = 0
   117  	indirectMatch = 1
   118  	directMatch   = 2
   119  )
   120  
   121  // RouteToServerScore checks wether this route matcher wants to route to the supplied server
   122  // and represents the result of that check as an integer score indicating the strength of the
   123  // match. Positive scores indicate a match, higher being stronger.
   124  func (m *SSHRouteMatcher) RouteToServerScore(server RouteableServer) (score int) {
   125  	// if host is a UUID or EC2 ID match only
   126  	// by server name and treat matches as unambiguous
   127  	if m.matchServerIDs && server.GetName() == m.cfg.Host {
   128  		return directMatch
   129  	}
   130  
   131  	hostnameMatch := m.routeToHostname(server.GetHostname())
   132  
   133  	// if the server has connected over a reverse tunnel
   134  	// then match only by hostname.
   135  	if server.GetUseTunnel() {
   136  		if hostnameMatch {
   137  			return directMatch
   138  		}
   139  		return notMatch
   140  	}
   141  
   142  	matchAddr := func(addr string) int {
   143  		ip, nodePort, err := net.SplitHostPort(addr)
   144  		if err != nil {
   145  			return notMatch
   146  		}
   147  
   148  		if m.cfg.Port != "" && m.cfg.Port != "0" && m.cfg.Port != nodePort {
   149  			// if port is well-specified and does not match, don't bother
   150  			// continuing the check.
   151  			return notMatch
   152  		}
   153  
   154  		if hostnameMatch || m.cfg.Host == ip {
   155  			// server presents a hostname or addr that exactly matches
   156  			// our target.
   157  			return directMatch
   158  		}
   159  
   160  		if slices.Contains(m.ips, ip) {
   161  			// server presents an addr that indirectly matches our target
   162  			// due to dns resolution.
   163  			return indirectMatch
   164  		}
   165  
   166  		return notMatch
   167  	}
   168  
   169  	score = matchAddr(server.GetAddr())
   170  
   171  	for _, addr := range server.GetPublicAddrs() {
   172  		score = max(score, matchAddr(addr))
   173  	}
   174  
   175  	return score
   176  }
   177  
   178  // routeToHostname helps us perform a special kind of case-insensitive comparison. SSH certs do not generally
   179  // treat principals/hostnames in a case-insensitive manner. This is often worked-around by forcing all principals and
   180  // hostnames to be lowercase. For backwards-compatibility reasons, teleport must support case-sensitive routing by default
   181  // and can't do this. Instead, teleport nodes whose hostnames contain uppercase letters will present certs that include both
   182  // the literal hostname and a lowered version of the hostname, meaning that it is sane to route a request for host 'foo' to
   183  // host 'Foo', but it is not sane to route a request for host 'Bar' to host 'bar'.
   184  func (m *SSHRouteMatcher) routeToHostname(principal string) bool {
   185  	if !m.cfg.CaseInsensitive {
   186  		return m.cfg.Host == principal
   187  	}
   188  
   189  	if len(m.cfg.Host) != len(principal) {
   190  		return false
   191  	}
   192  
   193  	// the below is modeled off of the fast ASCII path of strings.EqualFold
   194  	for i := 0; i < len(principal) && i < len(m.cfg.Host); i++ {
   195  		pr := principal[i]
   196  		hr := m.cfg.Host[i]
   197  		if pr|hr >= utf8.RuneSelf {
   198  			// not pure-ascii, fallback to literal comparison
   199  			return m.cfg.Host == principal
   200  		}
   201  
   202  		// Easy case.
   203  		if pr == hr {
   204  			continue
   205  		}
   206  
   207  		// Check if principal is an upper-case equivalent to host.
   208  		if 'A' <= pr && pr <= 'Z' && hr == pr+'a'-'A' {
   209  			continue
   210  		}
   211  		return false
   212  	}
   213  
   214  	return true
   215  }
   216  
   217  // IsEmpty checks if this route matcher has had a hostname set.
   218  func (m *SSHRouteMatcher) IsEmpty() bool {
   219  	return m.cfg.Host == ""
   220  }
   221  
   222  // MatchesServerIDs checks if this matcher wants to perform server ID matching.
   223  func (m *SSHRouteMatcher) MatchesServerIDs() bool {
   224  	return m.matchServerIDs
   225  }