diff --git a/docs/pages/enroll-resources/desktop-access/active-directory.mdx b/docs/pages/enroll-resources/desktop-access/active-directory.mdx index 4d273596155a5..52abf43751c32 100644 --- a/docs/pages/enroll-resources/desktop-access/active-directory.mdx +++ b/docs/pages/enroll-resources/desktop-access/active-directory.mdx @@ -840,27 +840,47 @@ In order to perform NLA, Teleport's `windows_desktop_service` needs to be able to make an outbound Kerberos connection to a key distribution center (KDC). This is most commonly performed on TCP port 88. -By default, Teleport will assume that a KDC is available on the same host that -is specified in the `ldap` configuration's `addr` field, using the default -Kerberos port. +Teleport determines the address of the KDC by checking the following (in order of priority): -For example, with the following configuration, Teleport will attempt to perform -NLA against `example.com:88`. +1. If `kdc_address` is set, Teleport will use this address. +1. As of version 18.3.1, if `locate_server` is enabled and `kdc_address` is + not set, Teleport will attempt to discover the KDC address via DNS SRV records. +1. If neither `locate_server` or `kdc_address` is specified, Teleport will + assume that a KDC is available on the same host that is specified in the + `ldap` configuration's `addr` field. + +For example, you can set the KDC address by specifying the `kdc_address` +in your Teleport configuration file. ```yaml windows_desktop_service: enabled: true - ldap: - addr: example.com:636 + kdc_address: kdc.example.com # defaults to port 88 if unspecified ``` -Alternatively, you can override the KDC address by specifying the `kdc_address` -in your Teleport configuration file. +If you're using the `locate_server` option, Teleport will perform a DNS +SRV lookup on the provided `domain` (e.g. `_kerberos._tcp.my-site._sites.example.com`). +NLA will be performed against the highest-priority and reachable KDC. + +```yaml +windows_desktop_service: + enabled: true + + locate_server: + enabled: true + site: "my-site" # optional + domain: example.com +``` + +If server location is disabled and `kdc_address` isn't specified, with the following configuration, +Teleport will attempt to perform NLA against `example.com:88`. ```yaml windows_desktop_service: enabled: true - kdc_address: kdc.example.com # defaults to port 88 if unspecified + + ldap: + addr: example.com:636 ``` To enable NLA, set the `TELEPORT_ENABLE_RDP_NLA` environment variable to `yes` diff --git a/docs/pages/includes/config-reference/desktop-config.yaml b/docs/pages/includes/config-reference/desktop-config.yaml index bd8d600a56fbe..bd2aab8e8e814 100644 --- a/docs/pages/includes/config-reference/desktop-config.yaml +++ b/docs/pages/includes/config-reference/desktop-config.yaml @@ -76,8 +76,9 @@ windows_desktop_service: pki_domain: root.example.com # (optional) Configures the address of the Kerberos Key Distribution Center, - # which is used to support RDP Network Level Authentication (NLA). - # If empty, the LDAP address will be used instead. + # which is used to support RDP Network Level Authentication (NLA). When set, + # this field takes priority over locate_server. If empty and locate_server + # is disabled, the LDAP address will be used instead. # # example: kdc.example.com:88. # The port is optional and defaults to port 88 if unspecified. diff --git a/lib/srv/desktop/rdp/rdpclient/client_common.go b/lib/srv/desktop/rdp/rdpclient/client_common.go index d3dee4298ae31..a3206304daa2d 100644 --- a/lib/srv/desktop/rdp/rdpclient/client_common.go +++ b/lib/srv/desktop/rdp/rdpclient/client_common.go @@ -85,7 +85,7 @@ type Config struct { // KDCAddr is the address of Key Distribution Center. // This is used to support RDP Network Level Authentication (NLA) // when connecting to hosts enrolled in Active Directory. - // This filed is not used when AD is false. + // This field is not used when AD is false. KDCAddr string // AD indicates whether the desktop is part of an Active Directory domain. diff --git a/lib/srv/desktop/windows_server.go b/lib/srv/desktop/windows_server.go index 9feda07a15867..9ccfb2004b70c 100644 --- a/lib/srv/desktop/windows_server.go +++ b/lib/srv/desktop/windows_server.go @@ -28,6 +28,7 @@ import ( "fmt" "log/slog" "net" + "os" "strconv" "strings" "time" @@ -59,6 +60,7 @@ import ( "github.com/gravitational/teleport/lib/srv/desktop/tdp" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/dns" logutils "github.com/gravitational/teleport/lib/utils/log" "github.com/gravitational/teleport/lib/utils/slices" "github.com/gravitational/teleport/lib/winpki" @@ -786,11 +788,9 @@ func (s *WindowsService) connectRDP(ctx context.Context, log *slog.Logger, tdpCo } log = log.With("computer_name", computerName) - kdcAddr := s.cfg.KDCAddr - if !desktop.NonAD() && kdcAddr == "" && s.cfg.LDAPConfig.Addr != "" { - if kdcAddr, err = utils.Host(s.cfg.LDAPConfig.Addr); err != nil { - return trace.Wrap(err, "KDC address is unspecified and LDAP address is invalid") - } + kdcAddr, err := s.getKDCAddress(ctx) + if err != nil { + return trace.Wrap(err, "getting KDC address") } nla := s.enableNLA && !desktop.NonAD() @@ -1330,3 +1330,75 @@ func (s *WindowsService) runCRLUpdateLoop(tlsConfig *tls.Config) { } } } + +// getKDCAddress gets the KDC address that should be used for NLA in +// this priority order: +// 1. Explicitly specified kdc_address +// 2. If enabled, using locate_server DNS lookups +// 3. If all else fails, ldap's addr +func (s *WindowsService) getKDCAddress(ctx context.Context) (string, error) { + if s.cfg.KDCAddr != "" { + if s.cfg.LocateServer.Enabled { + s.cfg.Logger.WarnContext(ctx, "Both locate_server and kdc_address are set, kdc_address takes priority", "kdc_address", s.cfg.KDCAddr) + } else { + s.cfg.Logger.DebugContext(ctx, "Using hardcoded KDC address", "kdc_address", s.cfg.KDCAddr) + } + return s.cfg.KDCAddr, nil + } + + if !s.cfg.LocateServer.Enabled && s.cfg.LDAPConfig.Addr != "" { + kdcAddr, err := utils.Host(s.cfg.LDAPConfig.Addr) + if err != nil { + return "", trace.Wrap(err, "KDC address is unspecified, locate server is disabled, and LDAP address is invalid") + } + s.cfg.Logger.DebugContext(ctx, "locate_server and kdc_address unspecified, assuming that KDC is available on the same host as LDAP", "address", s.cfg.LDAPConfig.Addr) + return kdcAddr, nil + } + + s.cfg.Logger.DebugContext( + ctx, + "Looking for KDC server", + "domain", s.cfg.Domain, + "site", s.cfg.LocateServer.Site, + ) + + // In development environments, the system's default resolver is unlikely to be + // able to resolve the Active Directory SRV records needed for server location, + // so we allow overriding the resolver. If the TELEPORT_KDC_RESOLVER parameter + // is not set, the default resolver will be used. + resolver := dns.NewResolver(ctx, os.Getenv("TELEPORT_KDC_RESOLVER"), s.cfg.Logger) + + servers, err := dns.LocateServerBySRV( + ctx, + s.cfg.Domain, + s.cfg.LocateServer.Site, + resolver, + "kerberos", + "", // Use port returned by SRV record + ) + if err != nil { + return "", trace.Wrap(err, "locating KDC server") + } + + if len(servers) == 0 { + return "", trace.NotFound("no KDC servers found for domain %q", s.cfg.Domain) + } + + var lastErr error + for _, server := range servers { + conn, err := net.DialTimeout("tcp", server, 5*time.Second) + if conn != nil { + conn.Close() + } + + if err == nil { + s.cfg.Logger.InfoContext(ctx, "Found KDC server", "server", server) + return server, nil + } + lastErr = err + + s.cfg.Logger.InfoContext(ctx, "Error connecting to KDC server, trying next available server", "server", server, "error", err) + } + + return "", trace.NotFound("no KDC servers responded successfully for domain %q: %v", s.cfg.Domain, lastErr) +} diff --git a/lib/winpki/locate.go b/lib/utils/dns/locate.go similarity index 60% rename from lib/winpki/locate.go rename to lib/utils/dns/locate.go index f24448223ea47..74dcd0b6be68f 100644 --- a/lib/winpki/locate.go +++ b/lib/utils/dns/locate.go @@ -16,30 +16,37 @@ * along with this program. If not, see . */ -package winpki +package dns import ( + "cmp" "context" "net" + "strconv" "github.com/gravitational/trace" ) -// locateLDAPServer looks up the LDAP server in an Active Directory -// environment by implementing the DNS-based discovery DC locator -// process. +// Resolver interface wraps the net.Resolver methods needed for testing +type Resolver interface { + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) +} + +// LocateServerBySRV looks up a server of a given service and port +// in an Active Directory environment by implementing the +// DNS-based discovery DC locator process. // // See https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/dc-locator?tabs=dns-based-discovery -func locateLDAPServer(ctx context.Context, domain string, site string, resolver *net.Resolver) ([]string, error) { +func LocateServerBySRV(ctx context.Context, domain string, site string, resolver Resolver, service string, port string) ([]string, error) { tryDomain := domain if site != "" { tryDomain = site + "._sites." + domain } - _, records, err := resolver.LookupSRV(ctx, "ldap", "tcp", tryDomain) + _, records, err := resolver.LookupSRV(ctx, service, "tcp", tryDomain) if err != nil && site != "" { // If the site lookup fails, try the domain directly. - _, records, err = resolver.LookupSRV(ctx, "ldap", "tcp", domain) + _, records, err = resolver.LookupSRV(ctx, service, "tcp", domain) } if err != nil { @@ -49,9 +56,10 @@ func locateLDAPServer(ctx context.Context, domain string, site string, resolver // note: LookupSRV already returns records sorted by priority and takes in to account weights var result []string for _, record := range records { - // SRV records will likely return the insecure LDAP port, - // so we ignore it and hard code the LDAPS port. - result = append(result, net.JoinHostPort(record.Target, "636")) + // If a port has been passed, use that. + // If not, use the port returned by the SRV record. + usePort := cmp.Or(port, strconv.Itoa(int(record.Port))) + result = append(result, net.JoinHostPort(record.Target, usePort)) } return result, nil diff --git a/lib/utils/dns/locate_test.go b/lib/utils/dns/locate_test.go new file mode 100644 index 0000000000000..e660ff81e8891 --- /dev/null +++ b/lib/utils/dns/locate_test.go @@ -0,0 +1,170 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package dns + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLocateServerBySRV(t *testing.T) { + for _, test := range []struct { + name string + domain string + site string + service string + port string + mockSRVRecords map[string][]*net.SRV + mockSRVErrors map[string]error + expectedResult []string + expectedError string + }{ + { + name: "successful lookup without site", + domain: "example.com", + site: "", + service: "ldap", + port: "", + mockSRVRecords: map[string][]*net.SRV{ + "_ldap._tcp.example.com": { + {Target: "dc1.example.com.", Port: 389, Priority: 0, Weight: 5}, + {Target: "dc2.example.com.", Port: 389, Priority: 1, Weight: 5}, + }, + }, + expectedResult: []string{"dc1.example.com.:389", "dc2.example.com.:389"}, + }, + { + name: "successful lookup with site", + domain: "example.com", + site: "site1", + service: "ldap", + port: "", + mockSRVRecords: map[string][]*net.SRV{ + "_ldap._tcp.site1._sites.example.com": { + {Target: "dc1-site1.example.com.", Port: 389, Priority: 0, Weight: 5}, + }, + }, + expectedResult: []string{"dc1-site1.example.com.:389"}, + }, + { + name: "site lookup fails, fallback to domain succeeds", + domain: "example.com", + site: "site1", + service: "ldap", + port: "", + mockSRVRecords: map[string][]*net.SRV{ + "_ldap._tcp.example.com": { + {Target: "dc1.example.com.", Port: 389, Priority: 0, Weight: 5}, + }, + }, + mockSRVErrors: map[string]error{ + "_ldap._tcp.site1._sites.example.com": &net.DNSError{Name: "site1._sites.example.com", Err: "no such host"}, + }, + expectedResult: []string{"dc1.example.com.:389"}, + }, + { + name: "both site and domain lookups fail", + domain: "example.com", + site: "site1", + service: "ldap", + port: "", + mockSRVErrors: map[string]error{ + "_ldap._tcp.site1._sites.example.com": &net.DNSError{Name: "site1._sites.example.com", Err: "no such host"}, + "_ldap._tcp.example.com": &net.DNSError{Name: "example.com", Err: "no such host"}, + }, + expectedError: "looking up SRV records", + }, + { + name: "successful port override", + domain: "example.com", + site: "", + service: "ldap", + port: "636", + mockSRVRecords: map[string][]*net.SRV{ + "_ldap._tcp.example.com": { + {Target: "dc1.example.com.", Port: 389, Priority: 0, Weight: 5}, + }, + }, + expectedResult: []string{"dc1.example.com.:636"}, + }, + { + name: "empty results", + domain: "example.com", + site: "", + service: "ldap", + port: "", + mockSRVRecords: map[string][]*net.SRV{ + "_ldap._tcp.example.com": {}, + }, + expectedResult: nil, + }, + { + name: "multiple records with IPv6 target", + domain: "example.com", + site: "", + service: "ldap", + port: "", + mockSRVRecords: map[string][]*net.SRV{ + "_ldap._tcp.example.com": { + {Target: "dc1.example.com.", Port: 389, Priority: 0, Weight: 5}, + {Target: "2001:db8::1.", Port: 389, Priority: 0, Weight: 5}, + }, + }, + expectedResult: []string{"dc1.example.com.:389", "[2001:db8::1.]:389"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + resolver := &mockResolver{ + srvRecords: test.mockSRVRecords, + srvErrors: test.mockSRVErrors, + } + + result, err := LocateServerBySRV(context.Background(), test.domain, test.site, resolver, test.service, test.port) + + if test.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectedError) + return + } + + require.NoError(t, err) + require.Equal(t, test.expectedResult, result) + }) + } +} + +type mockResolver struct { + srvRecords map[string][]*net.SRV + srvErrors map[string]error +} + +func (m *mockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + key := "_" + service + "._" + proto + "." + name + + if err, exists := m.srvErrors[key]; exists { + return "", nil, err + } + + if records, exists := m.srvRecords[key]; exists { + return name, records, nil + } + + return "", nil, &net.DNSError{Name: name, Err: "no such host"} +} diff --git a/lib/utils/dns/resolver.go b/lib/utils/dns/resolver.go new file mode 100644 index 0000000000000..05dec51b19b6e --- /dev/null +++ b/lib/utils/dns/resolver.go @@ -0,0 +1,60 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package dns + +import ( + "context" + "log/slog" + "net" +) + +// NewResolver creates and returns a DNS resolver +// +// DNSServerAddress, if set, will be used to resolve DNS requests. If +// it is left blank, the default system resolver will be used. +func NewResolver(ctx context.Context, dnsServerAddress string, logger *slog.Logger) *net.Resolver { + if logger == nil { + logger = slog.Default() + } + + dialer := net.Dialer{} + dial := func(dialCtx context.Context, network, address string) (net.Conn, error) { + return dialer.DialContext(dialCtx, network, address) + } + + if dnsServerAddress != "" { + logger.DebugContext(ctx, "Using custom DNS resolver address", "address", dnsServerAddress) + + host, port, err := net.SplitHostPort(dnsServerAddress) + if err != nil { + host = dnsServerAddress + port = "53" + } + + customResolverAddr := net.JoinHostPort(host, port) + dial = func(ctx context.Context, network, address string) (net.Conn, error) { + return dialer.DialContext(ctx, network, customResolverAddr) + } + } + + resolver := &net.Resolver{ + PreferGo: true, + Dial: dial, + } + + return resolver +} diff --git a/lib/winpki/ldap.go b/lib/winpki/ldap.go index 6aaa3a2a151fe..8f6e78974a945 100644 --- a/lib/winpki/ldap.go +++ b/lib/winpki/ldap.go @@ -35,6 +35,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils/dns" ) const ( @@ -347,35 +348,14 @@ func (c *LDAPConfig) createConnection(ctx context.Context, ldapTLSConfig *tls.Co dialer := net.Dialer{Timeout: ldapDialTimeout} if c.LocateServer.Enabled { - dial := func(dialCtx context.Context, network, address string) (net.Conn, error) { - return dialer.DialContext(dialCtx, network, address) - } - // In development environments, the system's default resolver is unlikely to be // able to resolve the Active Directory SRV records needed for server location, - // so we allow overriding the resolver. - if resolverAddr := os.Getenv("TELEPORT_LDAP_RESOLVER"); resolverAddr != "" { - c.Logger.DebugContext(ctx, "Using custom DNS resolver address", "address", resolverAddr) - // Check if resolver address has a port - host, port, err := net.SplitHostPort(resolverAddr) - if err != nil { - host = resolverAddr - port = "53" - } - - customResolverAddr := net.JoinHostPort(host, port) - dial = func(ctx context.Context, network, address string) (net.Conn, error) { - return dialer.DialContext(ctx, network, customResolverAddr) - } - } - - resolver := &net.Resolver{ - PreferGo: true, - Dial: dial, - } + // so we allow overriding the resolver. If the TELEPORT_LDAP_RESOLVER paramater + // is not set, the default resolver will be used. + resolver := dns.NewResolver(ctx, os.Getenv("TELEPORT_LDAP_RESOLVER"), c.Logger) var err error - if servers, err = locateLDAPServer(ctx, c.Domain, c.LocateServer.Site, resolver); err != nil { + if servers, err = dns.LocateServerBySRV(ctx, c.Domain, c.LocateServer.Site, resolver, "ldap", "636"); err != nil { return nil, trace.Wrap(err, "locating LDAP server") } }