// LDAPS server: one listener, bound only in the cluster PRIMARY process (so the // forked workers don't race the port and spew EADDRINUSE), and started from // onLoad ONLY when the port env var (SALTCORN_IDP_LDAP_PORT) is set (so the three // dev instances don't collide). The bind interface is SALTCORN_IDP_LDAP_HOST, // defaulting to loopback so LDAP is not network-exposed unless explicitly opted // in. LDAPS-only (ldapjs has no StartTLS); for dev we generate a self-signed // cert, production would supply one via config. Bind + search reuse the Saltcorn // user/group model and resolve their own tenant context. // // The ldapjs dependency is owned through lib/ldap/vendor.js (the single // require point); the byte-cap / filter-depth / per-IP guards live in our code // (vendor.js, search.js, harden.js). Multi-tenancy is via tenant-in-DN + // runWithTenant (lib/ldap/tenant.js). const cluster = require("cluster"); const selfsigned = require("selfsigned"); const constants = require("../constants"); const vendor = require("./vendor"); const bind = require("./bind"); const search = require("./search"); let started = false; let listening = false; const noopLog = { trace() {}, debug() {}, info() {}, warn() {}, error() {}, fatal() {}, child() { return noopLog; } }; const isPrimary = () => { return cluster.isPrimary !== undefined ? cluster.isPrimary : cluster.isMaster; }; const isLoopbackHost = (host) => { const h = String(host).toLowerCase(); return h === "127.0.0.1" || h === "::1" || h === "localhost"; }; // Bind the listener, retrying a bounded number of times on a transient bind // failure (EADDRINUSE/EACCES) -- e.g. the prior process's socket lingering across // a fast restart. After the last attempt, fail LOUDLY (LDAP auth is unavailable, // not benign) and reset `started` so a later plugin reload can retry. // // ldapjs's Server.listen() fires the SUCCESS path via the callback only -- it does // NOT emit a 'listening' event on the wrapper (only 'error' is emitted there), so // success must be detected by the listen callback. But listen() is re-called on // every retry, and each call leaves its callback registered as a one-shot // 'listening' listener on the inner net server; when a later attempt finally // binds, ALL the accumulated callbacks fire. The `settled` guard makes the // success (and give-up) effect run exactly once regardless. const listenWithRetry = (server, host, port) => { let settled = false; let attempt = 1; const onListening = () => { if (settled) { return; } settled = true; listening = true; server.removeListener("error", onError); server.on("error", (e) => { // eslint-disable-next-line no-console console.error("[saltcorn-idp] ldap server runtime error:", e && e.message); }); // eslint-disable-next-line no-console console.log("[saltcorn-idp] LDAPS listening on ldaps://" + host + ":" + port + " base " + constants.LDAP_BASE_DN + " (tenant = dc=,)"); if (!isLoopbackHost(host)) { // eslint-disable-next-line no-console console.warn("[saltcorn-idp] NOTE: LDAP is bound to " + host + " (beyond loopback) -- it is reachable from the network; ensure this is intended and firewalled appropriately."); } }; const onError = (e) => { if (settled) { return; } const code = e && e.code; if ((code === "EADDRINUSE" || code === "EACCES") && attempt < constants.LDAP_BIND_MAX_ATTEMPTS) { const delay = constants.LDAP_BIND_RETRY_BASE_MS * attempt; // eslint-disable-next-line no-console console.error("[saltcorn-idp] LDAP " + host + ":" + port + " not yet bindable (" + code + "); retry " + (attempt + 1) + "/" + constants.LDAP_BIND_MAX_ATTEMPTS + " in " + delay + "ms"); attempt++; setTimeout(() => { if (!settled) { server.listen(port, host, onListening); } }, delay); return; } settled = true; server.removeListener("error", onError); started = false; listening = false; // eslint-disable-next-line no-console console.error("[saltcorn-idp] WARNING: LDAP enabled on " + host + ":" + port + " but could NOT bind after " + attempt + " attempt(s) (" + code + "): LDAP authentication is UNAVAILABLE"); }; // One persistent error listener drives all retries; the listen callback (not a // 'listening' event) signals success. server.on("error", onError); server.listen(port, host, onListening); }; const startLdap = async (opts) => { const force = !!(opts && opts.force); // Diagnostic line for the intermittent unbound-:1637 heisenbug: capture pid, // cluster role, the once-per-process started/listening flags, and the port env // at a normal log level so the next occurrence is recorded (the symptom is a // silent no-bind: no "LDAPS listening" line, no EADDRINUSE/retry). // ROOT CAUSE (captured 2026-06-01): on a flaky PG boot EVERY startLdap call is // isPrimary=false -- the cluster PRIMARY never runs onLoad/startLdap, so nobody // binds. The self-heal watchdog (index.js) therefore force-binds from whatever // process notices the port is unbound; force=true bypasses the isPrimary gate. // eslint-disable-next-line no-console console.log("[saltcorn-idp] startLdap: pid=" + process.pid + " isPrimary=" + isPrimary() + " force=" + force + " started=" + started + " listening=" + listening + " " + constants.LDAP_PORT_ENV + "=" + (process.env[constants.LDAP_PORT_ENV] || "")); if (started) { return; } const portStr = process.env[constants.LDAP_PORT_ENV]; if (!portStr) { return; } const port = parseInt(portStr, 10); if (!Number.isFinite(port)) { return; } // Bind interface is configurable; default to loopback so LDAP is not exposed // unless an operator opts in. An empty/whitespace value falls back to default. const hostEnv = (process.env[constants.LDAP_HOST_ENV] || "").trim(); const host = hostEnv || constants.LDAP_DEFAULT_HOST; // Bind only in the cluster primary; forked workers return silently -- UNLESS // forced by the watchdog (the primary never bound, so a worker heals it; the // EADDRINUSE retry in listenWithRetry arbitrates if two workers race). if (!isPrimary() && !force) { return; } started = true; let server; try { const pems = await selfsigned.generate( [{ name: "commonName", value: "saltcorn-idp-ldap" }], { keyType: "rsa", keySize: 2048, algorithm: "sha256" } ); server = vendor.createHardenedServer({ certificate: pems.cert, key: pems.private, log: noopLog }); server.maxConnections = 256; server.bind("", bind.handler); server.search(constants.LDAP_BASE_DN, search.handler); } catch (e) { started = false; // eslint-disable-next-line no-console console.error("[saltcorn-idp] failed to initialize LDAP server:", e); return; } listenWithRetry(server, host, port); }; // True once the listener is actually bound (the onListening callback fired); // false before bind and after a give-up. Lets index.js arm a self-heal watchdog. const isListening = () => { return listening; }; module.exports = { startLdap, isListening };