ADSI und LDAP

Sehr viele Informationen zu Exchange stehen gar nicht in der Exchange Datenbank sondern im Active Directory. Damit ist natürlich ein Zugriff per LDAP und ADSI auf diese Informationen möglich. Zwar sollten Sie es vermeiden, direkt die Felder zu beschreiben, es sei denn Sie wissen um die Zusammenhänge aber auch zum Auslesen ist dies Schnittstelle wunderbar geeignet.

Achtung: Neue LDAP-Grenzen in Windows 2008R2
2009267 Windows Server 2008 R2 or Windows Server 2008 domain controller returns only 5000 attributes in a LDAP response
Dies ist z.B. für automatische Prozesse erforderlich, die große Datenmengen abfragen oder z.B. Gruppen mit mehr als 5000 Mitgliedern verarbeiten wollen.
Range Retrieval of Attribute Values http://msdn.microsoft.com/en-us/library/cc223242(PROT.10).aspx
Example Code für Ranging with IDirectoryObject http://msdn.microsoft.com/en-us/library/aa705923(VS.85).aspx
Example Code für Ranging with IDirectorySearch http://msdn.microsoft.com/en-us/library/aa705924(VS.85).aspx
Paging Search Results http://msdn.microsoft.com/en-us/library/aa367011(VS.85).aspx
LDAP Policies http://msdn.microsoft.com/en-us/library/cc223376(PROT.10).aspx

Folgende Informationen liegen z.B. im Active Directory und können per LDAP/ADSI ausgelesen werden:

  • Eigenschaften der Empfänger
    z.B.: die Mailadressen von Anwendern, Verteilern etc.
  • Mitgliedschaften von Verteilern
    Verteiler sind auch nur "Windows Gruppen", die sie auslesen können
  • Liste der Exchange Server
    Sogar die Versionsnummer ist im AD hinterlegt, genauso wie Daten zum Nachrichtentracking, Die Pfade der Exchange Datenbanken etc.
  • Übersicht über die Empfängerrichtlinien
    Welche SMTP-Adressen werden von RUS vergeben ?
  • Eigenschaften des ADC
    z.B.: um zu prüfen, wie aktuell der ADC gerade ist.
  • Und vieles mehr

Neben dieser Seite zu ADSI im allgemeinen habe ich auf PowerShell und LDAP/GC die Beispiele zum Zugriff per PowerShell beschrieben. und PS RSAT für die AD-Commandlets

Achtung Jan 2020 Update

Im Januar 2020 führt ein Update auf Windows Domain Controllern dazu, dass unsignierte LDAP-Anfragen per Default nicht mehr akzeptiert werden.

Wie verbinden?

ADSI können ist über zwei Wege nutzen:

  • Binden eines Objekts
    Sie können in VBScript einfach ein Objekt "binden", wenn Sie den Pfad dorthin können und dann dieses Objekt auch verändern. Wenn Sie z.B.: eine OU "binden", dann können Sie auch mit einer Schleife alle Unterobjekte durchlaufen, z.B.:

set oObject = GetObject("LDAP://cn=Administrator,cn=Users,dc=msxfaq,dc=de")

$oObject = [adsi]"LDAP://cn=Administrator,cn=Users,dc=msxfaq,dc=de"
  • ADO Abfragen
    Die zweite Option nutzt LDAP ähnlich einer Datenbankabfragen.

Bei allen Optionen müssen Sie nun den Pfad angeben. Diesen können Sie natürlich im Skript fest hinterlegen aber schön ist das nicht, da Sie dann das Skript immer anpassen müssen. Daher gibt es einige "vordefinierte" Pfade, die Sie einfach nutzen können z.B.:

set strRootDSE = GetObject ("LDAP://rootDSE")
wscript.echo strRootDSE.get ("defaultNamingContext")
wscript.echo strRootDSE.get ("schemaNamingContext")
wscript.echo strRootDSE.get ("configurationNamingContext")
wscript.echo strRootDSE.get ("rootDomainNamingContext")
wscript.echo join(strRootDSE.get ("namingContext"),vbcrlf)  'hier kommt ein Array !!

Das Ergebnis ist nicht sonderlich überraschend:

Beachten Sie, dass  der Eintrag "NamingContexts" ihnen in der Regel ein Array zurückliefert, welches sie nicht einfach per wscript.echo ausgeben können.

Die PowerShell-Version ist sogar noch kürzer:

$RootDSE = [adsi]"LDAP://rootDSE"
$RootDSE.defaultNamingContext
$RootDSE.schemaNamingContext
$RootDSE.configurationNamingContext
$RootDSE.rootDomainNamingContext
$RootDSE.namingContexts

GC Suche mit VBScript

Der einfache Fall einer LDAP-Abfrage stellt die direkte Verbindung zu dem Server darf, der die Informationen schon enthält. Nun kann aber LDAP und das Active Directory im Besonderen auch mehrere Server bereitstellen. Solange diese alle in einer Domäne sind, haben Sie alles die gleiche Information. Sobald es aber mehrere Domänen gibt, wird es interessant.

Ein LDAP-Server kann eine Anfrage zu einem Object, welches er selbst nicht vorhält, über einen Referral (RFC 2251) zu einem anderen LDAP-Server weiter verweisen. Der LDAP-Client sollte so eine Weiterleitung, die als LDAP_ERROR gemeldet wird, verstehen.

Allerdings kostet LDAP-Verweise natürlich Zeit und das Active Directory kennt schon seit der ersten Version das Konzept einen „globalen Katalogs“, der zumindest eine Teilinformation von allen Objekten im Active Directory vorhält. Allerdings muss ein LDAP-Client dann natürlich auch den GC auf den Port 3268 verbinden und die Informationen suchen.

Hier ein Beispiel, wie sie einen GC nach allen Mailobjekten (mailnickname=*) befragen.

wscript.echo  "Looking für GC"
dim oCont, oGC
Set oCont = GetObject("GC:") für Each oGC In oCont
    strGCPath = oGC.ADsPath
Next
wscript.echo "strGCPath=" & strGCPath, 3

wscript.echo "Querying AD für Objects" & strGCPath
Set oConnection = CreateObject("ADODB.Connection")
Set oRecordset = CreateObject("ADODB.Recordset")
Set oCommand = CreateObject("ADODB.Command")
oConnection.Provider = "ADsDSOObject"  'The ADSI OLE-DB provider
oConnection.Open "ADs Provider"
oCommand.ActiveConnection = oConnection
oCommand.Properties("Page Size") = 100
oCommand.CommandText = "<" & strGCPath & ">;" & _
	"(mailnickname=*);" & _
	"distinguishedName,ObjectClass,displayName,mail" & _
	";subtree"
Set oRecordset = oCommand.Execute
wscript.echo "Done Total Records found:" & oRecordset.recordcount

do until oRecordset.EOF
	wscript.echo "---- Infos aus dem ADO-Recordset ----"
	wscript.echo "Klasse:" & lcase(join(oRecordset.Fields("ObjectClass"),","))
	wscript.echo 	
	wscript.echo "distinguishedName:" & oRecordset.Fields("distinguishedName")
	wscript.echo "displayName      :" & oRecordset.Fields("displayName")
	wscript.echo "Mail             :" & oRecordset.Fields("mail")
	wscript.echo "---- Infos aus dem gebundenen Object ----"
	set oObject = GetObject("LDAP:// " & oRecordset.Fields("distinguishedName"))
	wscript.echo "name          :" & oObject.name
	wscript.echo "SamAccountName:" & oObject.samAccountName
	oRecordset.MoveNext
loop

Natürlich können Sie das Script auch einfach für eigene Tests herunterladen.

gcsearch.vbs
Nach dem Download als VBS-Datei speichern und mit CSCRIPT aufrufen.

Es gibt noch eine zweite "Abfragesprache" um den LDAP-Query zu erstellen. Sie ähnelt eher einer SQL-Abfragen.

Set objADO = CreateObject("ADODB.Connection") 
objADO.Provider = "AdsDSOObject" 
objADO.Open 
strSQL = "SELECT mail, st FROM 'LDAP://server/o=msxfaq' WHERE objectClass='person'" 
Set rs = objADO.Execute(strSQL)
For i = 0 to rs.Fields.Count - 1 
    strHead = strHead & rs.Fields(i).Name & vbTab 
Next 
While Not rs.EOF 
    strOut = strOut & GetRows(rs) & vbCrLf 
    rs.MoveNext 
Wend

Das Ergebnis ist aber identisch.

GC Einschränkung: Scope und Multi Domain

Auf diesen Fehler stoßen die meisten Administratoren nie, weil Sie nur genau einen Forest mit einer Domäne haben. Hier ist es irrelevant, ob sie einen GC oder einen DC fragen. Sie bekommen immer alle Daten. Wenn Sie aber mehrere Domänen haben, dann ist es zwar so, dass jeder GC auch den gleichen Datenbestand hat, aber bei der Suche doch andere Defaults angewendet werden. Wer hier einen GC in einer Sub-Domäne anfragt und nicht explizit auch einen "Search Scope" mit gibt, bekommt nur partielle Ergebnisse. Mit einem leeren Scope nutzt der GC einfach "seine Default Domain" als Scope.

Es macht also schon einen Unterschied, wie man den den ADSISearcher instanziert:

# Verbindung mit dem GC-Moniker funktioniert bei mir zumindest nicht. in VBScript war das noch OK
$objSearcher = [adsisearcher]([ADSI]"GC://")

# Verbindung mit dem RootNC - Das funktioniert mit jedem DC
$objSearcher = [adsisearcher]([ADSI]"GC://dc=msxfaq,dc=net")

# Verbindung mit einem RootDC - Liefert alle Ergebnise
$objSearcher = [adsisearcher]([ADSI]"GC://rootdc.msxfaq.net")

Verbindung mit einem DC in einer Subdomain ohne SearchRoot - Liefert nur Teilergebnisse aus sub.msxfaq.net
$objSearcher = [adsisearcher]([ADSI]"GC://subdc.sub.msxfaq.net/")

#Verbindung mit einem DC in der Subdomain mit SearchRoot - Liefert alle Ergebnisse
$objSearcher = [adsisearcher]([ADSI]"GC://subdc.sub.msxfaq.net/dc=msxfaq,dc=net")

Der praktische Weg ist also den Root-Namingcontext zu ermitteln und sich dann mit oder ohne Angabe eines DCs damit zu verbinden.

$RootNC = ([ADSI]("LDAP://rootDSE")).rootDomainNamingContext
$objSearcher = [adsisearcher]([ADSI]"GC://$RootNC")

Das Problem ist bei Abfragen gegen den DC auch vorhanden, nur dass Sie da bei der Abfrage nach einem Objekt in einer anderen Domäne einen LDAP_REFERRAL bekommen. Bei einer Anfrage an den GC bekommen Sie einfach nichts.

GC Einschränkung: Pagesize

Wenn Sie eine Suche an einen GC stellen, um eben Objekte aus allen Domänen des aktuellen Forest zu erhalten, dann sollten Sie auch mit einer großen Antwort rechnen. Damit kommt bei GC-Suchen erst recht das Thema "Paging" zum Tragen, das weiter unten bei generellen ADSI-Anfragen beschrieben ist.

GC Einschränkung: Struktur

Wenn Sie per Software eine Suche gegen den GC machen wollen, müssen Sie sich aber mit einem anderen LDAP-Port verbinden. Es gibt auch keine „Struktur“ wie bei einer Domäne mit OUs etc., sondern nur eine lange Liste, die man hoffentlich mit der richtigen Suche entsprechend gekürzt geliefert bekommt. Sie bekommen natürlich den DN eines gefundenen Objekts und können mit einem Scope auch filtern. Aber sie können sich z.B. nicht auf eine OU „binden“ und dann die Childobjekte abfragen.

GC Einschränkung: PropertySet/Felder

Wenn Sie glauben, dass der GC "alles weiß", dann irrien Sie. der GC kennt zwar jedes Objekt aber nicht zwingend mit allen Details. Leider liefert solch eine Anfrage nur die Ergebnisse, die der globale Katalog auch vorhält. Speziell MultiValue-Felder sind hier kritisch. So können Sie die Fehler "msExchPolicyIncluded" und "msExchPolicyExcluded" nicht über den GC auslesen. Die Inhalte sind einfach "null". Dieser Fehler fällt ihnen normalerweise nicht auf, wenn Sie "genau eine" Domäne betreiben, da dann der GC natürlich auch die anderen Felder "seiner" Domäne liefern kann.

Ob ein Feld in den GC repliziert wird, können Sie im Schema selbst nachschauen.

Auch Microsoft veröffentlicht auf der Webseite die Information, welche Felder im GC per Default enthalten sind:

Sie sollten aber nun nicht aus Verzweiflung ihre gewünschten Felder im Schema für den GC aktivieren. Dies ergibt eine hohe Replikationslast und ist nicht sinnvoll.

Und dann gibt es noch ein paar "besondere Felder, die bei einer GC-Suche mit Vorsicht zu behandeln sind.

  • Systemfelder wie MemberOf
    Diese Felder werden z.B. "errechnet", d.h. sie sind sogar nicht da und durchsuchbar, sondern das AD ermittelt den Inhalt aus dem Feld "Members" der jeweiligen Gruppen.
  • Numerische Felder "USNChanged" etc.
    Sie können natürlich auch auf diese Felder filtern. Dabei können Sie aber nur die Optionen "<=" und ">=" nutzen. Eine Suche nach ">" oder "<" ist nicht direkt möglich. Sie könne aber natürlich ">" ersetzen durch "!(feld<=wert).
    Siehe dazu auch http://tools.ietf.org/html/rfc4515 (and, or, not, equalityMatch, substrings, greaterOrEqual,lessOrEqual,present,approxMatch,extensibleMatch)

Auslesen über das Objekt

Um diese Felder zu erhalten, können Sie, wie im Beispielskript schon exemplarisch umgesetzt, sich einfach mit dem Objekt verbinden und dann auf alle Felder zugreifen. Dieser Vorgang ist aber "langsam" und belastet das Netzwerk, wenn Sie wirklich nur Daten auslesen wollen.

Auslesen aller Objekte aus allen Domänen

Der bessere Weg ist dann eine Verbindung mit der jeweiligen Domäne herzustellen. Folgender Beispielcode holt sich auf der Konfiguration des Active Directory alle Partitionen und verbindet sich dann sequentiell mit den einzelnen Domains und holt die gewünschten Daten ab.

set strRootDSE = GetObject ("LDAP://rootDSE")

wscript.echo "Loading Domains at LDAP://CN=Partitions," & strRootDSE.get ("configurationNamingContext")
set opartition = GetObject("LDAP://CN=Partitions," & strRootDSE.get ("configurationNamingContext")) für each oDomain in opartition 
   if oDomain.NetBIOSname = "" then 
      wscript.echo "Skip Domain: " & oDomain.dnsroot
   else
      wscript.echo "Processing Domain: " & oDomain.dnsroot
      call ParseDomain(oDomain.dnsroot)
   end if
next

sub ParseDomain(strdomainname)

   wscript.echo "Querying AD für Objects at Domain:" & strdomainname
   Set oConnection = CreateObject("ADODB.Connection")
   Set oRecordset = CreateObject("ADODB.Recordset")
   Set oCommand = CreateObject("ADODB.Command")
   oConnection.Provider = "ADsDSOObject"  'The ADSI OLE-DB provider
   oConnection.Open "ADs Provider"
   oCommand.ActiveConnection = oConnection
   oCommand.Properties("Page Size") = 100
   oCommand.CommandText = "<LDAP://" & strdomainname & ">;" & _
      "(mailnickname=*);" & _
      "distinguishedName,ObjectClass,displayName,mail" & _
      ";subtree"
   Set oRecordset = oCommand.Execute
   wscript.echo "Done Total Records found:" & oRecordset.recordcount
   do until oRecordset.EOF
      wscript.echo "distinguishedName:" & oRecordset.Fields("distinguishedName")
      wscript.echo "displayName      :" & oRecordset.Fields("displayName")
      wscript.echo "Mail             :" & oRecordset.Fields("mail")
      oRecordset.MoveNext
   loop
end sub

Hier das ganze auch als Download:

domainsearch.vbs
Bitte als VBS-Datei speichern und mit CSCRIPT starten

Dieser Weg ist zwar auch nicht grade "hübsch", aber wenn sie bei der Auswertung von 100.000 Objekten nicht mehrere Stunden sondern nur einige Minuten warten wollen, dann ist dieser Weg immer besser als ein Bind auf jedes einzelne Objekt.

Achtung: Das Script nimmt nur die Partitionen mit einem NetBIOS-Namen des Active Directory.Seit Windows 2003 gibt es z.B. auch die DNS-Partitionen. Zudem könnten von ihnen gesuchte Objekte auch in der Konfigurationspartition liegen. Dies sollten Sie beim Programmieren beachten um Dubletten zu vermeiden.

Filtern

Eine LDAP-Abfrage enthält auch immer einen Suchfilter. Der Einsatz von Filtern schon bei der Anfrage ist besser als sich alle Daten zu laden und dann auf dem Client zu filtern. Zum einen wird Bandbreite gespart aber auch die lokale Verarbeitung ist vereinfacht und schneller. Einfache LDAP-Filter sind

# Ich suche genau 
(samaccountname=frank)

(samaccountname=frank*)

Natürlich können und sollten Sie auch LDAP-Filter mit UND etc. verknüpfen. Beachten Sie dabei aber die "Umgekehrte polnische Notation", d.h. der Operator steht vor den elementen

# Ich suche genau 
(&(objectclass=group)(samaccountname=frank))

(|(mail=*)(proxyaddresses=*))

(!(mail=*))

Es sind natürlich noch komplexere Strukturen möglich. Beachten Sie aber, dass es auch Ausnahmen und Besonderheiten gibt, z.B:

  • DistinguishedName-Felder
    Diese kann man nicht mit "Wildcards" durchsuchen
  • Einziger Wildcard ist "*"
    Man kann also nicht mit (User=Frank?Carius) nach Objekten suchen, die mit einem beliebigen Zeichen getrennt sind.
  • Mehrfachfelder
    Wenn ein Feld wie z.B. ProxyAddresses eigentlich ein Array ist, dann wird jedes einzelne Feld durchsucht.
  • ANR - Ähnlich
    Es gibt Felder, die sind für "Ambiguous Name resolution" aktiviert, d.h. hier kann man auch "ähnliche" Inhalte finden.
  • Index
    Nicht alle Felder sind im Index. Zwar sind mittlerweile alle DCs vermutlich mit 64bit und genug Hauptspeicher ausgestattet, um alle Daten im RAM zu halten, aber dennoch sollten Sie bei vielen Abfragen schauen, ob sie vieleicht das Schema anpassen können.
  • Backlink Felder
    Dann gibt es noch errechnete Felder wie z.B. "MemberOf", die es im Active Directory nur indirekt gibt. Sie werden über einen Index aus den Feld "Member" ermittelt. Dennoch kann darüber gesucht werden. Da in diesem Feld aber z.B. nur "DistinguishedNames" stehen, die z.B. keine Wildcard-Suche unterstützen, muss der DN komplett in der Suchanfrage angegeben werden.

Einige Felder sind sogar noch mit Optionen anpassbar. Wer z.B. über "memberOf" sucht, findet nur die Objekte, die in der Gruppe direkt Mitglied sind. Man kann aber durchaus auch rekursiv die Mitglieder ermitteln lassen. Allerdings ist die Schreibweise etwas ungewöhnlich:

# Einfache anfrage der direkten Mitglieder
(memberof=cn=group1,ou=test,dc=msxfaq,dc=net)

# Rekursive Anfrage
(memberof:1.2.840.113556.1.4.1941 =cn=group1,ou=test,dc=msxfaq,dc=net)

Allerdings sollten Sie genau überlegen, ob Sie statt auf die Suche nach "MemberOf" zu setzen, vielleicht besser das "Member"-Attribut der Gruppe auslesen und die Ergebnisse daraufhin weiter filtern.

Limits und Paging

Bei den Beispielen weiter oben haben Sie sicher schon eine Zeile entdeckt, die gerne vergessen wird:

oCommand.Properties("Page Size") = 1000

Das Active Directory skaliert wunderbar und das führt letztlich auch dazu, dass sehr viele Objekte dort abgelegt werden können. Eine Suche nach Elementen oder das Auslesen eines Feldes kann daher nicht nur etwas Zeit dauert, sondern auch sehr viele Daten liefert. Würde der DC die Ergebnisse komplett zusammen sammeln und dann komplett an den Client senden, dann wäre das weniger effektiv noch Ressourcenschonend. Auch wäre der DC relativ einfach "angreifbar", wenn man ihn einfach mit unsinnigen Anfragen belastet. Auch eine Telefonauskunft lässt sich mit viele Anrufen einfach überlasten, nur dass Sie dort je Anruf richtig dafür "bezahlen". LDAP-Anfragen werden normal nicht verrechnet.

Der DC schon seine Ressourcen, indem er nur so viele Ergebnisse liefert, wie in den Buffer für den Client passen und der Client die Information bekommt, dass es noch mehr zu holen gibt. Der Client kann nach dem Abruf der Daten dann "die nächsten" Daten der Gruppe anfordern. Dazu dient die Spezifizierung einer "Pagesize".

Per Default hat ein DC z.B.: eine Pagesize von 1000 Elementen aber erst durch die explizite Angabe einer PageSize im Skript wird auch der ADSI-Stack angewiesen, eine "PageQuery" zu machen. Vorher versucht er einfach alles zu lesen und hört dann einfach auf. Die Grenzen Sind je nach Version des DCs unterschiedlich.

Betriebssystem Default Pagesize Default ValueRange MaxLimit PageSize MaxLimit ValueRange

Windows 2000

1000

1024

Kein

Kein

Windows 2003

1000

1500

Kein

Kein

Windows 2008/2008R2 und neuer

5000

5000

20000

5000

Die "PageSize" ist konfigurierbar und ich habe nicht wenige Firmen gesehen, die aus den 1000 bei Windows 2000 einfach mal 50.000 gemacht haben, nur weil eine Software nicht korrekt mit PageSearch umgehen kann. Damit ist dann spätestens bei Windows 2008 schon Schluss, da Microsoft hier harte Limits hinterlegt hat, die auch nicht per Richtline zu verändern sind.

  • 315071 How to view and set LDAP policy in Active Directory by using Ntdsutil.exe

Auch wer z.B. von einer Gruppe ein Feld wie "Member" auslesen will, wird nach ca. 1500 Mitgliedern keine weiteren Daten bekommen. Auch hier muss man ein "ADO Page Read" durchführen, was aber gar nicht mehr "trivial" ist.

Gerade Felder wie "Member" oder MemberOf" lassen sich oftmals auch über eine LDAP-Suche einfacher durchführen. Anstatt also sich von einer Gruppe das Feld "Member" zu holen, kann man auch per LDAP suchen nach "(memberof=cn=gruppe...)" und so elegant die Beschränkung umgehen.

GetInfo, SetInfo und GetInfoEx

ADSI lädt per Default immer die "Standardfelder" in den lokalen Cache und arbeitet damit. d.h. wen Sie nun über Minuten hinweg mit einem Objekte arbeiten, könnte es sein, dass dieses auf dem Domänencontroller schon wieder geändert wurde. Um eben diesen Cache wieder aktualisieren muss man dann "Getinfo" verwenden. Getinfo sorgt dafür, dass das ADSI-Objekt neu geladen wird.

Wenn Sie Feldinhalte verändern, dann werden auch diese nicht sofort in das LDAP-Verzeichnis zurück geschrieben, sondern erst ein "SetInfo" schreibt die Inhalte letztlich zurück. Wenn Sie viele Werte ändern, dann kann es sich anbieten, mehrere SetInfo einzubauen, damit man bei Fehlern auch besser sieht, welches Feld gerade nicht "gemocht" wird. Wenn Sie jedoch sicherstellen wollen, dass mehrere Fehler immer "als Block" geändert werden (analog zu einem BeginTransaction und EndTrancaction bei Datenbanken) dann sollten Sie das SetInfo nach der letzten Änderung durchführen.

Wenn Sie nicht genau wissen, welche Felder das Objekt ihnen anbietet, dann kann folgende Codesequenz helfen:

for count = 0 to object.propertycount
    wscript.echo object.item(count).name & vbtab & object.item(count).value
next

Sie holt erst die Anzahl der Properties und gibt dann die Namen und Werte der einzelnen Eigenschaften aus.

Einige besondere Eigenschaften können Sie so aber nicht erhalten. Die so genannten "Operationalen Attribute", d.h. Felder die Sie nicht setzen können, aber durch das LDAP-Verzeichnis einfach befüllt werden, erhalten Sie erst durch einen "GetInfoEx". Allerdings sorgt dieser Befehl indirekt auch dafür, dass nun alle Felder, die einfach nur ein "String" sind, nun durch ein Array mit der Größe 1 ersetzt werden. Beim Zugriff muss man also auf das Feld 0 zugreifen.

ADSI und Last

ADSI hat unter Entwicklern manchmal den Ruf, dass es zwar nett und einfach zu nutzen ist, aber die Performance nicht gerade zum besten ist. Das ist auch bei .NET 1.1 noch so, das hier auf ADIS zurück gegriffen wird. Erst mit .NET 2.0 hat sich dies wohl geändert. Um die Funktion von ADSI und die Belastung besser zu verstehen, habe ich ein ganz kleines Script gebaut und den Traffic mitgeschnitten:

WScript "1. Bind Object"
set test = GetObject("LDAP://CN=Carius\, Frank,OU=Technik,OU=Abteilung,DC=netatwork,DC=de")

WScript.echo "1. Ausgabe von: test.ADsPath " & test.ADsPath 
WScript.echo "1. Zugriff auf Feld test.msExchPoliciesIncluded" & vartype(test.msExchPoliciesIncluded)
WScript.echo "2. Zugriff auf Feld test.msExchPoliciesIncluded" & vartype(test.msExchPoliciesIncluded)

WScript "2. Bind Object"
set test = GetObject("LDAP://CN=Carius\, Frank,OU=Technik,OU=Abteilung,DC=netatwork,DC=de")
WScript.echo "2. Ausgabe von: test.ADsPath " & test.ADsPath 
WScript.echo "3. Zugriff auf Feld test.msExchPoliciesIncluded" & vartype(test.msExchPoliciesIncluded)

Die Screen Captures wurden mit Insight Active Directory erstellt, welches Sie auf www.winternals.com als Bestandteil des Administrators Pack kostenpflichtig kaufen können.
Update: Auch die Kostenfreie ADInsight-Version auf http://technet.microsoft.com/de-de/sysinternals/bb897539 sollte die Funktion erfüllen.

Diese Script bindet sich einfach an meinen Benutzer und gibt ein paar Werte aus. Dabei ist folgendes zu sehen:

  • Erstes Binden mit dem Objekt
    Hierbei gehen sehr wenige Daten über die Leitung
    .
    Eigentlich wird der Zugriff zuerst anonym versucht, was natürlich nicht funktioniert und dann mit meinen Credentials. Das Object wird schnell gefunden
  • Ausgabe des ADSPath
    Für diese Ausgabe wird keine weitere Verbindung zum LDAP-Server benutzt. Die Information ist also schon im ADSI-Objekt enthalten
  • Ausgabe eines "Nutzfelds" (hier msExchPoliciesIncluded)
    Für die Ausgabe dieses einen Felds kommt sehr viel Bewegung in das Active Directory. Es schein so, als ob das ADSI-Objekt einfach alle Felder dieses Objekts vorsorglich einliest. Und weil damit nicht genug ist, wird auch noch schnell ein Teil des Schemas eingelesen.

    Das mag auf den ersten Blick erst mal als Verschwendung angesehen werden, da hierbei natürlich viele Daten auch überflüssigerweise gelesen werden. Das müssen wir erst mal so hinnehmen
  • Weiteres Feld ausgeben
    Wird dann ein weiteres Feld des gleichen Objekts ausgegeben, dann werden die Daten des lokalen Cache genutzt, d.h.  die Daten werden nicht erneut vom LDAP-Server gelesen. Da kann natürlich bedeuten, dass ein Script, welches noch andere Funktionen ausführt und erst später auf das Objekt zurück kommt auf alten Daten aufsetzt.
  • Objekt erneut binden
    Im nächsten Schritt wurde das Objekt einfach noch mal gebunden. Wie nicht anders zu erwarten wird das bestehende Objekt nicht "recycled", sondern die Bindung wird komplett frisch aufgebaut. Das Feld "ADSPAth" wird dann noch direkt ausgegeben. Beim Zugriff auf ein anderes Feld wird erneut das komplette Objekt in den Speicher übertragen.
    Besonders "unschön" ist, dass auch die verbundenen Informationen im Schema ebenfalls erneut geladen werden, obwohl diese ja unverändert sind. Ich kann nur vermuten, dass die Entwickler hier die Aktualität und Gültigkeit der Daten höher priorisiert haben als Bandbreite und Belastung einsparen.

Abhilfe ist hierbei nicht einfach möglich. Wenn Sie große Datenmengen auslesen wollen, dann sollten Sie besser mit ADO die Daten unter Angabe entsprechender LDAP-Filter und Feldangaben abfragen, wobei diese jedoch nur zum Lesen geeignet sind. Um ein Objekt zu verändern, müssen Sie es binden und schreiben.

ADSI und VBScript-Besonderheiten

Wenn Sie nun schon am Skripten sind, dann sollten Sie auf jeden Fall genug Code für Debugging einbauen und einzelne Funktionen oder Teile ausführlich Testen. VBScript aber auch ADSI haben einige Besonderheiten, die man nicht auf Anhieb erkennt. Folgendes Beispiel soll das verdeutlichen:

set objUser = GetObject("LDAP://cn=User1,ou=Anwender,dc=msxfaq,dc=de")
wscript.echo "Test1:" & objUser.name
wscript.echo "Test2:" & objUser.get("name")

Beide Mal soll der Inhalt des Feldes "Name" ausgegeben werden. Die tatsächliche Ausgabe sieht aber wie folgt an:

Test1:CN=User1
Test2:User1

Diese "Besonderheit" hat mich schon einige Stunden gekostet, wenn ein VBScript z.B. anhand des Namens eines Objekts dann weitere Verarbeitungen durchführen soll.

ADSI und mehrere Domains

Kniffliger wird die Arbeit mit mehreren Domänen oder Forests. Per Default verbindet sich ADSI immer mit dem DC, der für den angemeldeten Benutzer maßgeblich ist. Eine Suche gegen den globalen Katalog ist zumindest im Bezug auf den Forest fast immer vollständig (Sonderfall Domain lokale Gruppen). Aber Änderungen an Objekten müssen immer auf einem DC durchgeführt werden, welcher auch eine beschreibbare Partition hat. Den muss man sich aber selbst suchen oder unter expliziter Angabe der Domäne suchen lassen.

Interessant wird hier auch die Aufgabenstellung, einen Benutzer in einer Domäne anzulegen und im gleichen Schritt in eine Gruppe in einer anderen Domäne zu addieren, welche in einem anderen Standort ist. Spätestens hier kommt dann die "Replikation" ins Spiel, dass der DC der Gruppe den Benutzer noch gar nicht auflösen kann. Die einfache Variante der folgenden Form funktioniert daher nicht:

Set GroupObj = GetObject("WinNT://domain01/gruppe01")
GroupObj.Add ("WinNT://domain02/User01)

Auch der Versuch per LDAP schlägt fehlt:

Set objUser = GetObject("LDAP://cn=User01,ou=test,dc=domain,dc=tldde")
Set objGroup = GetObject("LDAP://cn=gruppe01,ou=test,dc=domain02,dc=tld")
objGroup.Add(objUser.ADsPath)

Allerdings gibt es ja noch andere Schreibweisen, um einen Benutzer in eine Gruppe zu addieren. Eine spezielle Syntax erlaubt die Angabe der SID:

GroupObj.Add ("WinNT://SID=<"&sid&">")

Allerdings müssen Sie dazu die SID natürlich erst noch in das SDDL-Format bringen, um dieses zu addieren. Ein einfaches "objUser.SID" liefert nur ein Byte-Array zurück. Die erforderliche Konvertierung ist in VBScript etwas mühselig aber von anderen Autoren schon beschrieben.

Nach meinen Erfahrungen funktioniert der Weg über die SID aber nur, wenn die SID nicht im gleichen Forest sind. Ein Addieren einer SID eines Objekts aus einer andere Domäne im gleichen Forest konnte ich nicht durchführen. Anscheinend erkennt der DC, dass es sich um eine SID im Forest handelt und versucht den DN aufzulösen, genau wie bei allen anderen Wegen. Und dies funktioniert nur, wenn das Objekt schon im über den GC auflösbar ist.

ADSI/LDAP und Update von Objekten

Bei meiner Umsetzung der Skripte zur Verzeichnissynchronisation (MiniSync) ist mir ein interessante Verhalten aufgefallen. Wenn ein Skript Felder wieder beschreibt und am Ende mit SETINFO an den LDAP-Server sendet, dann aktualisiert der Server die Daten und erhöht die USN.

Wenn ich per VBScript jedoch ein Feld mit einem Wert beschreibe, der dem alten Wert entspricht, dann kann ich sehr oft ein SETINFO aufrufen und die Daten werden auch an den Server übertragen, aber das Windows 2003 Active Directory führt die Änderungen nicht aus. Das ist auch kein Fehler sondern eher eine sinnvolle Optimierung, denn wenn ein Feld nicht wirklich geändert wird, dann muss man auch keine Datenbankaktivität und Replikation  provozieren. Interessant ist das in der Hinsicht, dass die Skripte nicht mehr selbst diese Optimierung durchgehen müssen. Mich würde interessieren, ob andere LDAP-Server das gleiche Verhalten zeigen.

Auch wenn das Objekt direkt per LDAP mit LDP.EXE beschrieben wird, zeigt sich das gleiche Verhalten. Es ist also keine Funktion des ADSI-Clients.

ADSI und Performance beim Suchen

Bei der Abfrage eines LDAP-Verzeichnisses per ADSI sollten Sie auch noch ein Blick auf ihre Filterkriterien werfen. Nicht alle Felder sind mit einem Index versehen. So kann eine Anfrage wie "(telephoneNumber=*)" sehr viel Last erzeugen, weil die Telefonnummer nicht mit einem Index versehen ist. Es kann daher sogar günstiger sein, eine andere Abfrage zu wählen, bei der mehr Antworten kommen und dann das richtige Objekt auszusuchen. Auch Exchange macht solche Anfragen. Sie "suche" nach einem Objekt anhand des DN ist ungünstig, da dieser Name nicht im Index ist, der CN hingegen ist indexiert. Wenn Sie daher nach dem CN suchen, dann gibt dies meist auch nur genau einen Treffer. Selbst wenn es mehrere Objekte mit dem gleichen CN in unterschiedlichen OUs gibt, dann ist die Abfrage um ein vielfaches schneller, so dass die nachfolgende Prüfung der Liste auf der gewünschte Objekt sehr schnell erfolgt.

In dem Zuge sollten Sie auch die Besonderheit von der Eigenschaft "Recordcount" können. Nach einer ADO-Suche kann man über den Recordcount die Anzahl der gefundenen Elemente erhalten. Schlecht ist allerdings, dass auch der DC die genauer Anzahl eigentlich nicht weiß und ADSI für die Bestimmung alle Objekte abholt. Wenn Sie daher bei einer Anfrage z.B.: 30000 Einträge erhalten, dann dauert eine schlichte Ausgabe von objRecordset.recordcount durchaus einige Sekunden oder gar Minuten. Wenn Sie also sowieso mit einem "While not EOF"-Schleife die Ergebnisse abarbeiten wollen und keine Fortschrittsanzeige benötigen, dann verzichten Sie doch einfach darauf. Gerade wenn z.B.: ein Skript unbeobachtet läuft, ist dies problemlos möglich. (Ich habe bei MiniSync mittlerweile auch drauf verzichtet).

Übrigens gibt es bei Windows 2003 auf der OU ein Property "msDS-Approx-Immed-Subordinates", welches eine geschätzte Anzahl der Unterobjekte enthält. Das hilft aber nicht bei einer Suche über die Domäne oder den GC, sondern nur bei einer Auflistung in dieser OU. Und auch dann ist das eher ein Schätzwert.

Das setzen der "Page Size" hat nach meinen Erfahrungen keinen merklichen Einfluss auf die Geschwindigkeit. Daher bleibe ich hierbei besser unter der Grenze des LDAP-Servers (Ex55 = 1000. W2k=1000, WW3K=1500), z.B. durch

objCommand.Properties("Page Size") = 100 ' Paging akivieren

Übrigens können Skripte auf Servern kräftig gebremst werden, wenn die Performance des Servers für Hintergrundprozesse optimiert wird.

Weitere Links

Sehr viele Beispiele und Tools auf dieser Webseite nutzen VBScript und ADSI, um bestimmte Tätigkeiten durchzuführen.