Bisecting through attribute selection and paging kept reproducing the
same failure in different shapes ("Encoding too long" BER parser error,
then ECONNRESET) — all while a plain `ldapsearch` against the exact same
query, run from inside this same container, completed successfully every
single time. That's strong enough evidence of a bug somewhere in
ldapjs/@ldapjs-asn1's BER decoding against this AD's actual response
bytes, not in our query. Rather than keep chasing a third-party parser
bug, searchLdapDirectory() now shells out to the system `ldapsearch`
(added to the image via ldap-utils) and parses its LDIF output directly
— the same tool that's already proven reliable here. authenticateLdapUser
(the per-login lookup) is untouched: it's a narrow single-match query
that has shown no sign of this issue, and isn't worth the added latency
of spawning a process on every login.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
Paging was introduced to work around what looked like a response-size
problem, but the actual cause (previous commit) was @ldapjs/asn1's BER
decoder choking on a specific oversized attribute value, not overall
response size. With attributes now limited to mail/cn/displayName, the
whole response is tiny, and paging's extra sequential round-trips on the
same connection were themselves causing an ECONNRESET partway through.
An unpaged single request+response now matches what a plain ldapsearch
against this same AD does successfully.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
The real error surfaced by the previous commit's diagnostics: "Parser
error ... Encoding too long" from @ldapjs/asn1's BER decoder — a parser
desync (it misreads some later byte as a new field's length prefix),
most likely triggered while decoding some AD attribute value on one of
the ~1000 objects that we never actually use (e.g. nTSecurityDescriptor,
msDS-ReplAttributeMetaData, or similar oversized/binary attributes AD
attaches to many objects by default). Every call site only ever reads
mail/cn/displayName, so there was never a reason to request the full
attribute set — doing so avoids decoding whatever was tripping the bug,
regardless of exactly which attribute it was.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
Neither raising the client timeout nor switching to paged search fixed
the directory-wide import browse — narrowing it down with a plain
ldapsearch (same filter, run from inside this exact container, both
dn-only and full-attribute) showed the network path and response
content are both fine; a standard LDAP client handles it without issue.
That leaves ldapjs's own response handling. Its client-level `error`
event (e.g. a BER/protocol parser failure) forcibly closes the socket,
but we were swallowing that event silently — so every failure surfaced
as the same uninformative "<id> closed" ConnectionError regardless of
what actually went wrong. Now the last captured client `error` is
attached to the bind/search rejection instead, so the UI's error
message will show the real underlying cause next time this fails.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
A plain ldapsearch against this AD with the identical broad filter
(objectClass=person) completed cleanly — 996 entries, exit 0 — ruling
out the network/server as the cause of the "<id> closed" ConnectionError
our app was hitting. The difference is response size: ~1000 LDAP protocol
messages in one unbounded response is apparently enough to trip up
ldapjs's client-side handling, even though our own busy_timeout increase
(previous commit) didn't help since the connection was being closed, not
timed out. Paging the search (200 entries per round-trip, handled
internally by ldapjs — callers still just see one continuous stream of
searchEntry events) sidesteps the issue regardless of its exact root
cause. Applies to both the admin "import accounts" browse and the
per-login user lookup, since they share the same search() helper.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
ldapjs's `timeout` option is a single flat deadline for the whole
operation (set once when the request is sent, not reset per entry
received) — 5s is fine for a bind or a single-match search during login,
but the admin "import accounts" browse runs an unbounded
(objectClass=person) search across the entire base DN, which can take
longer than 5s against a real AD domain and was getting killed mid-stream
(surfaced as ldapjs's generic "<id> closed" ConnectionError). Bumped that
one call site to 30s; login-path calls keep the tighter 5s deadline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
Admins can now create local agent/admin accounts and configure LDAP
directly from the UI (Настройки → Аккаунты / LDAP), with login trying
LDAP first and falling back to the local password. This is the first
place users.role is actually enforced (requireAdminSession).
Fixed a bug in the generated 0005 migration: the INSERT into __new_users
selected auth_source from the old users table, which doesn't have that
column yet — caused drizzle-kit migrate to fail silently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH