import { randomUUID } from 'crypto'; import ldap from 'ldapjs'; let syncRunning = false; function getAttr(entry, name) { const want = String(name || '').toLowerCase(); for (const attr of entry.attributes) { if (attr.type.toLowerCase() === want) { const v = attr.values[0]; return v != null ? String(v).trim() : ''; } } return ''; } function searchAsync(client, base, options) { return new Promise((resolve, reject) => { const results = []; client.search(base, options, (err, res) => { if (err) return reject(err); res.on('searchEntry', (entry) => results.push(entry)); res.on('error', reject); res.on('end', () => resolve(results)); }); }); } function bindAsync(client, dn, password) { return new Promise((resolve, reject) => { client.bind(dn || '', password ?? '', (err) => (err ? reject(err) : resolve())); }); } function unbindAsync(client) { return new Promise((resolve) => { try { client.unbind(() => resolve()); } catch { resolve(); } }); } function insertLog(db, row) { db.prepare( `INSERT INTO ldap_sync_log (id, started_at, finished_at, trigger_type, status, users_synced, error_message) VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( row.id, row.startedAt, row.finishedAt, row.triggerType, row.status, row.usersSynced, row.errorMessage, ); } /** * @param {import('node:sqlite').DatabaseSync} db * @param {() => object} loadIntegrations * @param {'manual' | 'automatic'} trigger */ export async function performLdapSync(db, loadIntegrations, trigger) { if (syncRunning) { return { skipped: true, message: 'Synchronisation läuft bereits.' }; } syncRunning = true; const logId = randomUUID(); const startedAt = new Date().toISOString(); let usersSynced = 0; let errorMessage = null; let client = null; try { const config = loadIntegrations().ldap || {}; const serverUrl = String(config.serverUrl || '').trim(); const searchBase = String(config.searchBase || '').trim(); const filter = String( config.userSearchFilter || config.userFilter || '', ).trim(); const usernameAttr = String( config.usernameAttribute || 'sAMAccountName', ).trim(); if (!serverUrl) throw new Error('LDAP-Server URL fehlt.'); if (!searchBase) throw new Error('Base DN fehlt.'); if (!filter) throw new Error('User Search Filter fehlt.'); client = ldap.createClient({ url: serverUrl, timeout: 120000, connectTimeout: 20000, reconnect: false, }); client.on('error', () => {}); const bindDn = String(config.bindDn || '').trim(); const bindPassword = config.bindPassword != null ? String(config.bindPassword) : ''; await bindAsync(client, bindDn, bindPassword); const entries = await searchAsync(client, searchBase, { scope: 'sub', filter, attributes: [usernameAttr], }); await unbindAsync(client); client = null; db.exec('BEGIN'); try { let n = 0; for (const entry of entries) { let dn; try { dn = entry.objectName.toString(); } catch { continue; } const un = getAttr(entry, usernameAttr).toLowerCase().trim(); if (!un) continue; const existing = db .prepare('SELECT id, source FROM users WHERE username = ?') .get(un); if (existing) { if (existing.source === 'local') continue; db.prepare( `UPDATE users SET ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`, ).run(dn, existing.id); n += 1; } else { const id = randomUUID(); db.prepare( `INSERT INTO users (id, username, password_hash, role, source, ldap_dn, active, updated_at) VALUES (?, ?, NULL, 'user', 'ldap', ?, 1, datetime('now'))`, ).run(id, un, dn); n += 1; } } db.exec('COMMIT'); usersSynced = n; } catch (e) { try { db.exec('ROLLBACK'); } catch { /* ignore */ } throw e; } } catch (e) { errorMessage = e && e.message ? String(e.message) : String(e); if (client) { try { await unbindAsync(client); } catch { /* ignore */ } client = null; } } finally { try { const finishedAt = new Date().toISOString(); const status = errorMessage ? 'error' : 'success'; insertLog(db, { id: logId, startedAt, finishedAt, triggerType: trigger, status, usersSynced: errorMessage ? 0 : usersSynced, errorMessage, }); } finally { syncRunning = false; } } if (errorMessage) { return { ok: false, usersSynced: 0, error: errorMessage }; } return { ok: true, usersSynced }; } /** * @param {import('node:sqlite').DatabaseSync} db */ export function getSyncStatus(db) { const last = db .prepare( `SELECT finished_at FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 1`, ) .get(); const entries = db .prepare( `SELECT finished_at AS finishedAt, trigger_type AS triggerType, status, users_synced AS usersSynced, error_message AS errorMessage FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 10`, ) .all(); return { lastSyncAt: last?.finished_at ?? null, entries, }; }