diff --git a/Dockerfile b/Dockerfile index 705f197..d4c094b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,16 @@ COPY . . # Migrating first — as its own isolated, single-process step — means the # workers only ever see an already-existing, already-stable file. RUN mkdir -p data && npm run db:migrate +# A second, separate race: drizzle-kit's migrate command opens its own +# connection and leaves the file in SQLite's default (non-WAL) journal +# mode. The *first* pragma("journal_mode = WAL") call on a file is a real +# mode switch requiring a brief exclusive lock — and unlike ordinary +# statements, that specific switch doesn't reliably honor busy_timeout, so +# when all of next build's workers race to be the one performing it, one +# loses and throws SQLITE_BUSY regardless of the 30s timeout configured in +# lib/db/client.ts. Switching to WAL here, once, single-process, means +# every worker's own pragma call below is just a no-op mode check. +RUN node -e "require('better-sqlite3')('data/db.sqlite').pragma('journal_mode = WAL')" RUN npm run build ENV NODE_ENV=production diff --git a/src/lib/ldap/client.ts b/src/lib/ldap/client.ts index f498264..31a3fbd 100644 --- a/src/lib/ldap/client.ts +++ b/src/lib/ldap/client.ts @@ -119,30 +119,49 @@ async function ldapSearchCli( filter: string, ): Promise<{ dn: string; attributes: Record }[]> { const protocol = settings.useTls ? "ldaps" : "ldap"; - const { stdout } = await execFileAsync( - "ldapsearch", - [ - "-x", - "-LLL", - "-H", - `${protocol}://${settings.host}:${settings.port}`, - "-D", - settings.bindDn, - "-w", - settings.bindPassword, - "-b", - baseDn, - filter, - "mail", - "cn", - "displayName", - ], - // This now sits on the login path too — a hard deadline here backs up - // authenticateLdapUser's documented guarantee that a directory outage - // must never hang login, regardless of what ldapsearch itself does. - { maxBuffer: 20 * 1024 * 1024, timeout: 15_000 }, - ); - return parseLdif(stdout); + try { + const { stdout } = await execFileAsync( + "ldapsearch", + [ + "-x", + "-LLL", + // Simple Paged Results (RFC 2696): without it, a base DN with more + // entries than the server's admin-configured sizeLimit (AD default: + // 1000, but often set lower) fails the whole search outright — + // "Size limit exceeded (4)" — even though every individual page is + // under the limit. ldapsearch transparently walks all pages and + // concatenates the LDIF output, so parseLdif below needs no changes. + "-E", + "pr=500/noprompt", + "-H", + `${protocol}://${settings.host}:${settings.port}`, + "-D", + settings.bindDn, + "-w", + settings.bindPassword, + "-b", + baseDn, + filter, + "mail", + "cn", + "displayName", + ], + // This now sits on the login path too — a hard deadline here backs up + // authenticateLdapUser's documented guarantee that a directory outage + // must never hang login, regardless of what ldapsearch itself does. + { maxBuffer: 20 * 1024 * 1024, timeout: 15_000 }, + ); + return parseLdif(stdout); + } catch (err) { + // execFile's rejection .message is "Command failed: \n" + // — the full argv includes -w verbatim, which would + // otherwise reach callers (and, via the admin API's error responses, + // the browser) in plain text. Only stderr — ldapsearch's own diagnostic + // text, which never echoes the arguments it was invoked with — is safe + // to surface. + const stderr = (err as { stderr?: string })?.stderr?.trim(); + throw new Error(stderr || "ldapsearch failed"); + } } /** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */