Help us shape the future of Go! Take the 2026 Go Developer Survey

adldap

package module
v0.5.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 24, 2026 License: MIT Imports: 14 Imported by: 0

README

go-adldap

Manages Active Directory over LDAP, directly from Go — no PowerShell, no RSAT, no Windows host anywhere in the chain. The provider binary and TCP 636 are the whole runtime requirement.

It satisfies the same adcore.Directory contract as go-adpwsh, so a consumer written against one works against the other unchanged.

Status: the connection layer. Dial, TLS, three bind mechanisms, a bounded pool, error classification and DC pinning are done and tested. The per-class CRUD sub-directories are not yet implemented, so Directory() returns a value whose OU, Group and User fields are still nil.

Design

TLS is mandatory. ldaps on 636 or starttls on 389. There is no plain mode: a simple bind over cleartext 389 sends the password in the clear, and a domain with LDAP signing required refuses it anyway.

One DC, pinned for the client's lifetime. Config.Server names it and there is deliberately no discovery. A create that lands on DC-A and a read-back that hits DC-B reports "not found".

Exactly one auth block. Simple, Kerberos or NTLM — never zero, never two. Guessing would authenticate as the wrong identity.

go-ldap lives in exactly one package. internal/conn adapts it behind an interface whose types are this module's own — which is why swapping jcmturner/gokrb5 for oiweiwei/gokrb5.fork to add channel binding touched only this package. A test asserts the imports are there and nowhere else. GSSAPI sign/seal was considered and declined: TLS already protects the connection, so a second encryption layer buys nothing.

All attribute values are [][]byte. objectGUID, objectSid and nTSecurityDescriptor are binary; reading them back as strings corrupts them.

Authenticating

The intended path is a ticket the operator obtained themselves, so no credential goes in Terraform configuration:

KRB5CCNAME=FILE:/tmp/krb5cc_tf kinit svc_tf@CORP.LOCAL

The FILE: prefix matters. gokrb5 reads a credential cache with os.ReadFile, so only file caches work — and KEYRING (RHEL, Fedora with sssd) and KCM (Ubuntu with sssd-kcm) are the platform defaults. Naming one of those produces an error that says exactly this rather than "no ticket found".

This is the Linux and macOS path. Windows keeps credentials in the LSA with no readable cache, so a Windows operator uses simple or ntlm until an SSPI client lands behind the conn seam.

Where kinit was never installed — CI, a scratch container — a credential can be supplied instead:

Kerberos: &adldap.KerberosAuth{
    Username: "svc_tf",
    Password: adcore.NewSecret(os.Getenv("AD_PASSWORD")),
}

The realm defaults to the server's domain suffix uppercased, and with no /etc/krb5.conf present a minimal one is synthesized naming Config.Server as the KDC. At most one of CCachePath, Keytab and Password may be set.

Channel binding

Every Kerberos bind carries a tls-server-end-point channel-binding token, so a domain with LdapEnforceChannelBinding = 2 — required by the CIS Benchmark and the DISA STIG — accepts it. The token is computed from the certificate the connection actually negotiated, and is sent unconditionally, which is what a Windows client does.

NTLM is the remaining gap. Upstream go-ldap sends no token, so ntlm is still refused where the policy is 2, with data 80090346 and no mention of channel binding. Use kerberos or simple there. This is a library choice rather than a protocol limit — the AV_PAIR exists — and is recorded as future work.

Errors

An LDAP failure becomes the same adcore.Kind the PowerShell backend would produce for the same condition. That works because AD prefixes its diagnostic messages with the very Win32 codes the other backend classifies on — 0000208D: NameErr: is ERROR_DS_OBJ_NOT_FOUND — so both call adcore.ClassifyCode and read one table.

Only KindTransient is ever retried. A cancellation is KindTransport: the request may already have reached the DC, so re-issuing it could duplicate a side effect.

Testing

internal/adtest runs an in-process LDAP server on a real socket with a certificate the client verifies, so a wrong attribute encoding or a mis-sent control fails in CI rather than first on a lab domain.

make check

That is build, go vet, a gofmt check and go test ./... -race — the same target CI runs, so a red build reproduces with one command rather than by reading the workflow. -race is not decoration: the pool hands connections to callers concurrently and each one runs a Binder, so a race here is a race on someone's credentials.

make audit

go mod tidy -diff and govulncheck. A scheduled run does this weekly as well as on every push, because a vulnerability appears in a dependency without anyone touching this repository — and the Kerberos library is a fork of an upstream that stopped accepting commits in 2022, so nothing else would say so.

Licence

MIT. See LICENSE.

The cross-backend differential suite

acc_differential_test.go is behind the acc build tag and AD_ACC_DIFFERENTIAL=1. It creates one object of each class through this backend, reads it back through both this one and go-adpwsh, and requires the decoded models to be identical. The conformance suite proves each backend satisfies the contract; only this proves they agree.

That is why go-adpwsh appears in go.mod. It is a test dependency of this one file and nothing in the runtime path imports it — the invariant that go-ldap lives only in internal/conn is unaffected, and so is the rule that no Terraform package enters either library.

Its first run found three real divergences, all now fixed: the $ on a computer's and a gMSA's SamAccountName, GenericRead rendered as its four constituent bits, and an absent multi-valued attribute decoding as an empty slice on one side and nil on the other.

Documentation

Overview

Package adldap manages Active Directory over LDAP, directly from Go, with no PowerShell and no Windows host anywhere in the chain.

It satisfies the same adcore.Directory contract as go-adpwsh, so a consumer written against one works against the other unchanged. TLS is mandatory: LDAPS on 636 or StartTLS on 389. Plain LDAP is not offered, because a simple bind over it sends the password in clear text and a domain with LDAP signing required refuses it anyway.

Index

Constants

View Source
const (
	ControlPaging      = "1.2.840.113556.1.4.319"
	ControlShowDeleted = "1.2.840.113556.1.4.417"
	ControlSDFlags     = "1.2.840.113556.1.4.801"
	ControlExtendedDN  = "1.2.840.113556.1.4.529"
)

Microsoft LDAP control OIDs.

View Source
const MatchingRuleInChain = "1.2.840.113556.1.4.1941"

MatchingRuleInChain is LDAP_MATCHING_RULE_IN_CHAIN, which walks nested group membership server-side.

Variables

This section is empty.

Functions

func DefaultPort

func DefaultPort(m TLSMode) int

DefaultPort is the port a mode uses when Config.Port is zero.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the entry point. Hand it to a consumer through Directory.

func New

func New(ctx context.Context, cfg Config) (*Client, error)

New validates the configuration, opens and binds one connection, reads the rootDSE, and pins the domain controller this client targets for its lifetime. It performs one round trip.

The DC is pinned rather than rediscovered because a create that lands on DC-A and a read-back that hits DC-B reports "not found". Config.Server names it; there is deliberately no discovery.

func NewWithConn

func NewWithConn(ctx context.Context, cfg Config, dial func(context.Context) (conn.Conn, error)) (*Client, error)

NewWithConn builds a Client over a caller-supplied connection factory rather than dialling one.

It exists because the in-process LDAP server this module tests against cannot serve ModifyDN — the library behind it parses no such request — and ModifyDN is how a rename and a move happen. Without a seam here, the rename-and-move contract could be tested only against a real domain. The factory's type names an internal package, so nothing outside this module can supply one.

func (*Client) Close

func (c *Client) Close() error

Close releases the connection pool.

func (*Client) DefaultNamingContext

func (c *Client) DefaultNamingContext() string

DefaultNamingContext returns the domain's naming context.

func (*Client) Directory

func (c *Client) Directory() adcore.Directory

Directory presents this client through the backend-neutral contract.

The classes this backend does not implement are filled with stubs that return KindUnsupported, naming the class. They were nil interface fields until the lab showed what that actually costs: a consumer dereferences the nil and panics, which aborted an entire acceptance run and named neither the class nor the reason.

func (*Client) Server

func (c *Client) Server() string

Server returns the pinned domain controller.

type Config

type Config struct {
	// Server is the domain controller this client pins for its lifetime.
	Server string
	Port   int

	TLS TLSMode
	// CACertificateFile verifies the DC's certificate. Empty uses the system
	// pool.
	CACertificateFile string
	// InsecureSkipVerify disables certificate verification. It is never a
	// default and exists only for labs with self-signed DC certificates.
	InsecureSkipVerify bool
	// TLSConfig, when set, wins over the three fields above. Test hook.
	TLSConfig *tls.Config

	// Exactly one of these.
	Simple   *SimpleAuth
	Kerberos *KerberosAuth
	NTLM     *NTLMAuth

	// MaxConcurrency bounds pooled connections. Zero means 4.
	MaxConcurrency int
	// Timeout is the per-operation deadline. Zero means 60s.
	Timeout time.Duration

	Retry       adcore.RetryConfig
	Replication ReplicationConfig

	Log Logger
}

Config configures a Client. Server, TLS and exactly one auth block are required.

func (Config) Validate

func (c Config) Validate() error

Validate checks the configuration before any dial.

type KerberosAuth

type KerberosAuth struct {
	// CCachePath names a credential cache file explicitly.
	CCachePath string
	// Keytab and Username authenticate unattended, for CI with no kinit.
	Keytab   string
	Username string
	// Password authenticates from a supplied credential, for a runner where
	// kinit was never installed at all. It requires Username. With no
	// Krb5ConfPath and no /etc/krb5.conf, a minimal configuration is
	// synthesized from Realm and Server — otherwise this path would work only
	// where it was least needed.
	Password adcore.Secret
	// Realm defaults to Server's domain suffix, uppercased.
	Realm string
	// Krb5ConfPath overrides /etc/krb5.conf.
	Krb5ConfPath string
	// SPN overrides the service principal, which defaults to ldap/<server>.
	SPN string
}

KerberosAuth binds with a Kerberos ticket. The zero value uses the ambient credential cache, which is the preferred path: an operator runs kinit in their own shell and no password reaches Terraform configuration.

At most one of CCachePath, Keytab and Password may be set. With none set the ambient KRB5CCNAME applies.

type Logger

type Logger interface {
	Debug(ctx context.Context, msg string, kv ...any)
}

Logger is the output port. The package masks credential-bearing values before anything reaches it: redaction cannot be the caller's job, because the caller never sees the payload.

type NTLMAuth

type NTLMAuth struct {
	Domain   string
	Username string
	Password adcore.Secret
}

NTLMAuth binds with NTLM, for callers that cannot obtain a TGT.

type ReplicationConfig

type ReplicationConfig struct {
	Wait         bool
	Targets      []string // DC host names, or the single element "all"
	ForceSync    bool
	Timeout      time.Duration
	PollInterval time.Duration
}

ReplicationConfig governs the wait that follows a write.

type SimpleAuth

type SimpleAuth struct {
	Username string // a UPN, a DN, or DOMAIN\user
	Password adcore.Secret
}

SimpleAuth is a username and password bind. It requires TLS, which this package always has.

type TLSMode

type TLSMode string

TLSMode selects how the connection is protected. There is no "none".

const (
	TLSLDAPS    TLSMode = "ldaps"
	TLSStartTLS TLSMode = "starttls"
)

Directories

Path Synopsis
internal
adtest
Package adtest runs an in-process LDAP server backed by an in-memory directory, so this module's wire behaviour — attribute encoding, controls, filters, paging, binds — is exercised in CI rather than first on a lab domain.
Package adtest runs an in-process LDAP server backed by an in-memory directory, so this module's wire behaviour — attribute encoding, controls, filters, paging, binds — is exercised in CI rather than first on a lab domain.
attrs
Package attrs encodes and decodes the Active Directory attribute values the cmdlets used to hide.
Package attrs encodes and decodes the Active Directory attribute values the cmdlets used to hide.
conn
Package conn is the only place this module knows what LDAP library it uses.
Package conn is the only place this module knows what LDAP library it uses.
ldaperr
Package ldaperr turns an LDAP failure into the same adcore.Kind the PowerShell backend would produce for the same condition.
Package ldaperr turns an LDAP failure into the same adcore.Kind the PowerShell backend would produce for the same condition.
secdesc
Package secdesc edits the one part of an Active Directory security descriptor this backend needs: the Deny ACE that implements ProtectedFromAccidentalDeletion.
Package secdesc edits the one part of an Active Directory security descriptor this backend needs: the Deny ACE that implements ProtectedFromAccidentalDeletion.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL