/** * IAuth Daemon - Main implementation * Handles communication with Nefarious IRCd and DNSBL lookups */ import { createInterface, Interface } from 'node:readline'; import { readConfigFile } from './config.js'; import { reverseIPv4, isIPv4, getCacheEntry, setCacheEntry, getCacheSize, lookupDNSBL, matchesDNSBL, } from './dnsbl.js'; import { decodeSASLPlain, getSupportedMechanisms, } from './sasl.js'; import { AuthManager } from './auth/index.js'; import type { Config, ClientState, DNSBLConfig, CLIOptions, SASLState } from './types.js'; const VERSION = '1.0.0'; export class IAuthDaemon { private config: Config; private configPath: string; private options: CLIOptions; private clients = new Map(); private dnsblCounters = new Map(); private countPass = 0; private countReject = 0; private countSaslSuccess = 0; private countSaslFail = 0; private startTime = Date.now(); private rl: Interface | null = null; private authManager: AuthManager | null = null; private authInitialized = false; constructor(options: CLIOptions) { this.options = options; this.configPath = options.config; const { config } = readConfigFile(options.config); this.config = config; // Initialize DNSBL counters for (const dnsbl of this.config.dnsbls) { this.dnsblCounters.set(dnsbl.cfgNum, 0); } // Initialize auth manager asynchronously this.initializeAuth(); } /** * Initialize the authentication manager */ private async initializeAuth(): Promise { if (this.config.authProviders.length > 0) { this.authManager = new AuthManager(this.config.authProviders); try { await this.authManager.initialize(); this.authInitialized = true; const info = this.authManager.getProviderInfo(); this.debug(`Initialized ${info.length} auth provider(s): ${info.map(p => p.name).join(', ')}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.debug(`Failed to initialize auth manager: ${message}`); } } } /** * Check if SASL/auth is enabled */ private saslEnabled(): boolean { return this.authManager !== null && this.authManager.hasProviders(); } /** * Send a message to ircd via stdout */ private send(message: string): void { console.log(message); } /** * Send debug message (prefixed with "> :") */ private debug(message: string): void { if (this.options.verbose || this.options.debug) { this.send(`> :${message}`); } } /** * Start the daemon */ start(): void { this.handleStartup(); this.rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false, }); this.rl.on('line', (line) => this.handleLine(line)); this.rl.on('close', () => { this.debug('STDIN closed. Shutting down...'); process.exit(0); }); } /** * Send startup messages to ircd */ private handleStartup(): void { // Request iauth protocol version this.send('G 1'); // Send version this.send(`V :Nefarious2 iauthd-ts version ${VERSION}`); // Set policy options this.send(`O ${this.config.policy}`); // Send configuration this.sendNewConfig(); this.debug('Starting up'); this.sendStats(); } /** * Send configuration info to ircd */ private sendNewConfig(): void { this.send('a'); this.send(`A * version :Nefarious iauthd-ts ${VERSION}`); const { configLines } = readConfigFile(this.configPath); for (const line of configLines) { this.send(`A * iauthd-ts :${line}`); } } /** * Send statistics to ircd */ private sendStats(): void { const uptime = this.formatDuration(Date.now() - this.startTime); const upSince = new Date(this.startTime).toUTCString(); this.send('s'); this.send(`S iauthd-ts :Up since ${upSince} (${uptime})`); this.send(`S iauthd-ts :Cache size: ${getCacheSize()}`); this.send(`S iauthd-ts :Total Passed: ${this.countPass}`); this.send(`S iauthd-ts :Total Rejected: ${this.countReject}`); if (this.saslEnabled()) { this.send(`S iauthd-ts :SASL Success: ${this.countSaslSuccess}`); this.send(`S iauthd-ts :SASL Failed: ${this.countSaslFail}`); const providerInfo = this.authManager!.getProviderInfo(); for (const p of providerInfo) { this.send(`S iauthd-ts :Auth Provider: ${p.name} (priority=${p.priority}, healthy=${p.healthy})`); } } for (const dnsbl of this.config.dnsbls) { let desc = dnsbl.server; if (dnsbl.index) desc += ` (${dnsbl.index})`; if (dnsbl.bitmask) desc += ` (${dnsbl.bitmask})`; const count = this.dnsblCounters.get(dnsbl.cfgNum) || 0; this.send(`S iauthd-ts :${desc}: ${count}`); } } /** * Format duration in human-readable form */ private formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours % 24 > 0) parts.push(`${hours % 24}h`); if (minutes % 60 > 0) parts.push(`${minutes % 60}m`); if (seconds % 60 > 0 || parts.length === 0) parts.push(`${seconds % 60}s`); return parts.join(' '); } /** * Handle a line from ircd * Format: * Example: 26 C 172.19.0.1 12345 172.19.0.2 6667 */ private handleLine(line: string): void { if (!line) return; const parts = line.split(' '); const source = parseInt(parts[0], 10); const message = parts[1]; const args = parts.slice(2).join(' '); if (!message) return; switch (message) { case 'C': // Client introduction this.handleClientIntro(source, args); break; case 'D': // Client disconnect this.debug(`Client ${source} disconnected.`); this.deleteClient(source); break; case 'F': // SSL certificate fingerprint // Could be used for certificate-based auth break; case 'R': // Client authenticated via SASL/LOC this.handleAuth(source, args); break; case 'N': // Hostname received break; case 'd': // Hostname timeout break; case 'P': // Client password break; case 'U': // Client username (full) break; case 'u': // Client username (short) break; case 'n': // Client nickname break; case 'H': // Hurry up this.handleHurry(source, args); break; case 'T': // Client registered break; case 'E': // Error this.debug(`ircd error: ${args}`); break; case 'e': // Event if (args === 'rehash') { this.debug('Got a rehash. Rereading config file'); const { config } = readConfigFile(this.configPath); this.config = config; // Reinitialize auth providers if (this.authManager) { this.authManager.reload().catch(err => { this.debug(`Failed to reload auth manager: ${err}`); }); } else { this.initializeAuth(); } this.sendNewConfig(); } break; case 'A': // SASL authentication start this.handleSASLStart(source, args); break; case 'a': // SASL authentication continuation this.handleSASLContinue(source, args); break; case 'M': // Server name and capacity break; case 'X': // Extension query reply break; case 'x': // Extension query server not linked break; case 'W': // Untrusted WEBIRC this.debug('Got an untrusted WEBIRC attempt. Ignoring.'); break; case 'w': // Trusted WEBIRC this.handleWebIRC(source, args); break; default: this.debug(`Got unknown message '${message}' from server`); } } /** * Handle client introduction (C message) */ private handleClientIntro(id: number, args: string): void { const [ip, portStr, serverIp, serverPortStr] = args.split(' '); const port = parseInt(portStr, 10); const serverPort = parseInt(serverPortStr, 10); if (!ip) { this.debug('Got a C without a valid IP. Ignoring'); return; } if (this.clients.has(id)) { this.debug(`ERROR: Found existing entry for client ${id} (ip=${ip}). Exiting..`); process.exit(1); } this.debug(`Adding new entry for client ${id} (ip=${ip})`); const client: ClientState = { id, ip, port, serverIp, serverPort, whitelist: false, block: false, marks: new Map(), hurry: false, lookups: new Map(), hits: new Map(), }; this.clients.set(id, client); this.startDNSBLLookups(client); } /** * Start DNSBL lookups for a client */ private async startDNSBLLookups(client: ClientState): Promise { if (!isIPv4(client.ip)) { this.debug(`Unknown IP format: ${client.ip}, probably IPv6... skipping DNSBL`); // IPv6 clients skip DNSBL but still need to be processed // They'll be accepted when Hurry arrives since they have no pending lookups return; } const reversedIp = reverseIPv4(client.ip); if (!reversedIp) return; // Start all lookups concurrently const lookupPromises = this.config.dnsbls.map(async (dnsbl) => { const query = `${reversedIp}.${dnsbl.server}`; client.lookups.set(dnsbl.cfgNum, true); // Mark as pending this.debug(`Looking up client ${client.id}: ${query}`); // Check cache const cacheTime = dnsbl.cacheTime ?? this.config.cacheTime; const cached = getCacheEntry(query, cacheTime); let results: string[]; if (cached && cached.result !== null) { this.debug(`Found dnsbl cache entry for ${query}`); results = cached.result; } else if (cached) { // Cache entry exists but result is null (pending) this.debug(`Cache pending... on ${query}`); // Wait a bit and retry from cache await new Promise((resolve) => setTimeout(resolve, 100)); const retryCache = getCacheEntry(query, cacheTime); results = retryCache?.result ?? []; } else { // Start new lookup this.debug(`Starting DNS lookup for ${query}`); setCacheEntry(query, null); // Mark as pending results = await lookupDNSBL(reversedIp, dnsbl.server, this.config.dnsTimeout); setCacheEntry(query, results); } // Process results this.handleDNSBLResponse(client, dnsbl, results); // Mark lookup as complete client.lookups.set(dnsbl.cfgNum, false); // Check if we should process the client now this.handleClientUpdate(client); }); // Don't await - let lookups happen in background Promise.all(lookupPromises).catch((err) => { this.debug(`DNSBL lookup error: ${err}`); }); } /** * Handle a DNSBL response */ private handleDNSBLResponse(client: ClientState, dnsbl: DNSBLConfig, results: string[]): void { if (results.length === 0) return; const matched = matchesDNSBL(results, dnsbl); if (!matched) return; this.debug(`client ${client.id} matches ${dnsbl.server} result ${results.join(',')}`); // Apply whitelist if (dnsbl.whitelist) { client.whitelist = true; } // Apply block if (dnsbl.block) { client.block = dnsbl.block; } // Apply class if (dnsbl.class) { client.class = dnsbl.class; } // Apply mark if (dnsbl.mark) { client.marks.set(dnsbl.mark, dnsbl); } // Record hit client.hits.set(dnsbl.cfgNum, true); } /** * Check if client processing is complete */ private handleClientUpdate(client: ClientState): void { // Count pending lookups let pending = 0; for (const isPending of client.lookups.values()) { if (isPending) pending++; } if (client.hurry) { this.debug(`Client ${client.id} has Hurry set and ${pending} pending requests`); if (pending === 0) { // Update counters for hits for (const cfgNum of client.hits.keys()) { const current = this.dnsblCounters.get(cfgNum) || 0; this.dnsblCounters.set(cfgNum, current + 1); } client.hits.clear(); // Make decision if (client.whitelist) { this.clientPass(client); } else if ( client.block === 'all' || (client.block === 'anonymous' && !client.account) ) { this.clientReject(client, this.config.blockMsg); } else { this.clientPass(client); } } } else { this.debug(`Client ${client.id} has ${pending} pending requests`); } } /** * Handle Hurry message */ private handleHurry(id: number, classArg: string): void { const client = this.clients.get(id); if (!client) { this.debug('ERROR: Got a hurry for a client we aren\'t even holding on to!'); return; } this.debug(`Handling a hurry on ${id}`); client.hurry = true; this.handleClientUpdate(client); } /** * Handle authentication (R message) */ private handleAuth(id: number, account: string): void { const client = this.clients.get(id); if (!client) return; this.debug(`Client ${id} authed as ${account}`); client.account = account; this.handleClientUpdate(client); } /** * Handle trusted WEBIRC */ private handleWebIRC(id: number, args: string): void { const parts = args.split(' '); const [password, username, hostname, newIp] = parts; this.debug(`Got a w line: ${id} - pass=, user=${username}, host=${hostname}, ip=${newIp}`); const client = this.clients.get(id); if (!client) { this.debug('Got a webirc for a client we don\'t know about? Ignored.'); return; } // Save state const wasHurry = client.hurry; // Delete and recreate with new IP this.clients.delete(id); const newClient: ClientState = { id, ip: newIp, port: client.port, serverIp: client.serverIp, serverPort: client.serverPort, whitelist: false, block: false, marks: new Map(), hurry: wasHurry, lookups: new Map(), hits: new Map(), }; this.clients.set(id, newClient); this.startDNSBLLookups(newClient); } /** * Accept a client */ private clientPass(client: ClientState): void { this.debug(`Passing client ${client.id} (${client.ip})`); // Send marks for (const mark of client.marks.keys()) { this.sendMark(client, 'MARK', mark); } // Send done this.sendDone(client); this.countPass++; this.deleteClient(client.id); this.sendStats(); } /** * Reject a client */ private clientReject(client: ClientState, reason: string): void { this.debug(`Rejecting client ${client.id} (${client.ip}): ${reason}`); this.sendKill(client, reason); this.countReject++; this.deleteClient(client.id); this.sendStats(); } /** * Delete a client from tracking */ private deleteClient(id: number): void { this.debug('Deleting client from hash tables'); this.clients.delete(id); } /** * Send mark message */ private sendMark(client: ClientState, markType: string, markData: string): void { if (!markData) return; this.send(`m ${client.id} ${client.ip} ${client.port} ${markType} ${markData}`); } /** * Send done message */ private sendDone(client: ClientState): void { if (client.class) { this.send(`D ${client.id} ${client.ip} ${client.port} ${client.class}`); } else { this.send(`D ${client.id} ${client.ip} ${client.port}`); } } /** * Send kill message */ private sendKill(client: ClientState, reason: string): void { this.send(`k ${client.id} ${client.ip} ${client.port} :${reason}`); } // ==================== SASL Authentication ==================== /** * Handle SASL authentication start (A message from IRCd) * Format from IRCd: "A S :" or "A S :" * Or for host info: "A H :" * After handleLine parsing, args = "S :PLAIN" or "H :user@host:ip" */ private handleSASLStart(id: number, args: string): void { if (!this.saslEnabled()) { this.debug(`SASL not enabled, ignoring A message for ${id}`); this.sendSASLFail(id); return; } // Get or create client state let client = this.clients.get(id); if (!client) { // Create minimal client state for SASL-only handling // We don't have IP/port from this message, use placeholders client = { id, ip: '0.0.0.0', port: 0, serverIp: '', serverPort: 0, whitelist: false, block: false, marks: new Map(), hurry: false, lookups: new Map(), hits: new Map(), sasl: { started: false }, }; this.clients.set(id, client); } if (!client.sasl) { client.sasl = { started: false }; } // Parse: first character is type (S or H), rest is data // Format: "S :PLAIN" or "S PLAIN :certfp" or "H :user@host:ip" const spaceIdx = args.indexOf(' '); if (spaceIdx === -1) { this.debug(`Invalid SASL A message format: ${args}`); return; } const msgType = args.substring(0, spaceIdx); const rest = args.substring(spaceIdx + 1); // Check if this is host info (H) or mechanism start (S) if (msgType === 'H') { // Host info: H : const colonIdx = rest.indexOf(':'); if (colonIdx !== -1) { client.sasl.hostInfo = rest.substring(colonIdx + 1); this.debug(`SASL host info for ${id}: ${client.sasl.hostInfo}`); } return; } if (msgType === 'S') { // Mechanism start: S : or S : let mechanism: string; let certfp: string | undefined; if (rest.startsWith(':')) { // Format: S :PLAIN mechanism = rest.substring(1).trim(); } else { // Format: S PLAIN :certfp const colonIdx = rest.indexOf(':'); if (colonIdx !== -1) { mechanism = rest.substring(0, colonIdx).trim(); certfp = rest.substring(colonIdx + 1).trim(); } else { mechanism = rest.trim(); } } this.debug(`SASL start for ${id}: mechanism=${mechanism}, certfp=${certfp || 'none'}`); client.sasl.mechanism = mechanism; client.sasl.certfp = certfp; client.sasl.started = true; // Check if mechanism is supported const supported = getSupportedMechanisms(); if (!supported.includes(mechanism.toUpperCase())) { this.debug(`Unsupported SASL mechanism: ${mechanism}`); this.sendSASLMechs(id, supported); return; } // For PLAIN mechanism, send empty challenge to request credentials if (mechanism.toUpperCase() === 'PLAIN') { this.sendSASLChallenge(id, '+'); } return; } this.debug(`Unknown SASL A message type: ${msgType}`); } /** * Handle SASL authentication continuation (a message from IRCd) * Format from IRCd: "a :" * After handleLine parsing, args = ":" */ private handleSASLContinue(id: number, args: string): void { if (!this.saslEnabled()) { this.sendSASLFail(id); return; } const client = this.clients.get(id); if (!client || !client.sasl) { this.debug(`SASL continue for unknown client ${id}`); this.sendSASLFail(id); return; } // Parse: : const colonIdx = args.indexOf(':'); if (colonIdx === -1) { this.debug(`Invalid SASL a message format: ${args}`); this.sendSASLFail(id); return; } const data = args.substring(colonIdx + 1); if (client.sasl.mechanism?.toUpperCase() === 'PLAIN') { this.handleSASLPlain(client, data).catch(err => { this.debug(`SASL PLAIN auth error: ${err}`); this.sendSASLFail(id); this.countSaslFail++; }); } else { this.debug(`Unhandled SASL mechanism: ${client.sasl.mechanism}`); this.sendSASLFail(id); } } /** * Handle SASL PLAIN authentication */ private async handleSASLPlain(client: ClientState, base64Data: string): Promise { const decoded = decodeSASLPlain(base64Data); if (!decoded) { this.debug(`Failed to decode SASL PLAIN data for ${client.id}`); this.sendSASLFail(client.id); this.countSaslFail++; return; } this.debug(`SASL PLAIN auth attempt for ${client.id}: authcid=${decoded.authcid}`); const result = await this.authManager!.authenticate( decoded.authcid, decoded.password, decoded.authzid ); if (result.success && result.account) { this.debug(`SASL PLAIN auth success for ${client.id}: account=${result.account}`); client.account = result.account; this.sendSASLSuccess(client.id, result.account); this.countSaslSuccess++; } else { this.debug(`SASL PLAIN auth failed for ${client.id}: ${result.error || 'unknown'}`); this.sendSASLFail(client.id); this.countSaslFail++; } } /** * Send SASL challenge to client * Format: c : */ private sendSASLChallenge(id: number, challenge: string): void { const client = this.clients.get(id); if (client) { this.send(`c ${id} ${client.ip} ${client.port} :${challenge}`); } } /** * Send SASL login success * Format: L */ private sendSASLSuccess(id: number, account: string): void { const client = this.clients.get(id); if (client) { this.send(`L ${id} ${client.ip} ${client.port} ${account}`); this.send(`Z ${id} ${client.ip} ${client.port}`); } } /** * Send SASL authentication failed * Format: f */ private sendSASLFail(id: number): void { const client = this.clients.get(id); if (client) { this.send(`f ${id} ${client.ip} ${client.port}`); } else { // Client may not exist yet, send with dummy values this.send(`f ${id} 0.0.0.0 0`); } } /** * Send SASL available mechanisms * Format: l : */ private sendSASLMechs(id: number, mechanisms: string[]): void { const client = this.clients.get(id); if (client) { this.send(`l ${id} ${client.ip} ${client.port} :${mechanisms.join(',')}`); } } }