go-ldap へのコントリビューション
2023-08-18 - 森本 哲也
これまでもバックエンドに Go 言語を用いて製品開発していることをブログに書いてきました。先日その製品のプレスリリースを行いました。
Unicorn Cloud ID Manager (以下UCIDM) という製品名になります。またこの機会に UCIDM タグ も付けました。製品開発の過程で調査した技術情報や開発の話題などをフィルターできます。
本稿はその製品開発の裏話の1つとして、github.com/go-ldap/ldap (以下go-ldap) というライブラリへコントリビューションしたことを紹介します。go-ldap は Go で LDAP プロトコルの通信を行うクライアントライブラリです。
go-ldap という名前は organization 名でライブラリ名は ldap になります。Awesome Go などをみると、go で実装されたライブラリ名に go-
という接頭辞を付けることはよくあります。そのため、ここでは ldap ライブラリのことを go-ldap という organization 名で呼称するようにします。LDAP プロトコルを指しているのか ldap ライブラリを指しているのか、混同しないようにという意図になります。
UCIDM のシステム概要
唐突ですが、オンプレミス版の UCIDM システム構成の概要図が次になります。
この真ん中の方に Agent と呼ばれるモジュールがあります。
Agent は内部向けディレクトリサービスにある OpenLDAP や Active Directory (以下AD) といった LDAP サーバーからエントリーを取得して UCIDM の API サーバーに連携する役割を担っています。
大前提として Agent は LDAP プロトコルで OpenLDAP や AD サーバーと通信しなければなりません。LDAP プロトコルの機能を使って変更を検知して ID 連携できる必要があります。
UCIDM の開発を始めた当初は Java のライブラリを使って Agent を実装していました。このモジュールのみが Java で実装されていました。他のバックエンドのモジュールはすべて Go で開発されています。この体制は将来的に開発全体を遅くする要因になり得ると直感的に私は思いました。他プロジェクトで実運用されて動いている Java 実装の Agent モジュールがあるのに、Go で再実装する必要は本当にあるのか?そういった懸念はありました。この記事を書いているのは移行しましたってオチではあるのですが、本当の意味でその判断が適切だったかどうかは今後のプロダクトの開発の歴史が証明していくことになります。
Go 実装を推奨するもっともわかりやすいプロパガンダとして次をあげておきます。コンテナイメージのサイズはともかく、稼働中のメモリ使用量が減ることはリソース削減になって顧客の利益に寄与します。
- コンテナイメージのサイズ比較 (docker images で取得)
- Java 実装: 613 MiB
- Go 実装: 28.7 MiB
- 稼働中のメモリ使用量の比較 (docker stats で取得)
- Java 実装: 518 MiB
- Go 実装: 10.32 MiB
LDAP プロトコルの通信
現在広く使われている LDAP プロトコルのバージョンは3であり、そのプロトコル仕様は RFC 4511 で提案されています。LDAP の技術仕様は複数の RFC で提案されており、RFC 4510 がそれぞれの技術仕様へのインデックスにみえます。
LDAP はクライアントサーバモデルのプロトコルです。
まずエラー制御から説明します。サーバーからの応答は LDAPResult として定義されています。
LDAPResult ::= SEQUENCE {
resultCode ENUMERATED {
success (0),
operationsError (1),
protocolError (2),
...,
... },
matchedDN LDAPDN,
diagnosticMessage LDAPString,
referral [3] Referral OPTIONAL }
LDAPResult に含まれる resultCode を参照することで成否であったり、エラーが発生しているときはその種別を確認できます。go-ldap では Error 構造体に ResultCode というメンバーをもちます。resultCode の種別は80ほどあるようです。
// Error holds LDAP error information
type Error struct {
// Err is the underlying error
Err error
// ResultCode is the LDAP error code
ResultCode uint16
// MatchedDN is the matchedDN returned if any
MatchedDN string
// Packet is the returned packet if any
Packet *ber.Packet
}
LDAP プロトコルでは LDAP メッセージという単位で LDAP のオペレーションを扱います。LDAP メッセージは1つのリクエストとみなせます。RFC では次のように messageID, protocolOp, controls (オプション) で構成されます。
LDAPMessage ::= SEQUENCE {
messageID MessageID,
protocolOp CHOICE {
...,
searchRequest SearchRequest,
...,
intermediateResponse IntermediateResponse },
controls [0] Controls OPTIONAL }
go-ldap の内部で messagePacket という構造体があります。名前が似ているので LDAP メッセージを表現しているように類推してしまいますが、実際にはそうではありません。この構造体の Packet というメンバーが LDAP メッセージに相当します。その他のメンバーは go-ldap 内の制御に使われます。
type messagePacket struct {
Op int
MessageID int64
Packet *ber.Packet
Context *messageContext
}
もう少し話しを進めて go-ldap は次のように SearchRequest の構造体が定義されています。
// SearchRequest represents a search request to send to the server
type SearchRequest struct {
BaseDN string
Scope int
DerefAliases int
SizeLimit int
TimeLimit int
TypesOnly bool
Filter string
Attributes []string
Controls []Control
}
RFC の定義も一緒にみておきましょう。
SearchRequest ::= [APPLICATION 3] SEQUENCE {
baseObject LDAPDN,
scope ENUMERATED {
baseObject (0),
singleLevel (1),
wholeSubtree (2),
... },
derefAliases ENUMERATED {
neverDerefAliases (0),
derefInSearching (1),
derefFindingBaseObj (2),
derefAlways (3) },
sizeLimit INTEGER (0 .. maxInt),
timeLimit INTEGER (0 .. maxInt),
typesOnly BOOLEAN,
filter Filter,
attributes AttributeSelection }
この構造体の値を LDAP メッセージの protocolOp としてエンコードすることで検索リクエストが LDAP サーバーへ送信されます。検索リクエストは LDAP アプリケーションコードの1つとして定義されていて、エンコードする際にその数値をセットしています。RFC に APPLICATION 3
と書いてあるのがそうです。この数値を参照することで LDAP サーバーは検索リクエストであることを判別します。go-ldap では次のように扱います。
ApplicationSearchRequest = 3
pkt := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ApplicationSearchRequest, nil, "Search Request")
次に検索リクエストのメンバーに Controls というメンバーが含まれています。勘のよい方は気付かれたかもしれませんが、この Controls という値は先ほどの LDAP メッセージに添付できるコントロールを表しています。つまりコントロールというのは検索リクエストのパラメーターではなく、LDAP メッセージのオプションのパラメーターになります。後述しますが、このコントロールという属性が LDAP プロトコルのオペレーションを拡張する上で大きな役割を担っています。
go-ldap では SearchRequest に検索リクエストとコントロールを同時に設定できるようになっています。これは LDAP プロトコルの仕様に準拠した厳密なデータ構造ではありません。LDAP プロトコルの通信では、検索リクエストと LDAP メッセージに添付されるコントロールとして、別の属性の値として扱われます。しかし、ライブラリ利用者の視点から、検索リクエストの低レイヤーのデータ構造を意識する必要はなく、一緒に渡せた方が高レベルな API として使いやすいという配慮によるものでしょう。
ここからは勢いでいきましょう。検索リクエストに対して LDAP サーバーは検索レスポンスを返します。go-ldap では SearchResult として次のように定義されています。
// SearchResult holds the server's response to a search request
type SearchResult struct {
// Entries are the returned entries
Entries []*Entry
// Referrals are the returned referrals
Referrals []string
// Controls are the returned controls
Controls []Control
}
Entry は LDAP サーバーが管理している ID 情報そのものです。検索リクエストの条件に合致したエントリーが返されます。Referral はリクエストを受け付けたサーバーでは操作ができないものの、他の LDAP サーバーではできるかもしれないという情報を返します。Referral を扱う制御を私が実装したことがないため、実際に Referral を用いてどういった制御を行うのか、あまりよく分かっていません。
そして Control は LDAP のオペレーションの仕組みを提供します。また LDAP のオペレーションを拡張できるところでもあります。クライアントが送信するものを リクエストコントロール、サーバーから返信されるものを レスポンスコントロール と呼びます。それぞれ個別に専用コントロールを定義する場合もあれば、兼用コントロールで定義される場合もあるようです。syncrepl は前者であり、DirSync は後者になります。
データ構造としては、単一の LDAP メッセージに複数のコントロールを添付できる仕様になっていますが、これは特定の用途に限定されたものであり、ユーザーが複数のコントロールを任意に組み合わせてまとめて結果を取得するといった用途に使うものではありません。そういうリクエストを送ると、LDAP サーバーはメッセージが適切ではないとみなし、Protocolerror を返すと仕様には書いてあります。
これで検索リクエストを LDAP サーバーへ送り、検索レスポンスを受け取るところのデータ構造を確認できました。検索リクエスト以外においても、LDAP メッセージにリクエスト/レスポンスデータをエンコードしてコントロールを添付するところは同じです。それぞれのリクエスト/レスポンス種別に応じたアプリケーションコードを指定して、リクエストデータをエンコード、またはレスポンスデータをデコードすることで LDAP サーバーと通信できます。
ここまでの内容が RFC 4511 で提案されている基本的な仕様になります。
LDAP プロトコルの検索と変更検知
ID 連携のために機能として、日々の運用で変更された ID 情報を検知して連携する必要があります。LDAP プロトコルには更新差分を提供する仕組みはありません。そこで ID 情報の更新差分を取得する方法の1つとして cookie を使って検索する方法があります。Web 開発者だと cookie と聞くと HTTP cookie をイメージすると思います。ユーザーの状態を扱うデータという用途では HTTP cookie と LDAP プロトコルで扱う cookie はそれほど異なる概念ではありませんが、まったく別のものです。LDAP プロトコルで用いる cookie というのは、LDAP 検索において、指定した日時以降に更新されたエントリーを検索対象とする情報を扱います。検索パラメーターの1つと考えた方が Web 開発者には理解しやすいかもしれません。
そして cookie を使う方法は RFC 4511 では提案されておらず、LDAP プロトコルを拡張する機能に属する仕様になります。結論から書いておくと、前述したリクエストやレスポンスで拡張するところであると紹介したコントロールで cookie を扱います。その拡張方法も OpenLDAP サーバーと AD サーバーではそれぞれ仕様が異なります。
- OpenLDAP サーバー
- LDAP Sync Replication (syncrepl)
- syncrepl は RFC 4533 で Experimental として提案されている
- AD サーバー
- LDAP_SERVER_DIRSYNC_OID control code
- 拡張 LDAP control を用いて拡張検索機能を提供する DirSync と呼ばれる仕組みが提供されている
UCIDM では OpenLDAP サーバーと AD サーバー両方をサポート対象としています。したがって Agent モジュールはこの2つの機能に対応する必要があります。
前置きが長くなりました。本稿は syncrepl と DirSync の2つの機能を go-ldap にコントリビューションしたことについて書きます。時系列では DirSync, syncrepl の順番に機能をコントリビュートしたのでその順番で説明します。
DirSync
当社の 下窪 が DirSync コントロールの機能をコントリビュートしました。次が PR になります。過去に別 PR で提案されていたものの、途中でクローズされてしまっていたパッチをベースに、実際に AD サーバーでの振る舞いを検証しながら、いくつか運用要件を考慮して修正しました。
LDAP の検索リクエストに LDAP_SERVER_DIRSYNC_OID control code の仕様に沿ったリクエストコントロールをセットすることで cookie で指定した日時以降に更新されたエントリーのみを検索対象にできます。また応答にも同じデータ構造でレスポンスコントロールがセットされて返ってきます。そのレスポンスコントロールに含まれる cookie を保持しておき、次回の検索に利用することで前回の検索からの更新されたエントリーのみを取得できます。
type ControlDirSync struct {
Flags int64
MaxAttrCnt int64
Cookie []byte
}
通常の LDAP の検索リクエストにコントロールを追加するだけで済むため、リクエストコントロールのエンコード処理とレスポンスコントロールのデコード処理を実装するのみです。このようにして、通常の LDAP 検索にはない機能を拡張可能となります。
syncrepl
DirSync の成功体験からコントロールさえ制御すればすぐできそうかなと錯覚して意外と大変だったのが syncrepl です。2つの PR を経てその機能をコントリビュートしました。
当初 syncrepl の issue を提起したところ、メンテナーの1人から先に非同期検索の機能をマージする 必要があると教えてもらいました。いま振り返ってみると、必ずしも syncrepl の実装に非同期検索を要するわけではありません。意図としては非同期処理を go-ldap 側で制御してもらえる方がアプリケーションの実装コストが下がるので望ましいということです。このコメントの指摘は厳密には正しくないけれど、実用上はその方が嬉しいので結果的にはうまくいきました。
そこで LDAP 検索を非同期で行うための機能を調査してコントリビュートしました。この機能も過去に別の draft PR で提案されていたものの、途中で作業が止まっていたパッチをベースに、私の勘所を加えて送ったものです。
当初は Go のチャンネルをそのまま返すようなインターフェースで提案したものの、将来的に実装を変更することも考慮して実装の詳細は隠蔽した方がよいだろうと議論が進み、最終的に次のインターフェースを提供することに決定しました。
SearchAsync(ctx context.Context, searchRequest *SearchRequest, bufferSize int) Response
type Response interface {
Entry() *Entry
Referral() string
Controls() []Control
Err() error
Next() bool
}
アプリケーション側では次のように呼び出します。Next() メソッドがブロックすることで次のエントリー取得を待ちます。大量にエントリーを取得するときは非同期/並行に取得できるので効率がよいです。
r := conn.SearchAsync(ctx, searchRequest, 64)
for r.Next() {
entry := r.Entry()
fmt.Printf("%s has DN %s\n", entry.GetAttributeValue("cn"), entry.DN)
}
if err := r.Err(); err != nil {
log.Fatal(err)
}
この PR のレビューに1ヶ月かかりました。既存の API にはない汎用の新規 API となるので、メンテナーは慎重に設計を議論していました。私も Go のチャンネルを返さずに隠蔽するという API 設計について考える機会となり、とても学びになりました。
非同期検索の機能ができたら本命の syncrepl の機能を追加します。実際の PR が次になります。syncrepl の機能を使った検索は確立したコネクションをずっと維持した状態になるので永続検索 (persistent search) と呼ばれることもあります。Web 開発者向けに pubsub の consumer に近いイメージです。
これまでの流れから LDAP の検索リクエストにコントロールを追加してエンコード/デコード処理を実装したら終わりでしょうと推測されます。それはその通りなのですが、syncrepl は RFC 4533 (Experimental) として提案されているだけあって、もう少し複雑な制御になります。
Client Server
| |
|------ Sync Request Control ------------------------> |
| |
| <-------- Sync Info Message (optional) ------------- |
| |
| (One or more times for each entry) |
| <----- Sync State Control (with Entry) ------------- |
| |
| <-------- Sync Info Message (optional) ------------- |
| |
| <----------- Sync Done Control --------------------- |
| |
なんと、コントロールが4つもあるんですね。この4つのコントロールをすべて実装しないといけないというのが syncrepl の機能追加の主なところです。これは検索のための仕組みではなく、レプリケーションの機能の一部だからです。
リクエストコントロールとして Sync Request があります。
type ControlSyncRequest struct {
Criticality bool
Mode ControlSyncRequestMode
Cookie []byte
ReloadHint bool
}
レスポンスコントロールは3種類あります。
- Sync State
- Sync Done
- Sync Info
type ControlSyncState struct {
Criticality bool
State ControlSyncStateState
EntryUUID uuid.UUID
Cookie []byte
}
type ControlSyncDone struct {
Criticality bool
Cookie []byte
RefreshDeletes bool
}
type ControlSyncInfo struct {
Criticality bool
Value ControlSyncInfoValue
NewCookie *ControlSyncInfoNewCookie
RefreshDelete *ControlSyncInfoRefreshDelete
RefreshPresent *ControlSyncInfoRefreshPresent
SyncIdSet *ControlSyncInfoSyncIdSet
}
リクエストとレスポンスのコントロールが分かれていることは用途が明確であり、コントロールの実装においてスコープが限定されるので私はこちらの仕様の方が好みです。
レスポンスのコントロールが3種類あるということは、サーバーからの応答パターンが複数あって、それぞれに検索リクエストやサーバーの状態を考慮した検証が必要になります。つまりコントロールが増えることは実装コスト以上に検証コストが跳ね上がるので開発に時間がかかります。一方で既存の検索リクエストにコントロールのエンコード/デコード処理を実装するだけなので LDAP 通信を再現できれば各個撃破でコントロールの処理を実装していくだけで難易度は高くありません。
また syncrepl のコントロールにも cookie の値を保持していることが伺えます。DirSync とはまったく異なる仕様ですが、コントロールにある cookie を使って LDAP 検索を拡張するという概念はまったく同じです。
go-ldap に拡張機能を追加するときのデバッグ
go-ldap に LDAP の拡張機能を実装してコントリビュートしたいときに便利なデバッグ方法を紹介します。
LDAP 通信は ASN.1 Basic Encoding Rules (BER) のサブセットを使って転送されると RFC にあります。パケットのエンコード/デコードの基本的な仕様のようにみえます。go-ldap では github.com/go-asn1-ber/asn1-ber というライブラリを使ってパケットのエンコード/デコードを扱います。人間はパケットをそのまま読むことはできないため、デバッグのためにパケットをデコードする必要があります。このライブラリの機能を使ってパケットを dump すると、人間が読みやすいフォーマットで出力してくれます。
ber.PrintPacket(packet)
例えば、syncrepl で検索リクエストを行い ControlSyncState が返ってくる LDAP の応答は次のように dump されます。
LDAP Response: (Universal, Constructed, Sequence and Sequence of) Len=182 "<nil>"
Message ID: (Universal, Primitive, Integer) Len=1 "2"
Search Result Entry: (Application, Constructed, 0x04) Len=68 "<nil>"
Object Name: (Universal, Primitive, Octet String) Len=35 "uid=user,ou=users,dc=srcldap,dc=com"
Attributes: (Universal, Constructed, Sequence and Sequence of) Len=29 "<nil>"
Attribute: (Universal, Constructed, Sequence and Sequence of) Len=13 "<nil>"
Attribute Name: (Universal, Primitive, Octet String) Len=3 "uid"
Attribute Values: (Universal, Constructed, Set and Set OF) Len=6 "<nil>"
Attribute Value: (Universal, Primitive, Octet String) Len=4 "user"
Attribute: (Universal, Constructed, Sequence and Sequence of) Len=12 "<nil>"
Attribute Name: (Universal, Primitive, Octet String) Len=2 "cn"
Attribute Values: (Universal, Constructed, Set and Set OF) Len=6 "<nil>"
Attribute Value: (Universal, Primitive, Octet String) Len=4 "user"
Controls: (Context, Constructed, 0x00) Len=107 "<nil>"
Control: (Universal, Constructed, Sequence and Sequence of) Len=105 "<nil>"
Control Type (Sync State): (Universal, Primitive, Octet String) Len=24 "1.3.6.1.4.1.4203.1.9.1.2"
Control Value: (Universal, Primitive, Octet String) Len=77 "0K\n\x01\x02\x04\x103\xa8\x81C\x134Ok\x85<'#\x9a\xf3\fT\x044rid=000,csn=20230817032429.247533Z#000000#000#000000"
RFC 4511 で定義されていた LDAP メッセージのデータ構造を思い出してください。messageID, protocolOp (この例では Search Result Entry), controls の3つを確認できますね。ここで controls の1番目の要素のコントロールの Value は Octet String が返ってきているのでさらにこのバイト列をデコードします。
value, _ := ber.DecodePacketErr(packet.Data.Bytes())
ber.PrintPacket(value)
デコードしたコントロールの中身を dump すると次のようになります。
(Universal, Constructed, Sequence and Sequence of) Len=75 "<nil>"
(Universal, Primitive, Enumerated) Len=1 "2"
(Universal, Primitive, Octet String) Len=16 "3\xa8\x81C\x134Ok\x85<'#\x9a\xf3\fT"
(Universal, Primitive, Octet String) Len=52 "rid=000,csn=20230817032429.247533Z#000000#000#000000"
RFC 4533 で定義されている syncStateValue と同じであることを確認できます。このコントロールはエントリーが modify されたことを表しています。cookie にも日時が含まれているのが伺えます。
syncStateValue ::= SEQUENCE {
state ENUMERATED {
present (0),
add (1),
modify (2),
delete (3)
},
entryUUID syncUUID,
cookie syncCookie OPTIONAL
}
このようにパケットを dump しながら RFC のデータ構造を確認しつつ、go-ldap に実装していくことで LDAP 通信の機能拡張ができます。
まとめ
みんなでソフトウェアを保守していくというのが OSS というライセンスモデルです。
今回は UCIDM の製品開発の過程において、自分たちのプロダクトに必要な機能を go-ldap へコントリビュートしました。
- DirSync コントロールを使った LDAP 検索
- syncrepl を使った LDAP の永続検索
コントリビュートするにあたり、LDAP プロトコルに関する RFC を読んだり、go-ldap の実装を読んでプロトコル実装の理解が進みました。これらは顧客へ直接的に訴求する製品の機能ではありませんが、こういった知見そのものが今後の UCIDM の開発にも役に立つと私は考えています。また OSS でビジネスをしている当社の特徴の1つとして、業務の一環として OSS コントリビューションも奨励されています。