コンテンツへスキップ

PowerShell で LDAP 接続を検証する。段階的に確認するスクリプトと、返ってくる値の読み方

PowerShell のターミナルから LDAP ディレクトリサーバーへ接続し、ディレクトリツリーを確認する様子のイラスト

LDAP サーバーを立てたあと、「ちゃんと繋がるのか」を確認したくなります。GUI なら ldp.exe が定番ですが、あれは RSAT を入れないと出てこないうえ、手順を人に説明しづらいのが難点でした。
参考:【ldp.exe】を使用して、LDAP 接続の確認をする。

PowerShell だけで完結させたい。ということで、接続確認用のスクリプトを作って、返ってくる値の意味を一通り整理したメモです。

検証相手は手元に立てた OpenDJ ですが、OpenLDAP でも Active Directory でも考え方は同じです。

PowerShell で LDAP 接続を検証する

4段階で確認する

先に結論だけ。LDAP の接続確認は、この4段階に分けるのが圧倒的に楽です。

  1. ポートに届くか
  2. サーバーが何者か(匿名で聞ける)
  3. ログインできるか(バインド)
  4. 検索できるか

いきなり4を書いて「動かない」と悩むと、原因がポートなのか認証なのか検索条件なのか分からなくなります。わたしはこれで半日溶かしました。

順に見ていきます。

STEP 1:ポートに届くか

# ▼ 変数(ここだけ書き換え)
$LdapHost = "ldap01.example.local"
$Port     = 389

# ▼ 実行(そのまま貼る)
Test-NetConnection $LdapHost -Port $Port
ComputerName     : ldap01.example.local
RemoteAddress    : 192.0.2.10
RemotePort       : 389
TcpTestSucceeded : True

TcpTestSucceeded : True だけ見れば十分です。

False ならここで止まり。LDAP の設定を疑う前に、サーバーのサービスが動いているか、ファイアウォールが 389 を通しているかを見ます。

ちなみに LDAPS(暗号化あり)は 636 番です。両方試すとサーバーの構成が分かります。わたしの環境は 636 が False でした。つまり LDAPS は有効になっていない、と分かります。

ldap:// を付けると失敗する

最初にやりがちなのがこれです。接続先を LDAP の URL 形式で書いてしまうパターン。

Test-NetConnection ldap://ldap01.example.local -Port 389   # ✗ 失敗する
Test-NetConnection ldap01.example.local -Port 389          # ✓
WARNING: Name resolution of ldap://ldap01.example.local failed
TcpTestSucceeded : False

Test-NetConnection の第1引数は -ComputerName で、受け取るのはホスト名か IP アドレスです。ldap:// はスキームであって名前の一部ではないので、まるごと名前解決にかけられて失敗します。

ややこしいのは、同じ「接続先」でも書く場所によって形式が違うことです。

書くところ形式例
Test-NetConnectionホスト名のみldap01.example.local
LdapDirectoryIdentifier(host, port)ホスト名のみldap01.example.local
DirectoryEntry(...)LDAP:// が必要(しかも大文字)LDAP://ldap01.example.local:389/ou=people,dc=example,dc=com
URL の形で持っているとき

設定ファイルから読み込んだ値が URL 形式、ということはよくあります。その場合は [uri] で分解してから渡します。

# ▼ 変数(ここだけ書き換え)
$LdapUrl = "ldap://ldap01.example.local:389/ou=people,dc=example,dc=com"

# ▼ 実行(そのまま貼る)
$u = [uri]$LdapUrl

$port = if ($u.Port -gt 0) { $u.Port } elseif ($u.Scheme -eq 'ldaps') { 636 } else { 389 }
Test-NetConnection $u.Host -Port $port

$u.Host    # ldap01.example.local
$port      # 389
[uri]::UnescapeDataString($u.AbsolutePath.TrimStart('/'))   # ou=people,dc=example,dc=com

ベースDN まで URL に入っていれば AbsolutePath から取り出せます。先頭の / を落とすのと、= や , がパーセントエンコードされている場合に備えたデコードを忘れずに。

ldaps:// のときだけ落とし穴があります。 .NET は ldap の既定ポート 389 は知っていますが、ldaps は知りません。ポートを省略すると $u.Port が -1 になるので、上のようなフォールバックが要ります。

ldap://ldap01.example.local        -> $u.Port = 389
ldaps://ldap01.example.local       -> $u.Port = -1
ldap://ldap01.example.local:1389   -> $u.Port = 1389

STEP 2:サーバーに自己紹介させる(RootDSE)

ここが一番のコツです。

LDAP サーバーには RootDSE という「自己紹介コーナー」があります。ツリーの一番外側にあって、たいてい認証なしで読めます。 ここを読むと、そのサーバーが何を管理しているか教えてくれます。

ADSI(System.DirectoryServices)ではなく、System.DirectoryServices.Protocols を使います。理由は後述しますが、こちらのほうがエラーの中身が分かるからです。

# ▼ 変数(ここだけ書き換え)
$LdapHost     = "ldap01.example.local"
$Port         = 389
$RootDseAttrs = "namingContexts", "defaultNamingContext", "vendorName", "vendorVersion", "supportedLDAPVersion"

# ▼ 実行(そのまま貼る)
Add-Type -AssemblyName System.DirectoryServices.Protocols

$id  = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($LdapHost, $Port)
$con = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
$con.SessionOptions.ProtocolVersion = 3
$con.AuthType = [System.DirectoryServices.Protocols.AuthType]::Anonymous
$con.Bind()

$req = New-Object System.DirectoryServices.Protocols.SearchRequest(
    "",
    "(objectClass=*)",
    [System.DirectoryServices.Protocols.SearchScope]::Base,
    [string[]]$RootDseAttrs)

$res = $con.SendRequest($req)
echo '----------------------------------------'
foreach ($a in $res.Entries[0].Attributes.AttributeNames) {
    "{0}: {1}" -f $a, (@($res.Entries[0].Attributes[$a].GetValues([string])) -join ', ')
    ""
}
echo '----------------------------------------'

返ってきた値です。

----------------------------------------
namingcontexts: dc=example,dc=com

vendorname: Open Identity Platform Community

vendorversion: OpenDJ Server 5.1.2

supportedldapversion: 2, 3

----------------------------------------

namingContexts が本命です。これが「このサーバーが管理しているツリーの根っこ」です。

ベース DN の出発点は、推測しなくてもサーバーが教えてくれます。ネットのサンプルに書いてある dc=example,dc=com をそのまま使って「見つかりません」と悩む前に、まずここを読むのが最短です。

ただし、これがそのまま検索に使うベース DN とは限りません。実際に探したいユーザーは ou=people,dc=example,dc=com のように、この下にいることがほとんどです。また、ログインユーザーの DN でもありません。RootDSE はログインする前に匿名で読む、サーバー自身の情報です。ログインユーザーの DN は、STEP 3 で自分で指定する $BindDn のほうです。

Active Directory だと、namingContexts には DC=… のほかに CN=Configuration,… や CN=Schema,…、DC=DomainDnsZones,… なども並びます。AD なら defaultNamingContext を見るのが確実なので、取得する属性に入れてあります(OpenDJ には無い属性なので、上の結果には出てきません)。

ここが通った時点で「サーバーは生きていて、LDAP として喋れている」が確定します。以降で失敗したら、それは全部こちら側の設定値の問題です。切り分けとして大きい。

STEP 3:ログインできるか(バインド)

LDAP では「ログイン」のことを バインド(bind) と言います。

# ▼ 変数(ここだけ書き換え)
$LdapHost = "ldap01.example.local"
$Port     = 389
$BindDn   = "uid=testuser,ou=people,dc=example,dc=com"
$Password = "<パスワード>"

# ▼ 実行(そのまま貼る)
Add-Type -AssemblyName System.DirectoryServices.Protocols

$id  = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($LdapHost, $Port)
$con = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
$con.SessionOptions.ProtocolVersion = 3
$con.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
$con.Credential = New-Object System.Net.NetworkCredential($BindDn, $Password)

try {
    $con.Bind()
    "BIND OK"
} catch [System.DirectoryServices.Protocols.LdapException] {
    "ErrorCode : $($_.Exception.ErrorCode)"
    "Message   : $($_.Exception.Message)"
    "Server    : $($_.Exception.ServerErrorMessage)"
}
BIND OK
パラメーターの意味

ここが分かりにくいところなので、1つずつ。

パラメーター意味注意
LdapDirectoryIdentifier(host, port)どのサーバーの何番ポートに繋ぐかホスト名は名前解決できるものを。IP でも可
SessionOptions.ProtocolVersion = 3LDAP のバージョン必ず 3。 既定が 2 のことがあり、古いサーバー以外では弾かれます
AuthType::Anonymous匿名。ユーザー名なしRootDSE を読むときはこれ
AuthType::Basicsimple bind。ユーザー名とパスワードをそのまま送るAD 以外の LDAP はほぼこれ
AuthType::NegotiateWindows 同士の認証(Kerberos / NTLM)AD 専用と思っていい
Credential の第1引数バインドする「誰」か完全な DN が必要

Credential に短いユーザー名を渡すと失敗します。 ここが最初の関門です。

Active Directory は testuser のような短い名前を受け付けてくれますが、素の LDAP は受け付けません。 「ツリーのどこにいる誰なのか」をフルパスで書く必要があります。

ダメ : testuser
OK   : uid=testuser,ou=people,dc=example,dc=com

ファイルを開くのに memo.txt ではなく C:\Users\tanaka\memo.txt と書くのと同じ感覚です。LDAP はツリー構造なので、名前だけでは場所が特定できません。

STEP 4:検索できるか

# ▼ 変数(ここだけ書き換え)
$LdapHost   = "ldap01.example.local"
$Port       = 389
$BindDn     = "uid=testuser,ou=people,dc=example,dc=com"
$Password   = "<パスワード>"
$SearchBase = "ou=people,dc=example,dc=com"
$Filter     = "(objectClass=inetOrgPerson)"

# ▼ 実行(そのまま貼る)
Add-Type -AssemblyName System.DirectoryServices.Protocols

# --- 接続+バインド(STEP 3 と同じ。STEP 3 の直後ならここは飛ばしてOK)---
$id  = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($LdapHost, $Port)
$con = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
$con.SessionOptions.ProtocolVersion = 3
$con.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
$con.Credential = New-Object System.Net.NetworkCredential($BindDn, $Password)
$con.Bind()

# --- 検索 ---
$req = New-Object System.DirectoryServices.Protocols.SearchRequest(
    $SearchBase,
    $Filter,
    [System.DirectoryServices.Protocols.SearchScope]::Subtree)

$res = $con.SendRequest($req)
"ヒット件数: $($res.Entries.Count)"

echo '----------------------------------------'
foreach ($e in $res.Entries) {
    "dn: $($e.DistinguishedName)"
    foreach ($a in $e.Attributes.AttributeNames) {
        "    {0}: {1}" -f $a, (@($e.Attributes[$a].GetValues([string])) -join ' / ')
    }
    ""
}
echo '----------------------------------------'

返ってきた値です。

ヒット件数: 1
----------------------------------------
dn: uid=testuser,ou=people,dc=example,dc=com
    uid: testuser
    givenname: testuser
    objectclass: top / inetOrgPerson / organizationalPerson / person
    cn: testuser
    sn: testuser

----------------------------------------
検索パラメーターの意味

パラメーター意味
第1引数(ベースDN)ツリーのどこから探し始めるか。エクスプローラで言う「検索するフォルダ」
第2引数(フィルタ)条件。(属性=値) の形。括弧で括るのが正式
第3引数(スコープ)どこまで潜るか

スコープは3種類あります。

スコープ範囲
Base指定した1件だけ。RootDSE を読むときに使ったのがこれ
OneLevel直下の1階層だけ。サブフォルダは見ない
Subtree配下すべて。普段はこれ
フィルタの objectClass に注意

objectClass は「そのエントリの種類」です。ここがサーバーの種類によって違います。

探したいものActive DirectoryOpenDJ / OpenLDAP
ユーザーuserinetOrgPerson
グループgroupgroupOfNames / groupOfUniqueNames
ログイン ID の属性sAMAccountNameuid

ネットに転がっている PowerShell の LDAP サンプルは、ほぼ全部 Active Directory 向けです。そのまま OpenDJ に持っていくと objectClass=user は1件もヒットしません。

しかもこれはエラーになりません。静かに0件が返るだけです。「繋がっているのに何も出ない」ときは、まずここを疑ってください。

分からなければ、まず (objectClass=*) で全部拾ってしまうのが早いです。出てきたエントリの objectclass 属性を見れば、そのサーバーが何を使っているか分かります。

よく使う属性
属性中身
dnフルパス。一意の識別子
uidログイン ID
cn表示名(common name)
sn姓(surname)
givenName名
mailメールアドレス
objectClassエントリの種類。複数持つのが普通
userPasswordパスワードのハッシュ。取得できてしまうので扱い注意

objectClass が複数返ってくるのは正常です。top / inetOrgPerson / organizationalPerson / person は「継承関係を全部並べている」だけで、実質 inetOrgPerson です。

件数が多いときは一度止める

検索は、うっかりすると数千件を一気に引いてきます。件数の見当がつかないうちは、上限を決めて一度止めるのが安全です。

SearchRequest には SizeLimit があります。ここに上限を入れておくと、サーバーがその件数で打ち切って返してくれます。

function Invoke-LdapSearchLimited {
    param($Connection, [string]$Base, [string]$Filter, [int]$Max)

    $req = New-Object System.DirectoryServices.Protocols.SearchRequest(
        $Base, $Filter, [System.DirectoryServices.Protocols.SearchScope]::Subtree)
    $req.SizeLimit = $Max        # 0 = 無制限

    try {
        $res = $Connection.SendRequest($req)
        [pscustomobject]@{ Entries = $res.Entries; Truncated = $false }
    } catch [System.DirectoryServices.Protocols.DirectoryOperationException] {
        # 上限に達した場合、例外の Response に「そこまでの結果」が入っている
        if ($_.Exception.Response.ResultCode -eq 'SizeLimitExceeded') {
            [pscustomobject]@{ Entries = $_.Exception.Response.Entries; Truncated = $true }
        } else {
            throw
        }
    }
}

ここが分かりにくいところです。上限に達すると SendRequest は例外を投げます(ResultCode 4 / SizeLimitExceeded)。ただしこれは失敗ではありません。例外の Response.Entries に、そこまでの結果がちゃんと入っています。 捨てずに拾ってください。

呼び出し側で、打ち切られたときだけ確認を挟みます。

# ▼ 変数(ここだけ書き換え)
$SearchBase = "ou=people,dc=example,dc=com"
$Filter     = "(objectClass=inetOrgPerson)"
$MaxResults = 20

# ▼ 実行(STEP 4 でバインド済みの $con を使う)
$r = Invoke-LdapSearchLimited -Connection $con -Base $SearchBase -Filter $Filter -Max $MaxResults

if ($r.Truncated) {
    Write-Host "[!] $MaxResults 件で打ち切りました。まだ続きがあります。" -ForegroundColor Yellow
    echo '----------------------------------------'
    foreach ($e in $r.Entries) { "  dn: $($e.DistinguishedName)" }
    echo '----------------------------------------'

    if ((Read-Host "全件を取得して続けますか? (y/N)") -notmatch '^[yY]') { return }

    # y なら上限なしで取り直す
    $r = Invoke-LdapSearchLimited -Connection $con -Base $SearchBase -Filter $Filter -Max 0
}

手元の3エントリしかないディレクトリで、上限を 2 にして試した実行例です。

[!] 2 件で打ち切りました。まだ続きがあります。
----------------------------------------
  dn: dc=example,dc=com
  dn: ou=people,dc=example,dc=com
----------------------------------------

ここで 全件を取得して続けますか? (y/N) と聞かれます。y を返すと、上限なしで取り直します。

[OK] ヒット件数: 3
----------------------------------------
  dn: dc=example,dc=com
  dn: ou=people,dc=example,dc=com
  dn: uid=testuser,ou=people,dc=example,dc=com
----------------------------------------

SizeLimit = 0 は「無制限」です。0 を入れ忘れて上限のまま取り直すと、いつまでも同じ件数で止まります。

なお Read-Host を使うので、対話コンソールでの実行が前提です。非対話で回すと Windows PowerShell is in NonInteractive mode. で落ちます。バッチで流すなら確認を外し、上限で打ち切ったまま扱うか、上限自体を 0 にしてください。

一気に流す版

4段階を1本にまとめると、こんな形になります。

# 上限で打ち切って部分結果を返すヘルパー(前節のもの)
function Invoke-LdapSearchLimited {
    param($Connection, [string]$Base, [string]$Filter, [int]$Max)

    $req = New-Object System.DirectoryServices.Protocols.SearchRequest(
        $Base, $Filter, [System.DirectoryServices.Protocols.SearchScope]::Subtree)
    $req.SizeLimit = $Max        # 0 = 無制限

    try {
        $res = $Connection.SendRequest($req)
        [pscustomobject]@{ Entries = $res.Entries; Truncated = $false }
    } catch [System.DirectoryServices.Protocols.DirectoryOperationException] {
        if ($_.Exception.Response.ResultCode -eq 'SizeLimitExceeded') {
            [pscustomobject]@{ Entries = $_.Exception.Response.Entries; Truncated = $true }
        } else {
            throw
        }
    }
}

function Test-LdapConnection {
    param(
        [string]$LdapHost = "ldap01.example.local",
        [int]   $Port     = 389,
        [string]$BindDn   = "uid=testuser,ou=people,dc=example,dc=com",
        [string]$Password,
        [string]$SearchBase = "ou=people,dc=example,dc=com",
        [string]$Filter     = "(objectClass=inetOrgPerson)",
        [int]   $MaxResults = 20
    )

    Add-Type -AssemblyName System.DirectoryServices.Protocols

    # STEP 1
    if (-not (Test-NetConnection $LdapHost -Port $Port -WarningAction SilentlyContinue).TcpTestSucceeded) {
        Write-Host "[NG] ${LdapHost}:${Port} に到達できません" -ForegroundColor Red
        return
    }
    Write-Host "[OK] ポート疎通" -ForegroundColor Green

    $id = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($LdapHost, $Port)

    # STEP 2
    $anon = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
    $anon.SessionOptions.ProtocolVersion = 3
    $anon.AuthType = [System.DirectoryServices.Protocols.AuthType]::Anonymous
    try {
        $anon.Bind()
        $r = $anon.SendRequest((New-Object System.DirectoryServices.Protocols.SearchRequest(
            "", "(objectClass=*)",
            [System.DirectoryServices.Protocols.SearchScope]::Base, "namingContexts")))
        $nc = @($r.Entries[0].Attributes["namingContexts"].GetValues([string]))
        Write-Host "[OK] RootDSE  namingContexts: $($nc -join ', ')" -ForegroundColor Green
    } catch {
        Write-Host "[--] 匿名バインド不可(禁止設定なら正常)" -ForegroundColor DarkGray
    }

    # STEP 3
    $con = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
    $con.SessionOptions.ProtocolVersion = 3
    $con.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
    $con.Credential = New-Object System.Net.NetworkCredential($BindDn, $Password)
    try {
        $con.Bind()
        Write-Host "[OK] バインド成功: $BindDn" -ForegroundColor Green
    } catch [System.DirectoryServices.Protocols.LdapException] {
        Write-Host "[NG] バインド失敗 code=$($_.Exception.ErrorCode) $($_.Exception.Message)" -ForegroundColor Red
        Write-Host "     server: $($_.Exception.ServerErrorMessage)" -ForegroundColor Red
        return
    }

    # STEP 4
    try {
        $r = Invoke-LdapSearchLimited -Connection $con -Base $SearchBase -Filter $Filter -Max $MaxResults

        if ($r.Truncated) {
            Write-Host "[!] $MaxResults 件で打ち切りました。まだ続きがあります。" -ForegroundColor Yellow
            echo '----------------------------------------'
            foreach ($e in $r.Entries) { "  dn: $($e.DistinguishedName)" }
            echo '----------------------------------------'

            if ((Read-Host "全件を取得して続けますか? (y/N)") -notmatch '^[yY]') {
                Write-Host "[--] ここで中断しました" -ForegroundColor DarkGray
                return
            }
            $r = Invoke-LdapSearchLimited -Connection $con -Base $SearchBase -Filter $Filter -Max 0
        }

        Write-Host "[OK] ヒット件数: $($r.Entries.Count)" -ForegroundColor Green
        echo '----------------------------------------'
        foreach ($e in $r.Entries) { "  dn: $($e.DistinguishedName)" }
        echo '----------------------------------------'

    } catch [System.DirectoryServices.Protocols.DirectoryOperationException] {
        $rc = $_.Exception.Response.ResultCode
        Write-Host "[NG] 検索失敗 code=$([int]$rc) ($rc)" -ForegroundColor Red
        Write-Host "     $($_.Exception.Response.ErrorMessage)" -ForegroundColor Red
    }
}

関数を流したら、あとは変数を入れて呼び出すだけです。

# ▼ 変数(ここだけ書き換え)
$LdapHost   = "ldap01.example.local"
$Port       = 389
$BindDn     = "uid=testuser,ou=people,dc=example,dc=com"
$Password   = "<パスワード>"
$SearchBase = "ou=people,dc=example,dc=com"
$Filter     = "(objectClass=inetOrgPerson)"
$MaxResults = 20

# ▼ 実行(そのまま貼る)※ 上の関数定義を先に流しておくこと
$params = @{
    LdapHost   = $LdapHost
    Port       = $Port
    BindDn     = $BindDn
    Password   = $Password
    SearchBase = $SearchBase
    Filter     = $Filter
    MaxResults = $MaxResults
}
Test-LdapConnection @params

実行するとこうなります。

[OK] ポート疎通
[OK] RootDSE  namingContexts: dc=example,dc=com
[OK] バインド成功: uid=testuser,ou=people,dc=example,dc=com
[OK] ヒット件数: 1
----------------------------------------
  dn: uid=testuser,ou=people,dc=example,dc=com
----------------------------------------

追記:検索できるオブジェクトのタイプと属性の一覧を取る(スキーマ編)

STEP 4 でフィルタの objectClass や取得する属性を決めるとき、「このサーバーにはどんな型があって、それぞれどの属性を持てるのか」を一覧で見たくなります。LDAP サーバーは自分のスキーマ(定義)を公開しているので、ここまでと同じ System.DirectoryServices.Protocols で取り出せます。

仕組み

  • RootDSE の subschemaSubentry に、スキーマの置き場所が入っています。
    • Active Directory:CN=Aggregate,CN=Schema,CN=Configuration,DC=...
    • OpenDJ / OpenLDAP:cn=schema
  • そのエントリの objectClasses に、全オブジェクトタイプの定義が文字列で入っています。必須属性(MUST)、任意属性(MAY)、親クラス(SUP)もここに書かれています。
  • 親クラスの属性は子クラスの定義には書かれていません。なので、SUP を top までたどって合算します。

スクリプト:型と属性の一覧を CSV に出す

接続部分は前の STEP と同じです。結果は CSV 2本に出します。

Add-Type -AssemblyName System.DirectoryServices.Protocols

$LdapHost   = "ldap01.example.local"; $Port = 389
$BindDn     = "uid=testuser,ou=people,dc=example,dc=com"
$Password   = "********"
$OutDir     = "$env:USERPROFILE\ldap-schema"

$id  = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($LdapHost, $Port)
$con = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
$con.SessionOptions.ProtocolVersion = 3
$con.AuthType   = [System.DirectoryServices.Protocols.AuthType]::Basic
$con.Credential = New-Object System.Net.NetworkCredential($BindDn, $Password)
$con.Bind()

function Get-LdapEntries($Base, $Filter, $Scope, [string[]]$Attrs) {
    $req = New-Object System.DirectoryServices.Protocols.SearchRequest($Base, $Filter, $Scope, $Attrs)
    $con.SendRequest($req).Entries
}
function Split-OidList($s) { if ($s) { $s -split '[\s$]+' | Where-Object { $_ } } }
function Get-DefList($def, $keyword) {
    if ($def -cmatch "\b$keyword\s+(?:\(([^)]*)\)|(\S+))") { Split-OidList ($Matches[1] + $Matches[2]) }
}

# 1. スキーマの場所を RootDSE に聞く
$root     = Get-LdapEntries "" "(objectClass=*)" Base @("subschemaSubentry")
$schemaDn = $root[0].Attributes["subschemaSubentry"][0]
"Schema DN: $schemaDn"

# 2. objectClasses の定義を解析する
$schema  = Get-LdapEntries $schemaDn "(objectClass=*)" Base @("objectClasses")
$classes = @{}
foreach ($def in $schema[0].Attributes["objectClasses"].GetValues([string])) {
    if ($def -cnotmatch "\bNAME\s+\(?\s*'([^']+)'") { continue }
    $name = $Matches[1]
    $kind = if ($def -cmatch "\b(STRUCTURAL|AUXILIARY|ABSTRACT)\b") { $Matches[1] } else { "STRUCTURAL" }
    $classes[$name] = [pscustomobject]@{
        Name = $name; Kind = $kind
        Sup  = @(Get-DefList $def "SUP")
        Aux  = @(Get-DefList $def "AUX")
        Must = @(Get-DefList $def "MUST")
        May  = @(Get-DefList $def "MAY")
    }
}

# 3. 親クラス(と補助クラス)をたどって、クラス×属性の一覧にする
function Get-ClassAttrs($cls, $seen = @{}) {
    if (-not $classes.ContainsKey($cls) -or $seen[$cls]) { return }
    $seen[$cls] = $true
    $c = $classes[$cls]
    foreach ($a in $c.Must) { [pscustomobject]@{ Attribute = $a; Required = "MUST"; DefinedIn = $cls } }
    foreach ($a in $c.May)  { [pscustomobject]@{ Attribute = $a; Required = "MAY";  DefinedIn = $cls } }
    foreach ($s in ($c.Sup + $c.Aux)) { Get-ClassAttrs $s $seen }
}

New-Item -ItemType Directory -Force $OutDir | Out-Null

$classes.Values | Sort-Object Name |
    Select-Object Name, Kind, @{n = "Sup"; e = { $_.Sup -join "," }} |
    Export-Csv "$OutDir\classes.csv" -NoTypeInformation -Encoding UTF8

$classes.Keys | Sort-Object | ForEach-Object {
    $cls = $_
    Get-ClassAttrs $cls | Sort-Object Attribute -Unique |
        Select-Object @{n = "Class"; e = { $cls }}, Attribute, Required, DefinedIn
} | Export-Csv "$OutDir\class-attributes.csv" -NoTypeInformation -Encoding UTF8

# 例:inetOrgPerson(AD なら user)だけ画面に出す
Get-ClassAttrs "inetOrgPerson" | Sort-Object Required, Attribute | Format-Table -AutoSize

出てくるファイルは次の2つです。

  • classes.csv:オブジェクトタイプの一覧(名前、種類、親クラス)
  • class-attributes.csv:クラスごとの属性一覧。MUST か MAY か、どのクラスで定義されたかも入ります。

実際に使われている型を数える

スキーマ上の型は、AD だと数百あります。フィルタやマッピングを決めるなら、実データにある組み合わせを見る方が早いです。件数が多くても止まらないように、ページングで取ります。

$SearchBase = "dc=example,dc=com"

$req  = New-Object System.DirectoryServices.Protocols.SearchRequest(
          $SearchBase, "(objectClass=*)",
          [System.DirectoryServices.Protocols.SearchScope]::Subtree, [string[]]@("objectClass"))
$page = New-Object System.DirectoryServices.Protocols.PageResultRequestControl(500)
[void]$req.Controls.Add($page)

$counts = @{}
do {
    $res = $con.SendRequest($req)
    foreach ($e in $res.Entries) {
        $key = ($e.Attributes["objectclass"].GetValues([string]) |
                Where-Object { $_ -ne "top" } | Sort-Object) -join ","
        $counts[$key]++
    }
    $prc = $res.Controls | Where-Object { $_ -is [System.DirectoryServices.Protocols.PageResultResponseControl] }
    $page.Cookie = $prc.Cookie
} while ($prc -and $prc.Cookie.Length -gt 0)

$counts.GetEnumerator() | Sort-Object Value -Descending |
    Select-Object @{n = "ObjectClasses"; e = { $_.Key }}, @{n = "Count"; e = { $_.Value }} |
    Format-Table -AutoSize

inetOrgPerson,organizationalPerson,person が何件、groupOfUniqueNames が何件、という形で出ます。

Active Directory なら allowedAttributes が確実

AD の user には、補助クラス(mailRecipient、securityPrincipal など)由来の属性も付きます。スキーマの定義をたどるだけだと、これが漏れることがあります。

実在するオブジェクト1件の allowedAttributes を読めば、そのオブジェクトに設定できる属性が全部返ってきます。AD 独自の計算属性で、指定したときだけ返ってきます。

$u = Get-LdapEntries "CN=taro,CN=Users,DC=example,DC=local" "(objectClass=*)" Base @("allowedAttributes")
$u[0].Attributes["allowedattributes"].GetValues([string]) | Sort-Object

注意

  • AD はスキーマを読むのにバインドが必要です。匿名で読めるのは RootDSE くらいです。
  • OpenDJ / OpenLDAP の cn=schema は、既定の設定なら匿名でも読めることが多いです。
  • このスキーマ編のスクリプトは、まだ実機で流していません。定義文字列の解析は、AD と OpenDJ の標準的な書式に合わせて書いています。

ldapsearch でも裏を取る

PowerShell 側だけ見ていると「スクリプトが悪いのか、サーバーが悪いのか」が切り分けられません。OpenDJ には ldapsearch が同梱されているので、そちらでも叩きます。

rem ▼ 変数(ここだけ書き換え)
set "OPENDJ_HOME=<OpenDJのインストール先>"
set "LDAP_HOST=localhost"
set "LDAP_PORT=389"
set "BIND_DN=uid=testuser,ou=people,dc=example,dc=com"
set "BIND_PW=<パスワード>"
set "SEARCH_BASE=ou=people,dc=example,dc=com"
set "FILTER=(objectClass=inetOrgPerson)"

rem ▼ 実行(そのまま貼る)
"%OPENDJ_HOME%\bat\ldapsearch.bat"^
 -h %LDAP_HOST% -p %LDAP_PORT%^
 -D "%BIND_DN%"^
 -w "%BIND_PW%"^
 -b "%SEARCH_BASE%"^
 -s sub "%FILTER%" "*"
dn: uid=testuser,ou=people,dc=example,dc=com
cn: testuser
givenName: testuser
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
sn: testuser
uid: testuser

dn: の下に属性がぞろぞろ並べば成功。 末尾の "*" が「全属性ください」の意味です。

同じ結果が両方で出れば、サーバー側は問題なし。差が出たらスクリプト側の問題、と切り分けられます。

エラー集

LDAP の結果コード

System.DirectoryServices.Protocols を使うと、この番号がそのまま取れます。LDAP の共通仕様なので、サーバーの種類を問わず同じ意味です。

コード名前意味よくある原因・注意
0Success成功—
8StrongAuthRequired強力な認証が必要AD の LDAP 署名必須。暗号化なしの Basic バインドを拒否(後述)
32NoSuchObject指定した DN が存在しないベース DN の打ち間違い、namingContexts の外を指している。検索で出る
34InvalidDNSyntaxDN の書き方が不正理屈上はこれだが、多くのサーバーは 49 に丸めるので実際には出にくい(後述)
48InappropriateAuthentication認証方式が不適切匿名なのにパスワードを送った等
49InvalidCredentials認証に失敗したバインド失敗はほぼ全部これ。 一番よく見る
50InsufficientAccessRights権限不足読める DN で繋いだが、その属性は見せてもらえない
53UnwillingToPerformサーバーが拒否空パスワード、アカウント無効・ロック
65ObjectClassViolationスキーマ違反書き込み時。必須属性の不足

バインドの失敗は、まず間違いなく 49 です。 手元の OpenDJ で4パターン試したところ、すべて 49 が返りました— 裸のユーザー名、正しい DN + 誤ったパスワード、存在しない DN、そして uid=x,,ou=people のような構文の壊れた DN まで全部です。これは「どの DN が存在するか」を攻撃者に漏らさないための意図的な実装で、多くの LDAP サーバーが同じ振る舞いをします。

つまり 49 は「認証に失敗した」以上のことを教えてくれません。切り分けはコードではなく手順でやります。RootDSE(STEP 2)でベース DN を確定させ、その配下に自分の DN が実在するかを匿名検索で確かめてから、パスワードを疑う。この順です。

Active Directory なら 49 でも原因がわかる

「49 は詳細を教えてくれない」は、主に OpenLDAP / OpenDJ 系の話です。

Active Directory は、バインドに失敗すると結果コードは同じ 49 を返しますが、サーバーからのメッセージに data xxx という理由コードを入れてくれます。

STEP 3 のコードで Server : に出てくる ServerErrorMessage がそれです。たとえばこんな形で返ってきます。

80090308: LdapErr: DSID-0C09044E, comment: AcceptSecurityContext error, data 52e, v4563

この data 52e の部分が本当の理由です。

data意味対処の方向
525ユーザーが見つからないバインド DN/ユーザー名の打ち間違い
52eパスワードが違ういちばん多い。ユーザーは存在している
530ログオン可能な時間外アカウントのログオン時間制限
531このコンピューターからのログオンは不可ログオン先ワークステーションの制限
532パスワードの有効期限切れパスワードを更新する
533アカウントが無効AD 側でアカウントを有効化
701アカウントの有効期限切れアカウントの期限を延長
773次回ログオン時にパスワード変更が必要一度対話ログオンして変更する
775アカウントがロックされているロック解除(または解除時間まで待つ)

525 と 52e の違いは大事で、「そもそもユーザーがいない」のか「ユーザーはいるがパスワードが違う」のかがここで切り分けられます。

data の部分だけ抜き出したいときは、STEP 3 の catch の中にこれを足します。

if ($_.Exception.ServerErrorMessage -match 'data ([0-9a-f]+)') {
    "AD data   : $($Matches[1])"
}

OpenLDAP / OpenDJ ではこのメッセージが空か、Invalid Credentials 程度しか返ってきません。だからこそ、先に書いた「RootDSE と匿名検索で先に DN を確定させる」作戦が効いてきます。

コード 8:「この操作には強力な認証が必要です」

AD に STEP 3 のコードでバインドしたら、こう返ってきました。

"0" 個の引数を指定して "Bind" を呼び出し中に例外が発生しました: "この操作には強力な認証が必要です。"
    + FullyQualifiedErrorId : DirectoryOperationException

結果コード 8(StrongAuthRequired) です。DC 側で LDAP 署名が必須 になっていて、「389 番・暗号化なし・Basic 認証」のバインドを受け付けてくれない状態です。

もう一つ気づいたのは、赤字のエラーがそのまま出たことです。STEP 3 の catch は LdapException だけを拾っていますが、今回の例外は DirectoryOperationException という別の型なので、catch をすり抜けました。拾いたいときは catch を1つ足します。

} catch [System.DirectoryServices.Protocols.DirectoryOperationException] {
    "ResultCode: $($_.Exception.Response.ResultCode)"   # StrongAuthRequired
    "Message   : $($_.Exception.Message)"
}
クライアント側で合わせる(おすすめ)

DC の設定は変えずに、繋ぎ方のほうを合わせます。どれか1つで通ります。

LDAPS(636番)で繋ぐ:サーバー証明書を PC 側が信頼している必要があります。

$Port = 636
$id  = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($LdapHost, $Port)
$con = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
$con.SessionOptions.ProtocolVersion = 3
$con.SessionOptions.SecureSocketLayer = $true

389番のまま StartTLS:Bind() の直前に1行足します。

$con.SessionOptions.StartTransportLayerSecurity($null)

Negotiate+署名・暗号化:AD ならこれが一番通りやすいです。ユーザーは DN ではなく user@domain か DOMAIN\user の形で渡します。

$con.AuthType = [System.DirectoryServices.Protocols.AuthType]::Negotiate
$con.SessionOptions.Signing = $true
$con.SessionOptions.Sealing = $true
$con.Credential = New-Object System.Net.NetworkCredential("testuser", $Password, "EXAMPLE")
DC 側で署名必須を外す場所

検証環境なら、DC 側で外してしまう手もあります。

グループポリシーの管理 → Default Domain Controllers Policy → コンピューターの構成 → ポリシー → Windows の設定 → セキュリティの設定 → ローカル ポリシー → セキュリティ オプション

  • ドメイン コントローラー: LDAP サーバー署名必要条件 →「なし」
  • ドメイン コントローラー: LDAP サーバー署名要件の適用 →「無効」(Windows Server 2025 の DC で追加された項目。2025 で新しく作ったドメインは、何もしなくても署名が強制されます)

変更したら DC で gpupdate /force を実行します。

今の値はレジストリで確認できます。2 なら署名必須、1 なら必須ではありません。

reg query HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Parameters /v LDAPServerIntegrity

署名を外すと、ドメイン全体でパスワードが平文で流れるバインドを許すことになります。本番の DC ではやらずに、クライアント側で合わせてください。署名なしでバインドしてくるクライアントは、DC の「Directory Service」ログのイベント ID 2889 でわかります。

GPO を「すべての設定が無効」にしても戻らない

勘違いしやすいのがここです。Default Domain Controllers Policy の状態を「すべての設定が無効」にしてありましたが、それで署名必須が外れるわけではありません。

  • セキュリティ設定は、一度適用されるとレジストリに値が残る。GPO を止めても元には戻らない
  • 適用されない GPO の中で「なし」に変えても、DC には届かない
  • 2025 の DC は、GPO がなくても最初から署名を強制する

なので、GPO を「有効」に戻してから 上の2項目を設定し、gpupdate /force する必要があります。Default Domain Controllers Policy には DC 用のほかの大事な設定も入っているので、無効のまま放っておかないほうが安全です。

検索は成功しているのに0件

エラーコードは 0 なのに何も出ない。これは設定ミスではなく条件ミスです。

疑うところ確認方法
objectClass が AD 語彙フィルタを (objectClass=*) にして何が返るか見る
スコープが OneLevelSubtree に変える
ベース DN が1階層ずれている1つ上を指定して OneLevel で覗く
権限で見えていない管理者 DN でバインドし直して比較

ADSI 側のエラー

古い書き方(DirectoryEntry / DirectorySearcher)を使うと、エラーが HRESULT という 16 進数になります。読みにくいですが対応表はこうです。

HRESULT意味
0x8007203Aサーバーに到達できない
0x8007052Eユーザー名かパスワードが違う(=ResultCode 49 相当)
0x80072030指定した DN が存在しない(=ResultCode 32 相当)
0x80072020操作エラー。雑。だいたい設定の食い違い

そして最悪なのがこれです。

"0" 個の引数を指定して "FindAll" を呼び出し中に例外が発生しました: "エラーを特定できません"
    + FullyQualifiedErrorId : COMException

「エラーを特定できません」。 ADSI は中身が COM でできていて、接続が確立する前にコケると翻訳する材料がないため、この汎用メッセージで終わります。

裏を返すと、このメッセージ=サーバーに届く手前で失敗しているというヒントです。認証やフィルタではなく、接続先の組み立てを疑ってください。

デバッグのヒント

ヒント1:例外は最後まで掘る

PowerShell は例外に MethodInvocationException を1枚かぶせます。1段だけ見ても本体に届きません。

try {
    $ds.FindAll()
} catch {
    $e = $_.Exception
    while ($e.InnerException) { $e = $e.InnerException }   # 最後まで辿る
    "Type    : " + $e.GetType().FullName
    "Message : " + $e.Message
    "HRESULT : 0x" + ('{0:X8}' -f $e.HResult)
}

DirectoryOperationException を捕まえたときは、こちらのほうが情報量があります。

$_.Exception.Response.ResultCode     # 32 とか 49 とか
$_.Exception.Response.ErrorMessage   # サーバーが返した説明文

わたしの環境では、ErrorMessage が日本語でそのまま降ってきました。

検索ベースに指定されたエントリ ou=people,dc=nosuch,dc=com が
ディレクトリサーバー内に存在しません

ここまで出れば迷いようがありません。

ヒント2:切り分けは Protocols、実装は好きなほうで

最終的に DirectoryEntry を使うとしても、原因調査だけは System.DirectoryServices.Protocols でやるのが速いです。生の結果コードとサーバーのメッセージが取れる差は大きい。

「0x80072020 で悩む」より「ResultCode 32、ベース DN が無い」のほうが、対処が一瞬で決まります。

ヒント3:SearchRoot に文字列を代入しない

DirectorySearcher を使うときの落とし穴です。

$ds.SearchRoot = $directoryEntry   # 接続先と認証情報をセット
$ds.SearchRoot = $SearchBase       # ← これで全部消える

SearchRoot は DirectoryEntry を入れる場所ですが、PowerShell が親切なので、文字列を入れると勝手に変換します。その結果、1行目で組み立てた接続先・ユーザー名・パスワードが丸ごと捨てられます。

前後を出力して確かめると、こうです。

上書き前: Path='LDAP://ldap01.example.local:389'  Username='uid=testuser,ou=people,dc=example,dc=com'
上書き後: Path=''                                 Username=''   AuthType=(空)

見事に空です。これが「エラーを特定できません」の正体でした。

ベース DN は接続先パスの一部として書きます。

$de = New-Object System.DirectoryServices.DirectoryEntry(
        "LDAP://ldap01.example.local:389/ou=people,dc=example,dc=com",
        "uid=testuser,ou=people,dc=example,dc=com",
        "<パスワード>",
        [System.DirectoryServices.AuthenticationTypes]::None)   # simple bind

最後の None は「認証しない」ではなく、「Windows 独自の認証方式を使わない」という意味です。既定は Secure(NTLM / Kerberos)なので、AD 以外が相手だとこれを明示しないと通りません。名前が紛らわしい。

ヒント4:PowerShell 用のコマンドを、そのままコマンドプロンプトに貼らない

PowerShell から cmd.exe を呼ぶときは、内側のダブルクォートを2つ重ねます。

& cmd.exe /c "ldapsearch.bat -b ""ou=people,dc=example,dc=com"" ..."

これをそのままコマンドプロンプトに貼ると壊れます。cmd.exe に直接打つならクォートは1つずつです。

わたしはこれで ldapsearch が dn: の1行だけを返す状態になりました。属性が1つも出ない。

これ、実は検索自体は成功しています。 LDAP には「存在しない属性名を要求されたら、DN だけ返して属性は空にする」という仕様があり、壊れた "*" がそれに引っかかっていました。

逆に言えば、dn: が返っている時点で ポート疎通・認証・ベース DN・フィルタは全部通っているわけで、壊れているのは最後の1個だけと分かります。

ヒント5:分からなくなったら STEP 2 に戻る

RootDSE は認証なしで読めて、サーバーの正解を教えてくれる、この検証で唯一の「絶対的な基準点」です。

  • ベース DN が合っているか怪しい → namingContexts を見る
  • サーバーの種類が分からない → vendorName / vendorVersion を見る
  • そもそも生きているのか → ここが返れば生きている

推測で値をいじって試行錯誤するより、一度ここに戻るほうが速いです。

まとめ

  • 4段階に分ける。ポート → RootDSE → バインド → 検索
  • ベース DN は推測しない。 RootDSE の namingContexts を読む
  • バインドには完全な DN が必要。 短いユーザー名は AD だけの話
  • AuthType::Basic(simple bind)を明示する。 既定は AD 向け
  • objectClass はサーバーによって違う。 AD は user、OpenDJ は inetOrgPerson。間違えてもエラーにならず0件
  • 原因調査は System.DirectoryServices.Protocols。 生の ResultCode が読める
  • バインド失敗はほぼ 49 に丸められる。 原因を分けてくれないので、RootDSE と匿名検索で先に DN を確定させる
  • 接続先の書式は場所で違う。 Test-NetConnection はホスト名のみ、DirectoryEntry は LDAP:// 付き
  • 件数が読めない検索は SizeLimit で一度止める。 上限超過は例外で来るが、Response.Entries に部分結果が入っている

同じところで詰まっている人の役に立てば。

この記事を大体書いた人

kuroko

kuroko 氏はみんな大好きClaude Code です。
この記事は、Claude とのセッションをまとめて記事形式でアップロードしたものになります。
ちなみに、kuroko(「黒子」のイメージだそうです。) 氏の名前やサムネはClaude自身で設定、作成しています。

(Visited 43 times, 4 visits today)

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です