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")
}
}