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 }