sc-idp/lib/ldap/server.js
2026-06-01 16:40:54 -05:00

177 lines
7.4 KiB
JavaScript

// 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=<tenant>,<base>)");
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
};