// 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"); const settings = require("./settings"); 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=,)"); // Record what we actually bound with so the admin panel can tell whether a // later settings change still needs a restart to take effect. settings.setApplied({ enabled: true, host: host, port: port }).catch((e) => { // eslint-disable-next-line no-console console.error("[saltcorn-idp] failed to record LDAP applied state:", e && e.message); }); 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); if (started) { return; } // Settings come from the public-site config (root tenant); the // SALTCORN_IDP_LDAP_PORT/HOST env vars, when set, override them but are never // required. resolveRuntime applies that precedence. const runtime = await settings.resolveRuntime(); // Diagnostic line for the intermittent unbound-:1637 heisenbug (captured // 2026-06-01: on a flaky PG boot EVERY startLdap call is isPrimary=false, so the // cluster PRIMARY never binds and the index.js watchdog force-binds from whatever // process notices). 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 + " enabled=" + runtime.enabled + " host=" + runtime.host + " port=" + runtime.port + (runtime.portFromEnv ? " (port from env)" : "")); if (!runtime.enabled) { // LDAP off (no config and no env override). Record the applied state so the // admin panel's restart reminder is accurate, then do nothing. await settings.setApplied({ enabled: false }); return; } const port = runtime.port; const host = runtime.host; if (!settings.validatePort(port)) { // eslint-disable-next-line no-console console.error("[saltcorn-idp] LDAP enabled but port " + port + " is out of range (" + constants.LDAP_PORT_MIN + "-" + constants.LDAP_PORT_MAX + "); not binding"); return; } // 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 };