Restored to 'd77ce2adbe81c897769f79623edb2fdd7c5cdbc7'

Replit-Restored-To: d77ce2adbe
This commit is contained in:
alphaeusmote
2025-04-08 21:19:22 +00:00
parent 077269a3df
commit afca6f811d
18 changed files with 357 additions and 3584 deletions
+196 -307
View File
@@ -1,312 +1,201 @@
import { LdapConnection } from "@shared/schema";
import debugLib from "debug";
import * as ldapjs from "ldapjs";
import { promisify } from "util";
import { EventEmitter } from 'events';
import ldap from 'ldapjs';
import { LdapConnection } from '@shared/schema';
import { storage } from './storage';
const debug = debugLib("app:ldap");
/**
* Connect to LDAP server and return a client
*/
export async function connectToLdap(connection: LdapConnection): Promise<ldapjs.Client> {
const url = `${connection.useSSL ? "ldaps" : "ldap"}://${connection.server}:${connection.port}`;
class LdapClient extends EventEmitter {
private clients: Map<number, ldap.Client> = new Map();
private isConnected: Map<number, boolean> = new Map();
debug(`Connecting to LDAP server at ${url}`);
const client = ldapjs.createClient({
url,
timeout: 5000,
connectTimeout: 10000,
idleTimeout: 30000,
reconnect: {
initialDelay: 100,
maxDelay: 1000,
failAfter: 10
}
});
// Convert bind to promise
const bindAsync = promisify(client.bind).bind(client);
try {
// Bind with credentials
await bindAsync(connection.username, connection.password);
debug("Successfully authenticated to LDAP server");
return client;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
debug("Failed to connect to LDAP server:", error);
throw new Error(`Failed to connect to LDAP server: ${errorMessage}`);
}
}
export interface LdapSearchOptions {
base?: string;
filter: string;
scope?: "base" | "one" | "sub";
attributes?: string[];
limit?: number;
}
/**
* Search LDAP directory with the provided options
*/
export async function searchLdap(client: ldapjs.Client, options: LdapSearchOptions): Promise<Record<string, any>[]> {
const {
base = "",
filter,
scope = "sub",
attributes,
limit = 1000
} = options;
debug(`Searching LDAP with filter: ${filter}`);
return new Promise((resolve, reject) => {
const results: Record<string, any>[] = [];
client.search(base, {
filter,
scope, // ldapjs accepts 'base', 'one', 'sub' as strings
attributes,
sizeLimit: limit
}, (err: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse) => {
if (err) {
debug("LDAP search error:", err);
return reject(err);
}
// The types for ldapjs don't fully match the actual API
// We need to use any here because the type definitions are incomplete
res.on("searchEntry", (entry: any) => {
results.push(entry.object);
});
res.on("error", (err: ldapjs.Error) => {
debug("LDAP search result error:", err);
reject(err);
});
res.on("end", (result: any) => {
debug(`LDAP search completed with ${results.length} results`);
if (result && result.status !== 0) {
debug(`LDAP search ended with status: ${result.status}`);
}
resolve(results);
});
});
});
}
/**
* Test a connection to an LDAP server
*/
export async function testLdapConnection(connection: LdapConnection): Promise<boolean> {
try {
const client = await connectToLdap(connection);
client.destroy();
return true;
} catch (error: unknown) {
debug("LDAP connection test failed:", error);
return false;
}
}
/**
* Get basic info about an LDAP domain
*/
export async function getLdapDomainInfo(client: ldapjs.Client): Promise<Record<string, any> | null> {
try {
const results = await searchLdap(client, {
filter: "(objectClass=domain)",
scope: "base"
});
return results.length > 0 ? results[0] : null;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
debug("Failed to get LDAP domain info:", error);
throw new Error(`Failed to get LDAP domain info: ${errorMessage}`);
}
}
/**
* Get available attributes for a specific object type from LDAP schema
* This function retrieves attributes that are commonly used for the specified object type
*/
export interface LdapAttributeMetadata {
name: string;
type?: string;
description?: string;
syntax?: string;
isMultiValued?: boolean;
isMandatory?: boolean;
}
export async function getLdapAvailableAttributes(
client: ldapjs.Client,
targetObject: "users" | "groups" | "computers" | "ous"
): Promise<LdapAttributeMetadata[]> {
debug(`Getting available attributes for ${targetObject}`);
// Define common attributes for each object type
const commonAttributes = [
"cn", "name", "displayName", "description", "objectClass", "objectCategory",
"distinguishedName", "whenCreated", "whenChanged", "objectGUID", "objectSid"
];
// Object type specific attributes
const specificAttributes: Record<string, string[]> = {
users: [
"sAMAccountName", "userPrincipalName", "givenName", "sn", "mail",
"telephoneNumber", "mobile", "title", "department", "company",
"manager", "employeeID", "employeeNumber", "memberOf", "userAccountControl",
"pwdLastSet", "lastLogon", "lastLogonTimestamp", "accountExpires", "badPwdCount",
"logonCount", "homeDirectory", "homeDrive", "scriptPath", "profilePath",
"lockoutTime", "country", "st", "l", "streetAddress", "postalCode", "otherTelephone"
],
groups: [
"sAMAccountName", "groupType", "member", "memberOf", "managedBy", "msDS-PrincipalName",
"mail", "info", "groupCategory", "adminCount", "proxyAddresses"
],
computers: [
"sAMAccountName", "operatingSystem", "operatingSystemVersion", "operatingSystemServicePack",
"dNSHostName", "servicePrincipalName", "lastLogonTimestamp", "pwdLastSet",
"userAccountControl", "managedBy", "location", "serialNumber", "msDS-SupportedEncryptionTypes",
"networkAddress", "primaryGroupID"
],
ous: [
"ou", "name", "description", "distinguishedName", "managedBy", "gPLink", "gPOptions",
"msDS-Approx-Immed-Subordinates", "streetAddress", "l", "st", "postalCode", "c"
]
};
try {
// Try to dynamically retrieve schema info for more attributes
// This gets attributes from the schema for the specific object class
let dynamicAttributes: string[] = [];
let objectClass: string;
switch (targetObject) {
case "users":
objectClass = "user";
break;
case "groups":
objectClass = "group";
break;
case "computers":
objectClass = "computer";
break;
case "ous":
objectClass = "organizationalUnit";
break;
}
async connect(connection: LdapConnection): Promise<boolean> {
try {
// Attempt to query the schema - we need to try different base DNs since the schema location varies
let schemaResults: Record<string, any>[] = [];
// Try common locations for the schema (most AD servers will use one of these)
const schemaLocations = [
"CN=Schema,CN=Configuration,DC=domain,DC=com", // Generic example
"CN=Schema,CN=Configuration,DC=ad,DC=example,DC=com",
"CN=Schema,CN=Configuration,DC=example,DC=com",
"CN=Schema,CN=Configuration",
"CN=Schema",
];
let schemaFound = false;
for (const schemaLocation of schemaLocations) {
try {
schemaResults = await searchLdap(client, {
base: schemaLocation,
filter: `(&(objectClass=attributeSchema)(|(attributeSyntax=2.5.5.8)(attributeSyntax=2.5.5.9)(attributeSyntax=2.5.5.12)))`,
scope: "sub",
attributes: ["lDAPDisplayName", "attributeSyntax", "isSingleValued"],
limit: 500
});
if (schemaResults.length > 0) {
schemaFound = true;
debug(`Found schema at ${schemaLocation} with ${schemaResults.length} attributes`);
break;
}
} catch (locationError) {
debug(`Schema not found at ${schemaLocation}: ${locationError instanceof Error ? locationError.message : String(locationError)}`);
// Continue trying other locations
}
}
if (!schemaFound) {
debug("Could not find schema in any of the standard locations");
}
dynamicAttributes = schemaResults.map(attr => attr.lDAPDisplayName);
debug(`Retrieved ${dynamicAttributes.length} attributes from schema`);
} catch (schemaError) {
debug("Failed to retrieve attributes from schema, using predefined list:", schemaError);
// Continue with the predefined attributes
}
// Combine common and specific attributes, then add dynamic attributes
let allAttributeNames = [...commonAttributes, ...specificAttributes[targetObject]];
// Add any dynamic attributes that aren't already in our list
dynamicAttributes.forEach(attr => {
if (!allAttributeNames.includes(attr)) {
allAttributeNames.push(attr);
}
});
// Convert string attributes to metadata objects and sort alphabetically by name
const attributeMetadata: LdapAttributeMetadata[] = allAttributeNames.map((name: string) => {
// Attribute type inference based on common patterns
let type = "string";
if (name.toLowerCase().includes("count") || name.toLowerCase().includes("id") ||
name.endsWith("Type") || name.includes("ID")) {
type = "number";
} else if (name.toLowerCase().includes("time") || name.toLowerCase().includes("date") ||
name.toLowerCase().includes("created") || name.toLowerCase().includes("changed") ||
name.toLowerCase().includes("expires")) {
type = "datetime";
} else if (name.toLowerCase().includes("is") || name.toLowerCase().includes("has") ||
name.toLowerCase().includes("enabled") || name.toLowerCase().includes("disabled")) {
type = "boolean";
}
// Description inference based on name
let description = "";
if (name === "cn") description = "Common Name";
else if (name === "sn") description = "Surname";
else if (name === "givenName") description = "First Name";
else if (name === "sAMAccountName") description = "Login Name";
else if (name === "userPrincipalName") description = "User Principal Name";
else if (name === "mail") description = "Email Address";
else if (name === "memberOf") description = "Group Memberships";
else if (name === "member") description = "Members";
else if (name === "pwdLastSet") description = "Password Last Set";
else if (name === "lastLogon") description = "Last Login Time";
return {
name,
type,
description: description || undefined,
// For multi-valued attributes we could infer based on name, but would be more accurate
// to use the schema information which we don't fully process here yet
isMultiValued: name === "memberOf" || name === "member" || name === "proxyAddresses" ||
name === "objectClass" || name === "servicePrincipalName"
const clientOptions: ldap.ClientOptions = {
url: `${connection.useTLS ? 'ldaps' : 'ldap'}://${connection.server}:${connection.port}`,
reconnect: {
initialDelay: 1000,
maxDelay: 10000,
failAfter: 10
},
timeout: 5000,
connectTimeout: 10000
};
});
// Sort alphabetically by name
return attributeMetadata.sort((a, b) => a.name.localeCompare(b.name));
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
debug("Failed to get LDAP available attributes:", error);
// Return a default set of attributes instead of throwing
const defaultAttributes = [...commonAttributes, ...specificAttributes[targetObject]].sort();
// Convert to metadata objects with minimal information
return defaultAttributes.map(name => ({ name }));
const client = ldap.createClient(clientOptions);
return new Promise((resolve, reject) => {
client.on('error', async (err) => {
console.error(`LDAP connection error for ${connection.name}:`, err);
this.isConnected.set(connection.id, false);
await storage.updateLdapConnection(connection.id, { status: 'disconnected' });
this.emit('status', {
connectionId: connection.id,
status: 'disconnected',
error: err.message
});
});
client.bind(connection.username, connection.password, async (err) => {
if (err) {
console.error(`LDAP bind error for ${connection.name}:`, err);
this.isConnected.set(connection.id, false);
await storage.updateLdapConnection(connection.id, { status: 'disconnected' });
this.emit('status', {
connectionId: connection.id,
status: 'disconnected',
error: err.message
});
reject(err);
return;
}
this.clients.set(connection.id, client);
this.isConnected.set(connection.id, true);
await storage.updateLdapConnection(connection.id, {
status: 'connected',
lastConnected: new Date()
});
this.emit('status', {
connectionId: connection.id,
status: 'connected'
});
resolve(true);
});
});
} catch (error) {
console.error(`LDAP connection error for ${connection.name}:`, error);
this.isConnected.set(connection.id, false);
await storage.updateLdapConnection(connection.id, { status: 'disconnected' });
this.emit('status', {
connectionId: connection.id,
status: 'disconnected',
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
}
async disconnect(connectionId: number): Promise<void> {
const client = this.clients.get(connectionId);
if (client) {
return new Promise((resolve) => {
client.unbind(() => {
this.clients.delete(connectionId);
this.isConnected.set(connectionId, false);
resolve();
});
});
}
}
getClient(connectionId: number): ldap.Client | undefined {
return this.clients.get(connectionId);
}
isConnectionActive(connectionId: number): boolean {
return this.isConnected.get(connectionId) || false;
}
// LDAP CRUD operations
async searchUsers(connectionId: number, filter = '(objectClass=user)', attributes?: string[]): Promise<any[]> {
const client = this.getClient(connectionId);
if (!client) throw new Error('LDAP connection not established');
const connection = await storage.getLdapConnection(connectionId);
if (!connection) throw new Error('LDAP connection not found');
const baseDN = connection.baseDN || '';
const defaultAttributes = ['cn', 'sAMAccountName', 'mail', 'distinguishedName'];
const searchAttributes = attributes?.length ? attributes : defaultAttributes;
return new Promise((resolve, reject) => {
const results: any[] = [];
client.search(baseDN, {
filter,
scope: 'sub',
attributes: searchAttributes
}, (err, res) => {
if (err) {
reject(err);
return;
}
res.on('searchEntry', (entry) => {
results.push(entry.object);
});
res.on('error', (err) => {
reject(err);
});
res.on('end', (result) => {
resolve(results);
});
});
});
}
async searchGroups(connectionId: number, filter = '(objectClass=group)', attributes?: string[]): Promise<any[]> {
const defaultAttributes = ['cn', 'distinguishedName', 'member'];
return this.searchUsers(connectionId, filter, attributes || defaultAttributes);
}
async searchOUs(connectionId: number, filter = '(objectClass=organizationalUnit)', attributes?: string[]): Promise<any[]> {
const defaultAttributes = ['ou', 'distinguishedName'];
return this.searchUsers(connectionId, filter, attributes || defaultAttributes);
}
async searchComputers(connectionId: number, filter = '(objectClass=computer)', attributes?: string[]): Promise<any[]> {
const defaultAttributes = ['cn', 'distinguishedName', 'operatingSystem'];
return this.searchUsers(connectionId, filter, attributes || defaultAttributes);
}
async createEntry(connectionId: number, dn: string, attributes: any): Promise<boolean> {
const client = this.getClient(connectionId);
if (!client) throw new Error('LDAP connection not established');
return new Promise((resolve, reject) => {
client.add(dn, attributes, (err) => {
if (err) {
reject(err);
return;
}
resolve(true);
});
});
}
async updateEntry(connectionId: number, dn: string, changes: any[]): Promise<boolean> {
const client = this.getClient(connectionId);
if (!client) throw new Error('LDAP connection not established');
return new Promise((resolve, reject) => {
client.modify(dn, changes, (err) => {
if (err) {
reject(err);
return;
}
resolve(true);
});
});
}
async deleteEntry(connectionId: number, dn: string): Promise<boolean> {
const client = this.getClient(connectionId);
if (!client) throw new Error('LDAP connection not established');
return new Promise((resolve, reject) => {
client.del(dn, (err) => {
if (err) {
reject(err);
return;
}
resolve(true);
});
});
}
}
export const ldapClient = new LdapClient();