Files
cloudron-box/src/ldapserver.js
Girish Ramakrishnan 8a63f0368e Fix parsing of displayName
Currently, we only have one field for the name. The first part is
first name. The rest is last name. Obviously, this won't work in all
cases but is the best we can do for the moment.
2024-02-06 16:53:03 +01:00

709 lines
29 KiB
JavaScript

'use strict';
exports = module.exports = {
start,
stop,
_MOCK_APP: null
};
const addonConfigs = require('./addonconfigs.js'),
assert = require('assert'),
apps = require('./apps.js'),
AuditSource = require('./auditsource.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:ldap'),
eventlog = require('./eventlog.js'),
groups = require('./groups.js'),
ldap = require('ldapjs'),
mail = require('./mail.js'),
safe = require('safetydance'),
users = require('./users.js'),
util = require('util');
let gServer = null;
const NOOP = function () {};
// Will attach req.app if successful
async function authenticateApp(req, res, next) {
const sourceIp = req.connection.ldap.id.split(':')[0];
if (sourceIp.split('.').length !== 4) return next(new ldap.InsufficientAccessRightsError('Missing source identifier'));
// this is only used by the ldap test. the apps tests still uses proper docker
if (constants.TEST && sourceIp === '127.0.0.1') {
req.app = exports._MOCK_APP;
return next();
}
const [error, app] = await safe(apps.getByIpAddress(sourceIp));
if (error) return next(new ldap.OperationsError(error.message));
if (!app) return next(new ldap.OperationsError('Could not detect app source'));
req.app = app;
next();
}
// Will attach req.user if successful
async function userAuthInternal(appId, req, res, next) {
// extract the common name which might have different attribute names
const attributeName = Object.keys(req.dn.rdns[0].attrs)[0];
const commonName = req.dn.rdns[0].attrs[attributeName].value;
if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));
let verifyFunc;
if (attributeName === '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.verify;
} else {
verifyFunc = users.verifyWithUsername;
}
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', appId || '', { skipTotpCheck: true }));
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 getUsersWithAccessToApp(req) {
assert.strictEqual(typeof req.app, 'object');
const result = await users.list();
const allowedUsers = result.filter((user) => user.active && apps.canAccess(req.app, user)); // do not list inactive users
return allowedUsers;
}
// helper function to deal with pagination
function finalSend(results, req, res, next) {
let min = 0;
let 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 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, result] = await safe(getUsersWithAccessToApp(req));
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));
let results = [];
// send user objects
result.forEach(function (user) {
// skip entries with empty username. Some apps like owncloud can't deal with this
if (!user.username) return;
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', 'organizationalperson', 'top' ],
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`; })
}
};
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 results = [];
let [groupsListError, resultGroups] = await safe(groups.listWithMembers());
if (groupsListError) return next(new ldap.OperationsError(groupsListError.message));
if (req.app.accessRestriction && req.app.accessRestriction.groups) {
resultGroups = resultGroups.filter(function (g) { return req.app.accessRestriction.groups.indexOf(g.id) !== -1; });
}
resultGroups.forEach(function (group) {
const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['group'],
cn: group.name,
gidnumber: group.id,
memberuid: group.userIds
}
};
// 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 groupUsersCompare(req, res, next) {
debug('group users compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
const [error, result] = await safe(getUsersWithAccessToApp(req));
if (error) return next(new ldap.OperationsError(error.message));
// we only support memberuid here, if we add new group attributes later add them here
if (req.attribute === 'memberuid') {
const found = result.find(function (u) { return u.id === req.value; });
if (found) return res.end(true);
}
res.end(false);
}
async function groupAdminsCompare(req, res, next) {
debug('group admins compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
const [error, result] = await safe(getUsersWithAccessToApp(req));
if (error) return next(new ldap.OperationsError(error.message));
// we only support memberuid here, if we add new group attributes later add them here
if (req.attribute === 'memberuid') {
const user = result.find(function (u) { return u.id === req.value; });
if (user && users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) return res.end(true);
}
res.end(false);
}
async function mailboxSearch(req, res, next) {
debug('mailbox search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
// if cn is set OR filter is mail= we only search for one mailbox specifically
let email, dn;
if (req.dn.rdns[0].attrs.cn) {
email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
dn = req.dn.toString();
} else if (req.filter instanceof ldap.EqualityFilter && req.filter.attribute === 'mail') {
email = req.filter.value.toLowerCase();
dn = `cn=${email},${req.dn.toString()}`;
}
if (email) {
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
const [error, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
if (error) return next(new ldap.OperationsError(error.message));
if (!mailbox) return next(new ldap.NoSuchObjectError(dn.toString()));
if (!mailbox.active) return next(new ldap.NoSuchObjectError('Mailbox is not active'));
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['mailbox'],
objectcategory: 'mailbox',
cn: `${mailbox.name}@${mailbox.domain}`,
uid: `${mailbox.name}@${mailbox.domain}`,
mail: `${mailbox.name}@${mailbox.domain}`,
storagequota: mailbox.storageQuota,
messagesquota: mailbox.messagesQuota,
}
};
// 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 (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
} else {
res.end();
}
} else { // new sogo and dovecot listing (doveadm -A)
// TODO figure out how proper pagination here could work
let [error, mailboxes] = await safe(mail.listAllMailboxes(1, 100000));
if (error) return next(new ldap.OperationsError(error.message));
mailboxes = mailboxes.filter(m => m.active);
let results = [];
for (const mailbox of mailboxes) {
const dn = ldap.parseDN(`cn=${mailbox.name}@${mailbox.domain},ou=mailboxes,dc=cloudron`);
if (mailbox.ownerType === mail.OWNERTYPE_APP) continue; // cannot login with app mailbox anyway
const [error, ownerObject] = await safe(mailbox.ownerType === mail.OWNERTYPE_USER ? users.get(mailbox.ownerId) : groups.get(mailbox.ownerId));
if (error || !ownerObject) continue; // skip mailboxes with unknown user
const obj = {
dn: dn.toString(),
attributes: {
objectclass: ['mailbox'],
objectcategory: 'mailbox',
displayname: mailbox.ownerType === mail.OWNERTYPE_USER ? ownerObject.displayName : ownerObject.name,
cn: `${mailbox.name}@${mailbox.domain}`,
uid: `${mailbox.name}@${mailbox.domain}`,
mail: `${mailbox.name}@${mailbox.domain}`,
storagequota: mailbox.storageQuota,
messagesquota: mailbox.messagesQuota,
}
};
mailbox.aliases.forEach(function (a, idx) {
obj.attributes['mail' + idx] = `${a.name}@${a.domain}`;
});
// 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 mailAliasSearch(req, res, next) {
debug('mail alias get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
const [error, alias] = await safe(mail.searchAlias(parts[0], parts[1]));
if (error) return next(new ldap.OperationsError(error.message));
if (!alias) return next(new ldap.NoSuchObjectError('No such alias'));
if (!alias.active) return next(new ldap.NoSuchObjectError('Mailbox is not active')); // there is no way to disable an alias. this is just here for completeness
// https://wiki.debian.org/LDAP/MigrationTools/Examples
// https://docs.oracle.com/cd/E19455-01/806-5580/6jej518pp/index.html
// member is fully qualified - https://docs.oracle.com/cd/E19957-01/816-6082-10/chap4.doc.html#43314
const obj = {
dn: req.dn.toString(),
attributes: {
objectclass: ['nisMailAlias'],
objectcategory: 'nisMailAlias',
cn: `${parts[0]}@${alias.domain}`, // alias.name can contain wildcard character
rfc822MailMember: `${alias.aliasName}@${alias.aliasDomain}`
}
};
// 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 (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
} else {
res.end();
}
}
async function mailingListSearch(req, res, next) {
debug('mailing list get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
let email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
let parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
const name = parts[0], domain = parts[1];
const [error, result] = await safe(mail.resolveList(parts[0], parts[1]));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError('No such list'));
if (error) return next(new ldap.OperationsError(error.message));
const { resolvedMembers, list } = result;
if (!list.active) return next(new ldap.NoSuchObjectError('List is not active'));
// http://ldapwiki.willeke.com/wiki/Original%20Mailgroup%20Schema%20From%20Netscape
// members are fully qualified (https://docs.oracle.com/cd/E19444-01/816-6018-10/groups.htm#13356)
const obj = {
dn: req.dn.toString(),
attributes: {
objectclass: ['mailGroup'],
objectcategory: 'mailGroup',
cn: `${name}@${domain}`, // fully qualified
mail: `${name}@${domain}`,
membersOnly: list.membersOnly, // ldapjs only supports strings and string array. so this is not a bool!
mgrpRFC822MailMember: resolvedMembers // fully qualified
}
};
// 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 (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
} else {
res.end();
}
}
// Will attach req.user if successful
async function authenticateUser(req, res, next) {
debug('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
const appId = req.app.id;
await userAuthInternal(appId, req, res, next);
}
async function authorizeUserForApp(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
assert.strictEqual(typeof req.app, 'object');
const canAccess = apps.canAccess(req.app, req.user);
// we return no such object, to avoid leakage of a users existence
if (!canAccess) return next(new ldap.NoSuchObjectError('Invalid user or insufficient previleges'));
await eventlog.upsertLoginEvent(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.LDAP, { appId: req.app.id, userId: req.user.id, user: users.removePrivateFields(req.user) });
res.end();
}
async function verifyMailboxPassword(mailbox, password) {
assert.strictEqual(typeof mailbox, 'object');
assert.strictEqual(typeof password, 'string');
if (mailbox.ownerType === mail.OWNERTYPE_USER) {
return await users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true });
} else if (mailbox.ownerType === mail.OWNERTYPE_GROUP) {
const userIds = await groups.getMembers(mailbox.ownerId);
let verifiedUser = null;
for (const userId of userIds) {
const [error, result] = await safe(users.verify(userId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true }));
if (error) continue; // try the next user
verifiedUser = result;
break; // found a matching validated user
}
if (!verifiedUser) throw new BoxError(BoxError.INVALID_CREDENTIALS);
return verifiedUser;
} else {
throw new BoxError(BoxError.INVALID_CREDENTIALS);
}
}
async function authenticateSftp(req, res, next) {
debug('sftp auth: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
let [error, app] = await safe(apps.getByFqdn(parts[1]));
if (error || !app) return next(new ldap.InvalidCredentialsError());
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { skipTotpCheck: true }));
if (error) return next(new ldap.InvalidCredentialsError(error.message));
debug('sftp auth: success');
res.end();
}
async function userSearchSftp(req, res, next) {
debug('sftp user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError());
const parts = req.filter.value.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError());
const username = parts[0];
const appFqdn = parts[1];
const [error, app] = await safe(apps.getByFqdn(appFqdn));
if (error) return next(new ldap.OperationsError(error.message));
// only allow apps which specify "ftp" support in the localstorage addon
if (!safe.query(app.manifest.addons, 'localstorage.ftp.uid')) return next(new ldap.UnavailableError('Not supported'));
if (typeof app.manifest.addons.localstorage.ftp.uid !== 'number') return next(new ldap.UnavailableError('Bad uid, must be a number'));
const uidNumber = app.manifest.addons.localstorage.ftp.uid;
const [userGetError, user] = await safe(users.getByUsername(username));
if (userGetError) return next(new ldap.OperationsError(userGetError.message));
if (!user) return next(new ldap.OperationsError('Invalid username'));
if (!apps.isOperator(app, user)) return next(new ldap.InsufficientAccessRightsError('Not authorized'));
const obj = {
dn: ldap.parseDN(`cn=${username}@${appFqdn},ou=sftp,dc=cloudron`).toString(),
attributes: {
homeDirectory: app.storageVolumeId ? `/mnt/app-${app.id}` : `/mnt/appsdata/${app.id}/data`, // see also sftp.js
objectclass: ['user'],
objectcategory: 'person',
cn: user.id,
uid: `${username}@${appFqdn}`, // for bind after search
uidNumber: uidNumber, // unix uid for ftp access
gidNumber: uidNumber // unix gid for ftp access
}
};
finalSend([ obj ], req, res, next);
}
async function verifyAppMailboxPassword(serviceId, username, password) {
assert.strictEqual(typeof serviceId, 'string');
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
const pattern = serviceId === 'msa' ? 'MAIL_SMTP' : 'MAIL_IMAP';
const addonId = serviceId === 'msa' ? 'sendmail' : 'recvmail';
const appId = await addonConfigs.getAppIdByValue(addonId, `%${pattern}_PASSWORD`, password); // search by password because this is unique for each app
if (!appId) throw new BoxError(BoxError.NOT_FOUND);
const result = await addonConfigs.get(appId, addonId);
if (!result.some(r => r.name.endsWith(`${pattern}_USERNAME`) && r.value === username)) throw new BoxError(BoxError.INVALID_CREDENTIALS);
}
async function authenticateService(serviceId, dn, req, res, next) {
debug(`authenticateService: ${req.dn.toString()} (from ${req.connection.ldap.id})`);
if (!dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(dn.toString()));
const email = dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
const knownServices = [ 'msa', 'imap', 'pop3', 'sieve', 'sogo' ];
if (!knownServices.includes(serviceId)) return next(new ldap.OperationsError('Invalid DN. Unknown service'));
const [error, domain] = await safe(mail.getDomain(parts[1]));
if (error) return next(new ldap.OperationsError(error.message));
if (!domain) return next(new ldap.NoSuchObjectError(dn.toString()));
const serviceNeedsMailbox = serviceId === 'imap' || serviceId === 'sieve' || serviceId === 'pop3' || serviceId === 'sogo';
if (serviceNeedsMailbox && !domain.enabled) return next(new ldap.NoSuchObjectError(dn.toString()));
const [getMailboxError, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
if (getMailboxError) return next(new ldap.OperationsError(getMailboxError.message));
if (serviceNeedsMailbox) {
if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString()));
if (serviceId === 'pop3' && !mailbox.enablePop3) return next(new ldap.OperationsError('POP3 is not enabled'));
}
const [appPasswordError] = await safe(verifyAppMailboxPassword(serviceId, email, req.credentials || ''));
if (!appPasswordError) return res.end(); // validated as app
if (appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(appPasswordError.message));
if (appPasswordError.reason !== BoxError.NOT_FOUND) return next(new ldap.OperationsError(appPasswordError.message));
if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString())); // user auth requires active mailbox
const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(verifyError.message));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(verifyError.message));
if (verifyError) return next(new ldap.OperationsError(verifyError.message));
eventlog.upsertLoginEvent(result.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.MAIL, { mailboxId: email, userId: result.id, user: users.removePrivateFields(result) });
res.end();
}
async function authenticateMail(req, res, next) {
if (!req.dn.rdns[1].attrs.ou) return next(new ldap.NoSuchObjectError());
await authenticateService(req.dn.rdns[1].attrs.ou.value.toLowerCase(), req.dn, req, res, 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'
}
});
res.end();
}
async function start() {
assert(gServer === null, 'Already started');
const logger = {
trace: NOOP,
debug: NOOP,
info: debug,
warn: debug,
error: debug,
fatal: debug
};
gServer = ldap.createServer({ log: logger });
gServer.on('error', function (error) {
debug('start: server error. %o', error);
});
gServer.search('ou=users,dc=cloudron', authenticateApp, userSearch);
gServer.search('ou=groups,dc=cloudron', authenticateApp, groupSearch);
gServer.bind('ou=users,dc=cloudron', authenticateApp, authenticateUser, authorizeUserForApp);
// http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt
gServer.search('ou=mailboxes,dc=cloudron', mailboxSearch); // haraka (address translation), dovecot (LMTP), sogo (mailbox search)
gServer.bind('ou=mailboxes,dc=cloudron', async function (req, res, next) { // used for sogo only. this route happens only at sogo login time. after that it will use imap ldap route
await authenticateService('sogo', req.dn, req, res, next);
});
gServer.search('ou=mailaliases,dc=cloudron', mailAliasSearch); // haraka
gServer.search('ou=mailinglists,dc=cloudron', mailingListSearch); // haraka
gServer.bind('ou=imap,dc=cloudron', authenticateMail); // dovecot (IMAP auth)
gServer.bind('ou=msa,dc=cloudron', authenticateMail); // haraka (MSA auth)
gServer.bind('ou=sieve,dc=cloudron', authenticateMail); // dovecot (sieve auth)
gServer.bind('ou=pop3,dc=cloudron', authenticateMail); // dovecot (pop3 auth)
gServer.bind('ou=sftp,dc=cloudron', authenticateSftp); // sftp
gServer.search('ou=sftp,dc=cloudron', userSearchSftp);
gServer.compare('cn=users,ou=groups,dc=cloudron', authenticateApp, groupUsersCompare);
gServer.compare('cn=admins,ou=groups,dc=cloudron', authenticateApp, groupAdminsCompare);
// this is the bind for addons (after bind, they might search and authenticate)
gServer.bind('ou=addons,dc=cloudron', function(req, res /*, next */) {
debug('addons bind: %s', req.dn.toString()); // note: cn can be email or id
res.end();
});
// this is the bind for apps (after bind, they might search and authenticate user)
gServer.bind('ou=apps,dc=cloudron', function(req, res /*, next */) {
// TODO: validate password
debug('application bind: %s', req.dn.toString());
res.end();
});
// directus looks for the "DN" of the bind user
gServer.search('ou=apps,dc=cloudron', function(req, res, next) {
const obj = {
dn: req.dn.toString(),
};
finalSend([obj], req, res, next);
});
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();
});
await util.promisify(gServer.listen.bind(gServer))(constants.LDAP_PORT, '0.0.0.0');
}
async function stop() {
if (!gServer) return;
await util.promisify(gServer.close.bind(gServer))();
gServer = null;
}