mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-02 06:08:23 +08:00
We suggest that a UUID is used for agentless nodes metadata.name field, but we do not enforce it. This causes several edge cases and slightly weird UX in places that expect the name to be a UUID. Most notably, this presents dialing problems for the web ui as described in https://github.com/gravitational/teleport/issues/50914. To allowing dialing to function in all cases for these servers, routing has been updated to permit matches on metadata.name, however, the match is given a lower score then a match on a UUID. This should permit dialing, though, it may still result in ambiguity. Closes #50914.
242 lines
7.5 KiB
Go
242 lines
7.5 KiB
Go
// Copyright 2023 Gravitational, Inc
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package utils
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"slices"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
|
|
"github.com/gravitational/teleport/api/utils/aws"
|
|
)
|
|
|
|
// SSHRouteMatcher is a helper used to decide if an ssh dial request should match
|
|
// a given server. This is broken out of proxy.Router as a standalone helper in order
|
|
// to let other parts of teleport easily find matching servers when generating
|
|
// error messages or building access requests.
|
|
type SSHRouteMatcher struct {
|
|
cfg SSHRouteMatcherConfig
|
|
ips []string
|
|
matchServerIDs bool
|
|
}
|
|
|
|
// SSHRouteMatcherConfig configures an SSHRouteMatcher.
|
|
type SSHRouteMatcherConfig struct {
|
|
// Host is the target host that we want to route to.
|
|
Host string
|
|
// Port is an optional target port. If empty or zero
|
|
// it will match servers listening on any port.
|
|
Port string
|
|
// Resolver can be set to override default hostname lookup
|
|
// behavior (used in tests).
|
|
Resolver HostResolver
|
|
// CaseInsensitive enabled case insensitive routing when true.
|
|
CaseInsensitive bool
|
|
// DisableUnqualifiedLookups disables lookups for unqualified hostnames.
|
|
DisableUnqualifiedLookups bool
|
|
}
|
|
|
|
// HostResolver provides an interface matching the net.Resolver.LookupHost method. Typically
|
|
// only used as a means of overriding dns resolution behavior in tests.
|
|
type HostResolver interface {
|
|
// LookupHost performs a hostname lookup. See net.Resolver.LookupHost for details.
|
|
LookupHost(ctx context.Context, host string) (addrs []string, err error)
|
|
}
|
|
|
|
var errEmptyHost = errors.New("cannot route to empty target host")
|
|
|
|
// NewSSHRouteMatcherFromConfig sets up an ssh route matcher from the supplied configuration.
|
|
func NewSSHRouteMatcherFromConfig(cfg SSHRouteMatcherConfig) (*SSHRouteMatcher, error) {
|
|
if cfg.Host == "" {
|
|
return nil, trace.Wrap(errEmptyHost)
|
|
}
|
|
|
|
if cfg.Resolver == nil {
|
|
cfg.Resolver = net.DefaultResolver
|
|
}
|
|
|
|
m := newSSHRouteMatcher(cfg)
|
|
return &m, nil
|
|
}
|
|
|
|
// NewSSHRouteMatcher builds a new matcher for ssh routing decisions.
|
|
func NewSSHRouteMatcher(host, port string, caseInsensitive bool) SSHRouteMatcher {
|
|
return newSSHRouteMatcher(SSHRouteMatcherConfig{
|
|
Host: host,
|
|
Port: port,
|
|
CaseInsensitive: caseInsensitive,
|
|
Resolver: net.DefaultResolver,
|
|
})
|
|
}
|
|
|
|
func newSSHRouteMatcher(cfg SSHRouteMatcherConfig) SSHRouteMatcher {
|
|
_, err := uuid.Parse(cfg.Host)
|
|
dialByID := err == nil || aws.IsEC2NodeID(cfg.Host)
|
|
|
|
var ips []string
|
|
if !(cfg.DisableUnqualifiedLookups && !strings.Contains(cfg.Host, ".")) {
|
|
// unqualified lookups are still on by default, but future versions of teleport may disable them as they tend
|
|
// to be responsible for the majority of all lookups generated by a teleport cluster and are of questionable utility.
|
|
ips, _ = cfg.Resolver.LookupHost(context.Background(), cfg.Host)
|
|
}
|
|
|
|
return SSHRouteMatcher{
|
|
cfg: cfg,
|
|
ips: ips,
|
|
matchServerIDs: dialByID,
|
|
}
|
|
}
|
|
|
|
// RouteableServer is an interface describing the subset of the types.Server interface
|
|
// required to make a routing decision.
|
|
type RouteableServer interface {
|
|
GetName() string
|
|
GetHostname() string
|
|
GetAddr() string
|
|
GetUseTunnel() bool
|
|
GetPublicAddrs() []string
|
|
}
|
|
|
|
// RouteToServer checks if this route matcher wants to route to the supplied server.
|
|
func (m *SSHRouteMatcher) RouteToServer(server RouteableServer) bool {
|
|
return m.RouteToServerScore(server) > 0
|
|
}
|
|
|
|
const (
|
|
notMatch = 0
|
|
indirectMatch = 1
|
|
directMatch = 2
|
|
)
|
|
|
|
// RouteToServerScore checks wether this route matcher wants to route to the supplied server
|
|
// and represents the result of that check as an integer score indicating the strength of the
|
|
// match. Positive scores indicate a match, higher being stronger.
|
|
func (m *SSHRouteMatcher) RouteToServerScore(server RouteableServer) (score int) {
|
|
// if host is a UUID or EC2 ID match only
|
|
// by server name and treat matches as unambiguous
|
|
if m.matchServerIDs && server.GetName() == m.cfg.Host {
|
|
return directMatch
|
|
}
|
|
|
|
hostnameMatch := m.routeToHostname(server.GetHostname())
|
|
|
|
// if the server has connected over a reverse tunnel
|
|
// then match only by hostname.
|
|
if server.GetUseTunnel() {
|
|
if hostnameMatch {
|
|
return directMatch
|
|
}
|
|
return notMatch
|
|
}
|
|
|
|
matchAddr := func(addr string) int {
|
|
ip, nodePort, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return notMatch
|
|
}
|
|
|
|
if m.cfg.Port != "" && m.cfg.Port != "0" && m.cfg.Port != nodePort {
|
|
// if port is well-specified and does not match, don't bother
|
|
// continuing the check.
|
|
return notMatch
|
|
}
|
|
|
|
if hostnameMatch || m.cfg.Host == ip {
|
|
// server presents a hostname or addr that exactly matches
|
|
// our target.
|
|
return directMatch
|
|
}
|
|
|
|
if slices.Contains(m.ips, ip) {
|
|
// server presents an addr that indirectly matches our target
|
|
// due to dns resolution.
|
|
return indirectMatch
|
|
}
|
|
|
|
return notMatch
|
|
}
|
|
|
|
score = matchAddr(server.GetAddr())
|
|
|
|
for _, addr := range server.GetPublicAddrs() {
|
|
score = max(score, matchAddr(addr))
|
|
}
|
|
|
|
// Allow a match on name, even though it may not be a UUID or EC2 ID,
|
|
// to support agentless hosts that were created without their name being a UUID.
|
|
// The score however, is lower than a true direct match, to prevent any
|
|
// breaking changes to routing.
|
|
if server.GetName() == m.cfg.Host {
|
|
score = max(score, indirectMatch)
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
// routeToHostname helps us perform a special kind of case-insensitive comparison. SSH certs do not generally
|
|
// treat principals/hostnames in a case-insensitive manner. This is often worked-around by forcing all principals and
|
|
// hostnames to be lowercase. For backwards-compatibility reasons, teleport must support case-sensitive routing by default
|
|
// and can't do this. Instead, teleport nodes whose hostnames contain uppercase letters will present certs that include both
|
|
// the literal hostname and a lowered version of the hostname, meaning that it is sane to route a request for host 'foo' to
|
|
// host 'Foo', but it is not sane to route a request for host 'Bar' to host 'bar'.
|
|
func (m *SSHRouteMatcher) routeToHostname(principal string) bool {
|
|
if !m.cfg.CaseInsensitive {
|
|
return m.cfg.Host == principal
|
|
}
|
|
|
|
if len(m.cfg.Host) != len(principal) {
|
|
return false
|
|
}
|
|
|
|
// the below is modeled off of the fast ASCII path of strings.EqualFold
|
|
for i := 0; i < len(principal) && i < len(m.cfg.Host); i++ {
|
|
pr := principal[i]
|
|
hr := m.cfg.Host[i]
|
|
if pr|hr >= utf8.RuneSelf {
|
|
// not pure-ascii, fallback to literal comparison
|
|
return m.cfg.Host == principal
|
|
}
|
|
|
|
// Easy case.
|
|
if pr == hr {
|
|
continue
|
|
}
|
|
|
|
// Check if principal is an upper-case equivalent to host.
|
|
if 'A' <= pr && pr <= 'Z' && hr == pr+'a'-'A' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// IsEmpty checks if this route matcher has had a hostname set.
|
|
func (m *SSHRouteMatcher) IsEmpty() bool {
|
|
return m.cfg.Host == ""
|
|
}
|
|
|
|
// MatchesServerIDs checks if this matcher wants to perform server ID matching.
|
|
func (m *SSHRouteMatcher) MatchesServerIDs() bool {
|
|
return m.matchServerIDs
|
|
}
|