'use strict'; exports = module.exports = { getConfig, setConfig, start, stop, checkCertificate, }; const assert = require('node:assert'), AuditSource = require('./auditsource.js'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), debug = require('debug')('box:directoryserver'), eventlog = require('./eventlog.js'), ipaddr = require('./ipaddr.js'), groups = require('./groups.js'), ldap = require('ldapjs'), path = require('node:path'), paths = require('./paths.js'), reverseProxy = require('./reverseproxy.js'), safe = require('safetydance'), settings = require('./settings.js'), shell = require('./shell.js')('directoryserver'), users = require('./users.js'), util = require('node:util'); let gServer = null, gCertificate = null; const NOOP = function () {}; const SET_LDAP_ALLOWLIST_CMD = path.join(__dirname, 'scripts/setldapallowlist.sh'); async function getConfig() { const value = await settings.get(settings.DIRECTORY_SERVER_KEY); if (value === null) return { enabled: false, secret: '', allowlist: '' }; return JSON.parse(value); } async function validateConfig(config) { const { enabled, secret, allowlist } = config; if (!enabled) return; if (!secret) throw new BoxError(BoxError.BAD_FIELD, 'secret cannot be empty'); let gotOne = false; for (const line of allowlist.split('\n')) { if (!line || line.startsWith('#')) continue; const rangeOrIP = line.trim(); if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `'${rangeOrIP}' is not a valid IP or range`); gotOne = true; } // only allow if we at least have one allowed IP/range if (!gotOne) throw new BoxError(BoxError.BAD_FIELD, 'allowlist must at least contain one IP or range'); } async function applyConfig(config) { assert.strictEqual(typeof config, 'object'); // this is done only because it's easier for the shell script and the firewall service to get the value if (config.enabled) { if (!safe.fs.writeFileSync(paths.LDAP_ALLOWLIST_FILE, config.allowlist + '\n', 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message); } else { safe.fs.unlinkSync(paths.LDAP_ALLOWLIST_FILE); } const [error] = await safe(shell.sudo([ SET_LDAP_ALLOWLIST_CMD ], {})); if (error) throw new BoxError(BoxError.IPTABLES_ERROR, `Error setting ldap allowlist: ${error.message}`); if (!config.enabled) { await stop(); return; } if (!gServer) await start(); } async function setConfig(directoryServerConfig, auditSource) { assert.strictEqual(typeof directoryServerConfig, 'object'); assert(auditSource && typeof auditSource === 'object'); if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode'); const oldConfig = await getConfig(); const config = { enabled: directoryServerConfig.enabled, secret: directoryServerConfig.secret, allowlist: directoryServerConfig.allowlist || '' }; await validateConfig(config); await settings.setJson(settings.DIRECTORY_SERVER_KEY, config); await applyConfig(config); await eventlog.add(eventlog.ACTION_DIRECTORY_SERVER_CONFIGURE, auditSource, { fromEnabled: oldConfig.enabled, toEnabled: config.enabled }); } // helper function to deal with pagination function finalSend(results, req, res, next) { const min = 0, max = results.length; let cookie = null; let pageSize = 0; // check if this is a paging request, if so get the cookie for session info req.controls.forEach(function (control) { if (control.type === ldap.PagedResultsControl.OID) { pageSize = control.value.size; cookie = control.value.cookie; } }); function sendPagedResults(start, end) { start = (start < min) ? min : start; end = (end > max || end < min) ? max : end; let i; for (i = start; i < end; i++) { res.send(results[i]); } return i; } if (cookie && Buffer.isBuffer(cookie)) { // we have pagination let first = min; if (cookie.length !== 0) { first = parseInt(cookie.toString(), 10); } const last = sendPagedResults(first, first + pageSize); let resultCookie; if (last < max) { resultCookie = Buffer.from(last.toString()); } else { resultCookie = Buffer.from(''); } res.controls.push(new ldap.PagedResultsControl({ value: { size: pageSize, // correctness not required here cookie: resultCookie } })); } else { // no pagination simply send all results.forEach(function (result) { res.send(result); }); } // all done res.end(); next(); } async function authorize(req, res, next) { debug('authorize: ', req.connection.ldap.bindDN.toString()); // this is for connection attempts without previous bind if (req.connection.ldap.bindDN.equals('cn=anonymous')) return next(new ldap.InsufficientAccessRightsError()); // we only allow this one DN to pass if (!req.connection.ldap.bindDN.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InsufficientAccessRightsError()); return next(); } // https://ldapwiki.com/wiki/RootDSE / RFC 4512 - ldapsearch -x -h "${CLOUDRON_LDAP_SERVER}" -p "${CLOUDRON_LDAP_PORT}" -b "" -s base // ldapjs seems to call this handler for everything when search === '' async function maybeRootDSE(req, res, next) { debug(`maybeRootDSE: requested with scope:${req.scope} dn:${req.dn.toString()}`); if (req.scope !== 'base') return next(new ldap.NoSuchObjectError()); // per the spec, rootDSE search require base scope if (!req.dn || req.dn.toString() !== '') return next(new ldap.NoSuchObjectError()); res.send({ dn: '', attributes: { objectclass: [ 'RootDSE', 'top', 'OpenLDAProotDSE' ], supportedLDAPVersion: '3', vendorName: 'Cloudron LDAP', vendorVersion: '1.0.0', supportedControl: [ ldap.PagedResultsControl.OID ], supportedExtension: [] } }); res.end(); } async function userSearch(req, res, next) { debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id); const [error, allUsers] = await safe(users.list()); if (error) return next(new ldap.OperationsError(error.message)); const [groupsError, allGroups] = await safe(groups.listWithMembers()); if (groupsError) return next(new ldap.OperationsError(groupsError.message)); const results = []; // send user objects for (const user of allUsers) { // skip entries with empty username. Some apps like owncloud can't deal with this if (!user.username) continue; const dn = ldap.parseDN(`cn=${user.id},ou=users,dc=cloudron`); const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null const { firstName, lastName, middleName } = users.parseDisplayName(displayName); // https://datatracker.ietf.org/doc/html/rfc2798 const obj = { dn: dn.toString(), attributes: { objectclass: ['user', 'inetorgperson', 'person' ], objectcategory: 'person', cn: displayName, uid: user.id, entryuuid: user.id, // to support OpenLDAP clients mail: user.email, mailAlternateAddress: user.fallbackEmail, displayname: displayName, givenName: firstName, sn: lastName, middleName: middleName, username: user.username, samaccountname: user.username, // to support ActiveDirectory clients memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; }) } }; if (user.twoFactorAuthenticationEnabled) obj.attributes.twoFactorAuthenticationEnabled = true; // ensure all filter values are also lowercase const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null); if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message)); if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) { results.push(obj); } } finalSend(results, req, res, next); } async function groupSearch(req, res, next) { debug('group search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id); const [error, allUsers] = await safe(users.list()); if (error) return next(new ldap.OperationsError(error.message)); const results = []; const [errorGroups, allGroups] = await safe(groups.listWithMembers()); if (errorGroups) return next(new ldap.OperationsError(errorGroups.message)); for (const group of allGroups) { const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`); const members = group.userIds.filter(function (uid) { return allUsers.map(function (u) { return u.id; }).indexOf(uid) !== -1; }); const obj = { dn: dn.toString(), attributes: { objectclass: ['group'], cn: group.name, gidnumber: group.id, memberuid: members, member: members.map((userId) => `cn=${userId},ou=users,dc=cloudron`), uniquemember: members.map((userId) => `cn=${userId},ou=users,dc=cloudron`) } }; // ensure all filter values are also lowercase const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null); if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message)); if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) { results.push(obj); } } finalSend(results, req, res, next); } // Will attach req.user if successful async function userAuth(req, res, next) { // extract the common name which might have different attribute names const cnAttributeName = Object.keys(req.dn.rdns[0].attrs)[0]; const commonName = req.dn.rdns[0].attrs[cnAttributeName].value; if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN')); // totptoken is passed as the "attribute" using the '+' separator in the first RDNS of the request DN // when totptoken attribute is present, it signals that we must enforce totp check // totp check is currently requested by the client. this is the only way to auth against external cloudron dashboard, external cloudron app and external apps const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; // This has to be in-sync with externalldap.js const totpToken = TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null; const skipTotpCheck = !(TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs); let verifyFunc; if (cnAttributeName === 'mail') { verifyFunc = users.verifyWithEmail; } else if (commonName.indexOf('@') !== -1) { // if mail is specified, enforce mail check verifyFunc = users.verifyWithEmail; } else if (commonName.indexOf('uid-') === 0) { verifyFunc = users.verifyWithId; } else { verifyFunc = users.verifyWithUsername; } const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', '', { totpToken, skipTotpCheck })); if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(error.message)); if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(error.message)); if (error) return next(new ldap.OperationsError(error.message)); req.user = user; next(); } async function start() { assert(gServer === null, 'Already running'); const logger = { trace: NOOP, debug: NOOP, info: debug, warn: debug, error: debug, fatal: debug }; gCertificate = await reverseProxy.getDirectoryServerCertificate(); gServer = ldap.createServer({ certificate: gCertificate.cert, key: gCertificate.key, log: logger }); gServer.on('error', function (error) { debug('server startup error: %o', error); }); gServer.bind('ou=system,dc=cloudron', async function(req, res, next) { debug('system bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id); const config = await getConfig(); if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError('Invalid DN')); if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError('Invalid Secret')); req.user = { user: 'directoryServerAdmin' }; res.end(); // if we use next in the callback, ldapjs requires this after res.end(); return next(); }); gServer.search('ou=users,dc=cloudron', authorize, userSearch); gServer.search('ou=groups,dc=cloudron', authorize, groupSearch); gServer.bind('ou=users,dc=cloudron', userAuth, async function (req, res) { assert.strictEqual(typeof req.user, 'object'); await eventlog.upsertLoginEvent(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.fromDirectoryServerRequest(req), { userId: req.user.id, user: users.removePrivateFields(req.user) }); res.end(); }); gServer.search('', maybeRootDSE); // when '', it seems the callback is called for everything else // just log that an attempt was made to unknown route, this helps a lot during app packaging gServer.use(function(req, res, next) { debug('not handled: dn %s, scope %s, filter %s (from %s)', req.dn ? req.dn.toString() : '-', req.scope, req.filter ? req.filter.toString() : '-', req.connection.ldap.id); return next(); }); debug(`starting server on port ${constants.USER_DIRECTORY_LDAPS_PORT}`); await util.promisify(gServer.listen.bind(gServer))(constants.USER_DIRECTORY_LDAPS_PORT, '::'); } async function stop() { if (!gServer) return; debug('stopping server'); await util.promisify(gServer.close.bind(gServer))(); gServer = null; } async function checkCertificate() { assert(gServer !== null, 'Directory server is not running'); const certificate = await reverseProxy.getDirectoryServerCertificate(); if (certificate.cert === gCertificate.cert) { debug('checkCertificate: certificate has not changed'); return; } debug('checkCertificate: certificate changed. restarting'); await stop(); await start(); }