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.

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

Wie verbinden ?

ADSI können ist über zwei Wege nutzen:

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

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 ("namingContexts"),vbcrlf)  'hier kommt ein Array !!

Das Ergebnis ist nicht sonderlich überraschend:

Diese Werte können Sie nun aber ganz einfach in ihren eigenen Skripten verwenden.

rootdse.vbs.txt

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

Suchen und Auslesen von Objekten aus dem GC

Neben der direkte Verbindung zu einem bekannten benannten Objekt, kann man mit ADSI natürlich auch LDAP-Abfragen absetzen und so gezielt nach bestimmten Objekten suchen. Hier ein Beispiel, wie sie einen GC nach allen Mailobjekten (mailnickname=*) befragen.

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

wscript.echo "Querying AD for 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

Wenn Sie den GC befragen, müssen Sie sich nicht um die verschiedenen Domains in ihrem Forest kümmern. Aber Sie sollten eine wichtige Einschränkung kennen:

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.

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 das gleiche. Bei der Suche muss man aber ein paar Einschränkungen wissen:

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"))
for 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 for 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.

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.

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

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 Powershell

Natürlich kann man auch per Powershell über das ADSI-Kürzel direkt auf Objekte zugreifen.

$objUser = [adsi]"LDAP://cn=user1,ou=Anwender,dc=msxfaq,dc=de"

Wichtig ist hier die genaue Schreibweise mit LDAP in Großbuchstaben und "//"-Zeichen. Ein paar sehr gute Anleitungen gibt es auf:

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" kennen. 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 ' Pageing 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.

Keywords: ADSI VBScript LDAP