Repository: gravitational/teleport (Go)

Issue:
Custom RDP port for LDAP discovery
### What
What would you like Teleport to do differently?
Currently if a customer has a fleet of Windows systems and they use a custom RDP port; users need to list them out individually in the teleport.yaml file with the custom port. If they are using discovery they don't utilize this option. 

### How
How would you implement this?
Ability to specify RDP port for those systems that come through via discovery

### Why
Why do you need this?
Eliminates those with large windows footprints to not list them out individually and still utilize discovery for all Windows systems.

### Workaround
If a workaround exists, please include it.
Listing Windows systems our individually within the configuration file. 
desktop discovery: support multiple search paths
Teleport's LDAP-based discovery for Windows desktops is configured via two parameters:

- `base_dn`: specifies _where_ to search
- `filters`: specifies _what_ to search for

Because `base_dn` is a single value, searching across multiple separate OUs is difficult. Sometimes you can find a common root and set `base_dn` to something that contains both OUs, and then use filters to select only the OUs you care about, but that gets difficult quickly and most people resort to running multiple `windows_destop_service`s so that they can configure each to search a separate DN.

Proposal: deprecate `base_dn` in favor of `search_paths`, which will be an array.

```
windows_desktop_service:
  enabled: yes
  ldap:
    discovery:
      search_paths:
      - CN=foo,DC=example,DC=com
      - CN=bar,DC=example,DC=com
```

If `base_dn` and `search_paths` are both specified, then we just append `base_dn` to the set of search paths and search all of them.
Make Windows Desktop discovery/reconciler interval configurable
What would you like Teleport to do?
Make the windows desktop interval optionally manually configurable.

What problem does this solve?
Currently, Teleport hard codes the reconciller/discovery interval for Windows Desktops at 5 minutes: https://github.com/gravitational/teleport/blob/5570259168e3c458682b394a16e627681394bf46/lib/srv/desktop/discovery.go#L126

The concern is efficiency, and the negative impact to the Active Directory with frequent discovery

If a workaround exists, please include it.
n/a


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccbf8898d1ef1..8e09a6dd196c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,10 @@
 #### TLS Cipher Suites
 
 TLS cipher suites with known security issues can no longer be manually
-configured in the Teleport YAML configuration file.
-If you do not explicitly configure any of the listed TLS cipher suites, you are
-not affected by this change.
+configured in the Teleport YAML configuration file. If you do not explicitly
+configure any of the listed TLS cipher suites, you are not affected by this
+change.
+
 Teleport 18 removes support for:
 - `tls-rsa-with-aes-128-cbc-sha`
 - `tls-rsa-with-aes-256-cbc-sha`
@@ -74,9 +75,35 @@ Here's a plan example for the code above:
 
 #### AWS endpoint URL mode removed
 
-The AWS endpoint URL mode (`--endpoint-url`) has been removed for
-`tsh proxy aws` and `tsh aws`. Users using this mode should use the default
-HTTPS Proxy mode from now on.
+The AWS endpoint URL mode (`--endpoint-url`) has been removed for `tsh proxy
+aws` and `tsh aws`. Users using this mode should use the default HTTPS Proxy
+mode from now on.
+
+### Other changes
+
+#### Windows desktop discovery enhancements
+
+Teleport's LDAP-based discovery mechanism for Windows desktops now supports:
+
+- a configurable discovery interval
+- custom RDP ports
+- the ability to run multiple separate discovery configurations, allowing you to
+  configure finely-grained discovery policies without running multiple agents
+
+To update your configuration, move the `discovery` section to `discovery_configs`:
+
+```diff
+windows_desktop_service:
+  enabled: yes
++  discovery_interval: 10m # optional, defaults to 5 minutes
+-  discovery:
+-    base_dn: '*'
+-    label_attributes: [ department ]
++  discovery_configs:
++    - base_dn: '*'
++      label_attributes: [ department ]
++      rdp_port: 9989 # optional, defaults to 3389
+```
 
 ## 16.0.0 (xx/xx/xx)
 
diff --git a/docs/pages/enroll-resources/desktop-access/active-directory.mdx b/docs/pages/enroll-resources/desktop-access/active-directory.mdx
index 0d89017dd6e92..5a864d02db54a 100644
--- a/docs/pages/enroll-resources/desktop-access/active-directory.mdx
+++ b/docs/pages/enroll-resources/desktop-access/active-directory.mdx
@@ -607,8 +607,8 @@ To configure Teleport to protect access to Windows desktops:
        sid: "$LDAP_USER_SID"
        # Path to the certificate you exported.
        der_ca_file: "path/to/exported/cert"
-     discovery:
-       base_dn: "*"
+     discovery_configs:
+       - base_dn: "*"
    auth_service:
      enabled: false
    proxy_service:
@@ -681,16 +681,22 @@ To connect to a Windows desktop:
 ## LDAP Discovery
 
 In Active Directory environments, Teleport can be configured to discover hosts
-via LDAP. LDAP discovery is enabled by setting the `discovery.base_dn` field in
-the Teleport Windows Desktop Service configuration. You can set `base_dn` to a
-wildcard `'*` to search from the root of the domain, or you can specify an alternate
-base distinguished name to search from.
+via LDAP. LDAP discovery is enabled by adding one or more discovery configs to
+the `discovery_configs` field in the Teleport Windows Desktop Service
+configuration. You can set `base_dn` to a wildcard `'*` to search from the root
+of the domain, or you can specify an alternate base distinguished name to search
+from.
+
+By default, all hosts discovered via LDAP will be registered with a port of 3389.
+You can override the RDP port for each discovery configuration.
 
 ```yaml
 windows_desktop_service:
   enabled: true
-  discovery:
-    base_dn: '*'
+  discovery_configs:
+    - base_dn: 'OU=prod,DC=example,DC=com'
+    - base_dn: 'OU=staging,DC=example,DC=com'
+      rdp_port: 9989
 ```
 
 ### Filters
@@ -703,28 +709,27 @@ compute a logical AND of all filters specified.
 ```yaml
 windows_desktop_service:
   enabled: true
-  discovery:
-    base_dn: '*'
-    filters:
-    - '(location=Oakland)'
-    - '(!(primaryGroupID=516))'
+  discovery_configs:
+    - base_dn: '*'
+      filters:
+        - '(location=Oakland)'
+        - '(!(primaryGroupID=516))'
 ```
 
 ### Labeling
 
 It is often necessary to apply labels to discovered hosts to control access. You
 can configure Teleport to convert LDAP attributes to Teleport labels by setting
-the `discovery.label_attributes` field in the Teleport Windows Desktop Service
-configuration.
+the `label_attributes` field in the discovery configuration.
 
 ```yaml
 windows_desktop_service:
   enabled: true
-  discovery:
-    base_dn: '*'
-    label_attributes:
-      - 'location'
-      - 'department'
+  discovery_configs:
+    - base_dn: '*'
+      label_attributes:
+        - 'location'
+        - 'department'
 ```
 
 Each of the specified attributes will be placed in a Teleport label prefixed with `ldap/`.
diff --git a/docs/pages/includes/config-reference/desktop-config.yaml b/docs/pages/includes/config-reference/desktop-config.yaml
index e98377e0caa26..236e43b6b144d 100644
--- a/docs/pages/includes/config-reference/desktop-config.yaml
+++ b/docs/pages/includes/config-reference/desktop-config.yaml
@@ -12,19 +12,17 @@ windows_desktop_service:
   # set this to false (its default value).
   show_desktop_wallpaper: false
   # (optional) ldap contains configuration keys used when connecting Teleport
-  # to an Active Directory domain. This enables the discovery service for
-  # Windows desktops belonging to an Active Directory domain configured for
-  # Teleport access.
+  # to an Active Directory domain.
   ldap:
     # Address of the LDAP server for secure LDAP connections.
     # Usually, this address will use port 636, like: ldap.example.com:636.
     # For best results, this address should point to a highly-available
     # endpoint rather than a single domain controller.
-    addr: '$LDAP_SERVER_ADDRESS'
+    addr: "$LDAP_SERVER_ADDRESS"
     # Optional: the server name to use when validating the LDAP server's
     # certificate. Useful in cases where addr is an IP but the server
     # presents a cert with some other hostname.
-    server_name: '$LDAP_SERVER_NAME'
+    server_name: "$LDAP_SERVER_NAME"
     # You can skip LDAPS certificate verification by setting
     # this to true. It is recommended that this be set to false
     # and the certificate added your system's trusted repository,
@@ -40,7 +38,7 @@ windows_desktop_service:
     # deprecated: prefer ldap_ca_cert instead
     der_ca_file: /path/to/cert
     # Active Directory domain name you are connecting to.
-    domain: '$LDAP_DOMAIN_NAME'
+    domain: "$LDAP_DOMAIN_NAME"
     # LDAP username for authentication. This username must include the domain
     # NetBIOS name. The use of single quotes here is intentional in order to
     # avoid the need to escape the backslash (\) character.
@@ -48,7 +46,7 @@ windows_desktop_service:
     # For example, if your domain is "example.com", the NetBIOS name for it is
     # likely "EXAMPLE". When connecting as the "svc-teleport" user, you should
     # use the format: "EXAMPLE\svc-teleport".
-    username: '$LDAP_USERNAME'
+    username: "$LDAP_USERNAME"
     # The security identifier of the service account specified by the username
     # field above. This looks like a string starting with "S-".
     #
@@ -61,7 +59,7 @@ windows_desktop_service:
     # The value can be obtained over LDAP by constructing a query with the
     # filter = (&(objectCategory=person)(objectClass=user)(sAMAccountName=$LDAP_USERNAME))
     # and requesting the attribute = objectSid
-    sid: '$LDAP_USER_SID'
+    sid: "$LDAP_USER_SID"
 
   # (optional) When AD support is used, this field allows you to override
   # the domain that Teleport uses for PKI operations. If empty, the domain
@@ -79,57 +77,61 @@ windows_desktop_service:
   #
   # Note: NLA is only supported in Active Directory environments - this field has
   # no effect when connecting to desktops as local Windows users.
-  kdc_address: '$KDC_SERVER_ADDRESS'
+  kdc_address: "$KDC_SERVER_ADDRESS"
 
   # (optional) static_hosts is a list of hosts to register as WindowsDesktop
   # objects in Teleport. You can define host name and labels directly.
   static_hosts:
-  - name: example1
-    ad: false
-    addr: win1.dev.example.com
-    labels:
-      datacenter: dc1
-  - ad: true
-    addr: win2.dev.example.com # name will be generated based on address
-    labels:
-      controller: all
+    - name: example1
+      ad: false
+      addr: win1.dev.example.com
+      labels:
+        datacenter: dc1
+    - ad: true
+      addr: win2.dev.example.com # name will be generated based on address
+      labels:
+        controller: all
 
-  # (optional) settings for enabling automatic desktop discovery via LDAP
-  discovery:
-    # The wildcard '*' character tells Teleport to discover all the hosts in
-    # the Active Directory Domain. To refine the search, specify a custom DN.
-    # To disable automatic discovery, leave this field blank.
-    base_dn: '*'
-    # (optional) LDAP filters for further customizing the LDAP search.
-    # See https://ldap.com/ldap-filters for details on LDAP filter syntax.
-    filters:
-    - '(location=Oakland)'
-    - '(!(primaryGroupID=516))' # exclude domain controllers
-    # (optional) LDAP attributes to convert into Teleport labels.
-    # The key of the label will be "ldap/" + the value of the attribute.
-    label_attributes:
-    - location
+  # (optional) settings for enabling automatic desktop discovery via LDAP.
+  # To disable automatic discovery, omit this section.
+  discovery_configs:
+    - base_dn: "OU=prod,DC=example,DC=com"
+      # (optional) LDAP filters for further customizing the LDAP search.
+      # See https://ldap.com/ldap-filters for details on LDAP filter syntax.
+      filters:
+        - "(location=Oakland)"
+        - "(!(primaryGroupID=516))" # exclude domain controllers
+      # (optional) LDAP attributes to convert into Teleport labels.
+      # The key of the label will be "ldap/" + the value of the attribute.
+      label_attributes:
+        - location
+      # (optional) The port to use for RDP.
+      # Defaults to 3389 if unspecified.
+      rdp_port: 3389
+
+  # (optional) interval at which to run desktop discovery
+  discovery_interval: 10m
 
   # (optional) configure a set of label selectors for dynamic registration.
   # If specified, this service will monitor the cluster for dynamic_windows_desktop
   # and automatically proxy connections for desktops with matching labels.
   resources:
-  - labels:
-      "env": "dev"
+    - labels:
+        "env": "dev"
 
   # (optional) rules for applying labels to Windows hosts based on regular expressions
   # matched against the host name. If multiple rules match, the desktop will
   # get the union of all matching labels.
   host_labels:
-  - match: '^.*\.dev\.example\.com'
-    labels:
-      environment: dev
-  - match: '^.*\.prod\.example\.com'
-    labels:
-      environment: prod
-  - match: '^EC2AMAZ-'
-    labels:
-      environment: discovered-in-aws
+    - match: '^.*\.dev\.example\.com'
+      labels:
+        environment: dev
+    - match: '^.*\.prod\.example\.com'
+      labels:
+        environment: prod
+    - match: "^EC2AMAZ-"
+      labels:
+        environment: discovered-in-aws
 
   # Labels to attach to the Windows Desktop Service. This is used internally, so
   # any custom labels added won't affect the Windows hosts.
diff --git a/integration/helpers/internal.go b/integration/helpers/internal.go
index d3194b495bb15..4ebba6e23baf7 100644
--- a/integration/helpers/internal.go
+++ b/integration/helpers/internal.go
@@ -67,15 +67,6 @@ func StartAndWait(process *service.TeleportProcess, expectedEvents []string) ([]
 }
 
 func EnableDesktopService(config *servicecfg.Config) {
-	// This config won't actually work, because there is no LDAP server,
-	// but it's enough to force desktop service to run.
 	config.WindowsDesktop.Enabled = true
 	config.WindowsDesktop.ListenAddr = *utils.MustParseAddr("127.0.0.1:0")
-	config.WindowsDesktop.Discovery.BaseDN = ""
-	config.WindowsDesktop.LDAP = servicecfg.LDAPConfig{
-		Domain:             "example.com",
-		Addr:               "127.0.0.1:636",
-		Username:           "test",
-		InsecureSkipVerify: true,
-	}
 }
diff --git a/lib/auth/windows/ldap.go b/lib/auth/windows/ldap.go
index 37e1c2510d357..05e49c9e6d2b7 100644
--- a/lib/auth/windows/ldap.go
+++ b/lib/auth/windows/ldap.go
@@ -65,7 +65,7 @@ func (cfg LDAPConfig) Check() error {
 	return nil
 }
 
-// DomainDN returns the distinguished name for the domain.
+// DomainDN returns the distinguished name for an Active Directory Domain.
 func DomainDN(domain string) string {
 	var sb strings.Builder
 	parts := strings.Split(domain, ".")
diff --git a/lib/config/configuration.go b/lib/config/configuration.go
index 6aeb06d2f3eeb..0ab57fe4b328d 100644
--- a/lib/config/configuration.go
+++ b/lib/config/configuration.go
@@ -23,6 +23,7 @@
 package config
 
 import (
+	"cmp"
 	"context"
 	"crypto/x509"
 	"errors"
@@ -2057,22 +2058,53 @@ func applyWindowsDesktopConfig(fc *FileConfig, cfg *servicecfg.Config) error {
 		cfg.WindowsDesktop.ListenAddr = *listenAddr
 	}
 
+	for _, attributeName := range fc.WindowsDesktop.Discovery.LabelAttributes {
+		if !types.IsValidLabelKey(attributeName) {
+			return trace.BadParameter("WindowsDesktopService specifies label_attribute %q which is not a valid label key", attributeName)
+		}
+	}
+
 	for _, filter := range fc.WindowsDesktop.Discovery.Filters {
 		if _, err := ldap.CompileFilter(filter); err != nil {
 			return trace.BadParameter("WindowsDesktopService specifies invalid LDAP filter %q", filter)
 		}
 	}
 
-	for _, attributeName := range fc.WindowsDesktop.Discovery.LabelAttributes {
-		if !types.IsValidLabelKey(attributeName) {
-			return trace.BadParameter("WindowsDesktopService specifies label_attribute %q which is not a valid label key", attributeName)
+	if fc.WindowsDesktop.Discovery.BaseDN != "" && len(fc.WindowsDesktop.DiscoveryConfigs) > 0 {
+		return trace.BadParameter("WindowsDesktopService specifies both discovery and discovery_configs: move the discovery section to discovery_configs to continue")
+	}
+
+	for _, discoveryConfig := range fc.WindowsDesktop.DiscoveryConfigs {
+		for _, filter := range discoveryConfig.Filters {
+			if _, err := ldap.CompileFilter(filter); err != nil {
+				return trace.BadParameter("WindowsDesktopService specifies invalid LDAP filter %q", filter)
+			}
 		}
 	}
 
-	cfg.WindowsDesktop.Discovery = servicecfg.LDAPDiscoveryConfig{
-		BaseDN:          fc.WindowsDesktop.Discovery.BaseDN,
-		Filters:         fc.WindowsDesktop.Discovery.Filters,
-		LabelAttributes: fc.WindowsDesktop.Discovery.LabelAttributes,
+	// append the old (singular) discovery config to the new format that supports multiple configs
+	if fc.WindowsDesktop.Discovery.BaseDN != "" {
+		fc.WindowsDesktop.DiscoveryConfigs = append(fc.WindowsDesktop.DiscoveryConfigs, fc.WindowsDesktop.Discovery)
+	}
+
+	cfg.WindowsDesktop.Discovery = make([]servicecfg.LDAPDiscoveryConfig, 0, len(fc.WindowsDesktop.DiscoveryConfigs))
+	for _, dc := range fc.WindowsDesktop.DiscoveryConfigs {
+		if dc.BaseDN == "" {
+			return trace.BadParameter("WindowsDesktopService discovey_config is missing required base_dn")
+		}
+		cfg.WindowsDesktop.Discovery = append(cfg.WindowsDesktop.Discovery,
+			servicecfg.LDAPDiscoveryConfig{
+				BaseDN:          dc.BaseDN,
+				Filters:         dc.Filters,
+				LabelAttributes: dc.LabelAttributes,
+				RDPPort:         cmp.Or(dc.RDPPort, int(defaults.RDPListenPort)),
+			},
+		)
+	}
+
+	cfg.WindowsDesktop.DiscoveryInterval = fc.WindowsDesktop.DiscoveryInterval
+	if cfg.WindowsDesktop.DiscoveryInterval < 0 {
+		return trace.BadParameter("desktop discovery interval must not be negative (%v)", fc.WindowsDesktop.DiscoveryInterval.String())
 	}
 
 	var err error
diff --git a/lib/config/fileconf.go b/lib/config/fileconf.go
index 546a98f41bbbc..e817741820916 100644
--- a/lib/config/fileconf.go
+++ b/lib/config/fileconf.go
@@ -2490,7 +2490,12 @@ type WindowsDesktopService struct {
 	// no effect when connecting to desktops as local Windows users.
 	KDCAddress string `yaml:"kdc_address"`
 	// Discovery configures desktop discovery via LDAP.
+	// New usages should prever DiscoveryConfigs instead, which allows for multiple searches.
 	Discovery LDAPDiscoveryConfig `yaml:"discovery,omitempty"`
+	// DiscoveryConfigs configures desktop discovery via LDAP.
+	DiscoveryConfigs []LDAPDiscoveryConfig `yaml:"discovery_configs,omitempty"`
+	// DiscoveryInterval controls how frequently the discovery process runs.
+	DiscoveryInterval time.Duration `yaml:"discovery_interval"`
 	// ADHosts is a list of static, AD-connected Windows hosts. This gives users
 	// a way to specify AD-connected hosts that won't be found by the filters
 	// specified in `discovery` (or if `discovery` is omitted).
@@ -2590,6 +2595,9 @@ type LDAPDiscoveryConfig struct {
 	// discovered desktops having a label with key "ldap/location" and
 	// the value being the value of the "location" attribute.
 	LabelAttributes []string `yaml:"label_attributes"`
+	// RDPPort is the port to use for RDP for hosts discovered with this configuration.
+	// Optional, defaults to 3389 if unspecified.
+	RDPPort int `yaml:"rdp_port"`
 }
 
 // TracingService contains configuration for the tracing_service.
diff --git a/lib/service/desktop.go b/lib/service/desktop.go
index 789f50330eb3c..444d7892b532f 100644
--- a/lib/service/desktop.go
+++ b/lib/service/desktop.go
@@ -228,16 +228,15 @@ func (process *TeleportProcess) initWindowsDesktopServiceRegistered(logger *slog
 			StaticHosts: cfg.WindowsDesktop.StaticHosts,
 			OnHeartbeat: process.OnHeartbeat(teleport.ComponentWindowsDesktop),
 		},
-		ShowDesktopWallpaper:         cfg.WindowsDesktop.ShowDesktopWallpaper,
-		LDAPConfig:                   windows.LDAPConfig(cfg.WindowsDesktop.LDAP),
-		KDCAddr:                      cfg.WindowsDesktop.KDCAddr,
-		PKIDomain:                    cfg.WindowsDesktop.PKIDomain,
-		DiscoveryBaseDN:              cfg.WindowsDesktop.Discovery.BaseDN,
-		DiscoveryLDAPFilters:         cfg.WindowsDesktop.Discovery.Filters,
-		DiscoveryLDAPAttributeLabels: cfg.WindowsDesktop.Discovery.LabelAttributes,
-		Hostname:                     cfg.Hostname,
-		ConnectedProxyGetter:         proxyGetter,
-		ResourceMatchers:             cfg.WindowsDesktop.ResourceMatchers,
+		ShowDesktopWallpaper: cfg.WindowsDesktop.ShowDesktopWallpaper,
+		LDAPConfig:           windows.LDAPConfig(cfg.WindowsDesktop.LDAP),
+		KDCAddr:              cfg.WindowsDesktop.KDCAddr,
+		PKIDomain:            cfg.WindowsDesktop.PKIDomain,
+		Discovery:            cfg.WindowsDesktop.Discovery,
+		DiscoveryInterval:    cfg.WindowsDesktop.DiscoveryInterval,
+		Hostname:             cfg.Hostname,
+		ConnectedProxyGetter: proxyGetter,
+		ResourceMatchers:     cfg.WindowsDesktop.ResourceMatchers,
 	})
 	if err != nil {
 		return trace.Wrap(err)
diff --git a/lib/service/servicecfg/windows.go b/lib/service/servicecfg/windows.go
index 988dc16b3e466..d1b5d5b990d02 100644
--- a/lib/service/servicecfg/windows.go
+++ b/lib/service/servicecfg/windows.go
@@ -22,6 +22,7 @@ import (
 	"crypto/x509"
 	"maps"
 	"regexp"
+	"time"
 
 	"github.com/gravitational/teleport/lib/limiter"
 	"github.com/gravitational/teleport/lib/services"
@@ -55,7 +56,8 @@ type WindowsDesktopConfig struct {
 	KDCAddr string
 
 	// Discovery configures automatic desktop discovery via LDAP.
-	Discovery LDAPDiscoveryConfig
+	Discovery         []LDAPDiscoveryConfig
+	DiscoveryInterval time.Duration
 
 	// StaticHosts is an optional list of static Windows hosts to expose through this
 	// service.
@@ -97,6 +99,8 @@ type LDAPDiscoveryConfig struct {
 	// discovered desktops having a label with key "ldap/location" and
 	// the value being the value of the "location" attribute.
 	LabelAttributes []string
+	// RDPPort is the RDP port to register for each host discovered with this configuration.
+	RDPPort int
 }
 
 // HostLabelRules is a collection of rules describing how to apply labels to hosts.
diff --git a/lib/srv/desktop/discovery.go b/lib/srv/desktop/discovery.go
index ee50a8de2aad6..f90912c9ef9cc 100644
--- a/lib/srv/desktop/discovery.go
+++ b/lib/srv/desktop/discovery.go
@@ -36,7 +36,7 @@ import (
 	apidefaults "github.com/gravitational/teleport/api/defaults"
 	"github.com/gravitational/teleport/api/types"
 	"github.com/gravitational/teleport/lib/auth/windows"
-	"github.com/gravitational/teleport/lib/defaults"
+	"github.com/gravitational/teleport/lib/service/servicecfg"
 	"github.com/gravitational/teleport/lib/services"
 	"github.com/gravitational/teleport/lib/services/readonly"
 	"github.com/gravitational/teleport/lib/utils"
@@ -121,9 +121,7 @@ func (s *WindowsService) startDesktopDiscovery() error {
 			s.cfg.Logger.ErrorContext(s.closeCtx, "desktop reconciliation failed", "error", err)
 		}
 
-		// TODO(zmb3): consider making the discovery period configurable
-		// (it's currently hard coded to 5 minutes in order to match DB access discovery behavior)
-		t := s.cfg.Clock.NewTicker(5 * time.Minute)
+		t := s.cfg.Clock.NewTicker(s.cfg.DiscoveryInterval)
 		defer t.Stop()
 		for {
 			select {
@@ -140,12 +138,12 @@ func (s *WindowsService) startDesktopDiscovery() error {
 	return nil
 }
 
-func (s *WindowsService) ldapSearchFilter() string {
-	var filters []string
-	filters = append(filters, fmt.Sprintf("(%s=%s)", windows.AttrObjectClass, ClassComputer))
-	filters = append(filters, fmt.Sprintf("(!(%s=%s))", windows.AttrObjectClass, ClassGMSA))
-	filters = append(filters, s.cfg.DiscoveryLDAPFilters...)
-
+func (s *WindowsService) ldapSearchFilter(additionalFilters []string) string {
+	var filters = []string{
+		fmt.Sprintf("(%s=%s)", windows.AttrObjectClass, ClassComputer),
+		fmt.Sprintf("(!(%s=%s))", windows.AttrObjectClass, ClassGMSA),
+	}
+	filters = append(filters, additionalFilters...)
 	return windows.CombineLDAPFilters(filters)
 }
 
@@ -154,46 +152,48 @@ func (s *WindowsService) getDesktopsFromLDAP() map[string]types.WindowsDesktop {
 	// Check whether we've ever successfully initialized our LDAP client.
 	s.mu.Lock()
 	if !s.ldapInitialized {
-		s.cfg.Logger.DebugContext(context.Background(), "LDAP not ready, skipping discovery and attempting to reconnect")
+		s.cfg.Logger.DebugContext(s.closeCtx, "LDAP not ready, skipping discovery and attempting to reconnect")
 		s.mu.Unlock()
 		s.initializeLDAP()
 		return nil
 	}
 	s.mu.Unlock()
 
-	filter := s.ldapSearchFilter()
-	s.cfg.Logger.DebugContext(context.Background(), "searching for desktops", "filter", filter)
-
-	var attrs []string
-	attrs = append(attrs, computerAttributes...)
-	attrs = append(attrs, s.cfg.DiscoveryLDAPAttributeLabels...)
-
-	entries, err := s.lc.ReadWithFilter(s.cfg.DiscoveryBaseDN, filter, attrs)
-	if trace.IsConnectionProblem(err) {
-		// If the connection was broken, re-initialize the LDAP client so that it's
-		// ready for the next reconcile loop. Return the last known set of desktops
-		// in this case, so that the reconciler doesn't delete the desktops it already
-		// knows about.
-		s.cfg.Logger.InfoContext(context.Background(), "LDAP connection error when searching for desktops, reinitializing client")
-		if err := s.initializeLDAP(); err != nil {
-			s.cfg.Logger.ErrorContext(context.Background(), "failed to reinitialize LDAP client, will retry on next reconcile", "error", err)
+	result := make(map[string]types.WindowsDesktop)
+	for _, discoveryConfig := range s.cfg.Discovery {
+		filter := s.ldapSearchFilter(discoveryConfig.Filters)
+		s.cfg.Logger.DebugContext(s.closeCtx, "searching for desktops", "filter", filter)
+
+		var attrs []string
+		attrs = append(attrs, computerAttributes...)
+		attrs = append(attrs, discoveryConfig.LabelAttributes...)
+
+		entries, err := s.lc.ReadWithFilter(discoveryConfig.BaseDN, filter, attrs)
+		if trace.IsConnectionProblem(err) {
+			// If the connection was broken, re-initialize the LDAP client so that it's
+			// ready for the next reconcile loop. Return the last known set of desktops
+			// in this case, so that the reconciler doesn't delete the desktops it already
+			// knows about.
+			s.cfg.Logger.InfoContext(s.closeCtx, "LDAP connection error when searching for desktops, reinitializing client")
+			if err := s.initializeLDAP(); err != nil {
+				s.cfg.Logger.ErrorContext(s.closeCtx, "failed to reinitialize LDAP client, will retry on next reconcile", "error", err)
+			}
+			return s.lastDiscoveryResults
+		} else if err != nil {
+			s.cfg.Logger.WarnContext(s.closeCtx, "could not discover Windows Desktops", "error", err)
+			return nil
 		}
-		return s.lastDiscoveryResults
-	} else if err != nil {
-		s.cfg.Logger.WarnContext(context.Background(), "could not discover Windows Desktops", "error", err)
-		return nil
-	}
 
-	s.cfg.Logger.DebugContext(context.Background(), "discovered Windows Desktops", "count", len(entries))
+		s.cfg.Logger.DebugContext(s.closeCtx, "discovered Windows Desktops", "count", len(entries))
 
-	result := make(map[string]types.WindowsDesktop)
-	for _, entry := range entries {
-		desktop, err := s.ldapEntryToWindowsDesktop(s.closeCtx, entry, s.cfg.HostLabelsFn)
-		if err != nil {
-			s.cfg.Logger.WarnContext(s.closeCtx, "could not create Windows Desktop from LDAP entry", "error", err)
-			continue
+		for _, entry := range entries {
+			desktop, err := s.ldapEntryToWindowsDesktop(s.closeCtx, entry, s.cfg.HostLabelsFn, &discoveryConfig)
+			if err != nil {
+				s.cfg.Logger.WarnContext(s.closeCtx, "could not create Windows Desktop from LDAP entry", "error", err)
+				continue
+			}
+			result[desktop.GetName()] = desktop
 		}
-		result[desktop.GetName()] = desktop
 	}
 
 	// capture the result, which will be used on the next reconcile loop
@@ -214,7 +214,7 @@ func (s *WindowsService) deleteDesktop(ctx context.Context, d types.WindowsDeskt
 	return s.cfg.AuthClient.DeleteWindowsDesktop(ctx, d.GetHostID(), d.GetName())
 }
 
-func (s *WindowsService) applyLabelsFromLDAP(entry *ldap.Entry, labels map[string]string) {
+func (s *WindowsService) applyLabelsFromLDAP(entry *ldap.Entry, labels map[string]string, cfg *servicecfg.LDAPDiscoveryConfig) {
 	// apply common LDAP labels by default
 	labels[types.OriginLabel] = types.OriginDynamic
 	labels[types.DiscoveryLabelWindowsDNSHostName] = entry.GetAttributeValue(attrDNSHostName)
@@ -237,7 +237,7 @@ func (s *WindowsService) applyLabelsFromLDAP(entry *ldap.Entry, labels map[strin
 	}
 
 	// apply any custom labels per the discovery configuration
-	for _, attr := range s.cfg.DiscoveryLDAPAttributeLabels {
+	for _, attr := range cfg.LabelAttributes {
 		if v := entry.GetAttributeValue(attr); v != "" {
 			labels[types.DiscoveryLabelLDAPPrefix+attr] = v
 		}
@@ -311,6 +311,7 @@ func (s *WindowsService) ldapEntryToWindowsDesktop(
 	ctx context.Context,
 	entry *ldap.Entry,
 	getHostLabels func(string) map[string]string,
+	cfg *servicecfg.LDAPDiscoveryConfig,
 ) (types.WindowsDesktop, error) {
 	hostname := entry.GetAttributeValue(attrDNSHostName)
 	if hostname == "" {
@@ -323,7 +324,7 @@ func (s *WindowsService) ldapEntryToWindowsDesktop(
 	}
 	labels := getHostLabels(hostname)
 	labels[types.DiscoveryLabelWindowsDomain] = s.cfg.Domain
-	s.applyLabelsFromLDAP(entry, labels)
+	s.applyLabelsFromLDAP(entry, labels, cfg)
 
 	if os, ok := labels[types.DiscoveryLabelWindowsOS]; ok && strings.Contains(os, "linux") {
 		return nil, trace.BadParameter("LDAP entry looks like a Linux host")
@@ -335,7 +336,7 @@ func (s *WindowsService) ldapEntryToWindowsDesktop(
 	}
 
 	s.cfg.Logger.DebugContext(ctx, "resolved desktop host", "hostname", hostname, "addrs", addrs)
-	addr, err := utils.ParseHostPortAddr(addrs[0], defaults.RDPListenPort)
+	addr, err := utils.ParseHostPortAddr(addrs[0], cfg.RDPPort)
 	if err != nil {
 		return nil, trace.Wrap(err)
 	}
diff --git a/lib/srv/desktop/windows_server.go b/lib/srv/desktop/windows_server.go
index 767c9595106a7..630b0fd6194f2 100644
--- a/lib/srv/desktop/windows_server.go
+++ b/lib/srv/desktop/windows_server.go
@@ -202,16 +202,10 @@ type WindowsServiceConfig struct {
 	// If empty LDAP address will be used.
 	// Used for NLA support when AD is true.
 	KDCAddr string
-	// DiscoveryBaseDN is the base DN for searching for Windows Desktops.
-	// Desktop discovery is disabled if this field is empty.
-	DiscoveryBaseDN string
-	// DiscoveryLDAPFilters are additional LDAP filters for searching for
-	// Windows Desktops. If multiple filters are specified, they are ANDed
-	// together into a single search.
-	DiscoveryLDAPFilters []string
-	// DiscoveryLDAPAttributeLabels are optional LDAP attributes to convert
-	// into Teleport labels.
-	DiscoveryLDAPAttributeLabels []string
+	// Discovery contains policies for configuring LDAP-based discovery.
+	Discovery []servicecfg.LDAPDiscoveryConfig
+	// DiscoveryInterval configures how frequently the discovery process runs.
+	DiscoveryInterval time.Duration
 	// Hostname of the Windows desktop service
 	Hostname string
 	// ConnectedProxyGetter gets the proxies teleport is connected to.
@@ -235,21 +229,25 @@ type HeartbeatConfig struct {
 }
 
 func (cfg *WindowsServiceConfig) checkAndSetDiscoveryDefaults() error {
-	switch {
-	case cfg.DiscoveryBaseDN == types.Wildcard:
-		cfg.DiscoveryBaseDN = windows.DomainDN(cfg.Domain)
-	case len(cfg.DiscoveryBaseDN) > 0:
-		if _, err := ldap.ParseDN(cfg.DiscoveryBaseDN); err != nil {
-			return trace.BadParameter("WindowsServiceConfig contains an invalid base_dn: %v", err)
+	for i := range cfg.Discovery {
+		switch {
+		case cfg.Discovery[i].BaseDN == types.Wildcard:
+			cfg.Discovery[i].BaseDN = windows.DomainDN(cfg.Domain)
+		case len(cfg.Discovery[i].BaseDN) > 0:
+			if _, err := ldap.ParseDN(cfg.Discovery[i].BaseDN); err != nil {
+				return trace.BadParameter("WindowsServiceConfig contains an invalid base_dn %q: %v", cfg.Discovery[i].BaseDN, err)
+			}
 		}
-	}
 
-	for _, filter := range cfg.DiscoveryLDAPFilters {
-		if _, err := ldap.CompileFilter(filter); err != nil {
-			return trace.BadParameter("WindowsServiceConfig contains an invalid LDAP filter %q: %v", filter, err)
+		for _, filter := range cfg.Discovery[i].Filters {
+			if _, err := ldap.CompileFilter(filter); err != nil {
+				return trace.BadParameter("WindowsServiceConfig contains an invalid LDAP filter %q: %v", filter, err)
+			}
 		}
 	}
 
+	cfg.DiscoveryInterval = cmp.Or(cfg.DiscoveryInterval, 5*time.Minute)
+
 	return nil
 }
 
@@ -416,7 +414,7 @@ func NewWindowsService(cfg WindowsServiceConfig) (*WindowsService, error) {
 		return nil, trace.Wrap(err)
 	}
 
-	if len(s.cfg.DiscoveryBaseDN) > 0 {
+	if len(s.cfg.Discovery) > 0 {
 		if err := s.startDesktopDiscovery(); err != nil {
 			return nil, trace.Wrap(err)
 		}
@@ -429,7 +427,7 @@ func NewWindowsService(cfg WindowsServiceConfig) (*WindowsService, error) {
 	// if LDAP-based discovery is not enabled, but we have configured LDAP
 	// then it's important that we periodically try to use the LDAP connection
 	// to detect connection closure
-	if s.ldapConfigured && len(s.cfg.DiscoveryBaseDN) == 0 {
+	if s.ldapConfigured && len(s.cfg.Discovery) == 0 {
 		s.startLDAPConnectionCheck(ctx)
 	}
 


Test patch:
diff --git a/lib/config/configuration_test.go b/lib/config/configuration_test.go
index 4811d7b30be07..f3eb278ebf763 100644
--- a/lib/config/configuration_test.go
+++ b/lib/config/configuration_test.go
@@ -2357,6 +2357,22 @@ func TestWindowsDesktopService(t *testing.T) {
 				}
 			},
 		},
+		{
+			desc:        "NOK - legacy discovery and new discovery_configs both specified",
+			expectError: require.Error,
+			mutate: func(fc *FileConfig) {
+				fc.WindowsDesktop.Discovery = LDAPDiscoveryConfig{
+					BaseDN: "*",
+				}
+				fc.WindowsDesktop.DiscoveryConfigs = []LDAPDiscoveryConfig{
+					{BaseDN: "OU=stage,DC=example,DC=com"},
+					{BaseDN: "OU=prod,DC=example,DC=com"},
+				}
+				fc.WindowsDesktop.LDAP = LDAPConfig{
+					Addr: "something",
+				}
+			},
+		},
 		{
 			desc:        "OK - valid config",
 			expectError: require.NoError,
diff --git a/lib/srv/desktop/discovery_test.go b/lib/srv/desktop/discovery_test.go
index dfbc98e107b43..ee4977ca0ea18 100644
--- a/lib/srv/desktop/discovery_test.go
+++ b/lib/srv/desktop/discovery_test.go
@@ -35,6 +35,7 @@ import (
 
 	"github.com/gravitational/teleport/api/types"
 	"github.com/gravitational/teleport/lib/auth"
+	"github.com/gravitational/teleport/lib/service/servicecfg"
 	"github.com/gravitational/teleport/lib/services"
 	logutils "github.com/gravitational/teleport/lib/utils/log"
 )
@@ -63,13 +64,8 @@ func TestDiscoveryLDAPFilter(t *testing.T) {
 		},
 	} {
 		t.Run(test.desc, func(t *testing.T) {
-			s := &WindowsService{
-				cfg: WindowsServiceConfig{
-					DiscoveryLDAPFilters: test.filters,
-				},
-			}
-
-			filter := s.ldapSearchFilter()
+			s := new(WindowsService)
+			filter := s.ldapSearchFilter(test.filters)
 			_, err := ldap.CompileFilter(filter)
 			test.assert(t, err)
 		})
@@ -89,12 +85,11 @@ func TestAppliesLDAPLabels(t *testing.T) {
 		"quux":                {""},
 	})
 
-	s := &WindowsService{
-		cfg: WindowsServiceConfig{
-			DiscoveryLDAPAttributeLabels: []string{"bar"},
-		},
-	}
-	s.applyLabelsFromLDAP(entry, l)
+	s := new(WindowsService)
+	s.applyLabelsFromLDAP(entry, l, &servicecfg.LDAPDiscoveryConfig{
+		BaseDN:          "*",
+		LabelAttributes: []string{"bar"},
+	})
 
 	// check default labels
 	require.Equal(t, types.OriginDynamic, l[types.OriginLabel])
@@ -142,7 +137,7 @@ func TestLabelsDomainControllers(t *testing.T) {
 	} {
 		t.Run(test.desc, func(t *testing.T) {
 			l := make(map[string]string)
-			s.applyLabelsFromLDAP(test.entry, l)
+			s.applyLabelsFromLDAP(test.entry, l, new(servicecfg.LDAPDiscoveryConfig))
 
 			b, _ := strconv.ParseBool(l[types.DiscoveryLabelWindowsIsDomainController])
 			test.assert(t, b)
diff --git a/lib/srv/desktop/windows_server_test.go b/lib/srv/desktop/windows_server_test.go
index a27b1d0bcb42a..e535f06d52fde 100644
--- a/lib/srv/desktop/windows_server_test.go
+++ b/lib/srv/desktop/windows_server_test.go
@@ -38,6 +38,7 @@ import (
 	libevents "github.com/gravitational/teleport/lib/events"
 	"github.com/gravitational/teleport/lib/events/eventstest"
 	"github.com/gravitational/teleport/lib/modules"
+	"github.com/gravitational/teleport/lib/service/servicecfg"
 	"github.com/gravitational/teleport/lib/srv/desktop/tdp"
 	logutils "github.com/gravitational/teleport/lib/utils/log"
 )
@@ -49,13 +50,17 @@ func TestMain(m *testing.M) {
 
 func TestConfigWildcardBaseDN(t *testing.T) {
 	cfg := &WindowsServiceConfig{
-		DiscoveryBaseDN: "*",
+		Discovery: []servicecfg.LDAPDiscoveryConfig{
+			{
+				BaseDN: "*",
+			},
+		},
 		LDAPConfig: windows.LDAPConfig{
 			Domain: "test.goteleport.com",
 		},
 	}
 	require.NoError(t, cfg.checkAndSetDiscoveryDefaults())
-	require.Equal(t, "DC=test,DC=goteleport,DC=com", cfg.DiscoveryBaseDN)
+	require.Equal(t, "DC=test,DC=goteleport,DC=com", cfg.Discovery[0].BaseDN)
 }
 
 func TestConfigDesktopDiscovery(t *testing.T) {
@@ -95,8 +100,12 @@ func TestConfigDesktopDiscovery(t *testing.T) {
 	} {
 		t.Run(test.desc, func(t *testing.T) {
 			cfg := &WindowsServiceConfig{
-				DiscoveryBaseDN:      test.baseDN,
-				DiscoveryLDAPFilters: test.filters,
+				Discovery: []servicecfg.LDAPDiscoveryConfig{
+					{
+						BaseDN:  test.baseDN,
+						Filters: test.filters,
+					},
+				},
 			}
 			test.assert(t, cfg.checkAndSetDiscoveryDefaults())
 		})