185 lines
7.9 KiB
JavaScript
185 lines
7.9 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");
|
|
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=<tenant>,<base>)");
|
|
// 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
|
|
};
|