ircu2/tools/iauthd-ts/tests/dnsbl-integration.test.ts

642 lines
20 KiB
TypeScript

/**
* DNSBL Integration Tests with Mocked DNS
*
* Tests the full pipeline: client connect → DNSBL lookup → decision
* Uses mock DNS responses to test all matching modes and edge cases.
*
* Based on real-world AfterNET production config.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { spawn, ChildProcess } from 'node:child_process';
import { writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createInterface } from 'node:readline';
/**
* Mock DNS responses for testing
* Maps reversed-IP.dnsbl-server to response IP(s)
*/
const mockDnsResponses: Map<string, string[]> = new Map();
/**
* Set up a mock DNS response
*/
function mockDns(clientIp: string, dnsblServer: string, responses: string[]): void {
const reversed = clientIp.split('.').reverse().join('.');
const query = `${reversed}.${dnsblServer}`;
mockDnsResponses.set(query, responses);
}
/**
* Clear all mock DNS responses
*/
function clearMockDns(): void {
mockDnsResponses.clear();
}
interface TestHarness {
proc: ChildProcess;
output: string[];
send: (line: string) => void;
waitFor: (pattern: RegExp, timeout?: number) => Promise<string>;
getMarks: (clientId: number) => string[];
wasAccepted: (clientId: number) => boolean;
wasRejected: (clientId: number) => boolean;
close: () => Promise<void>;
}
/**
* Create a test harness with mock DNS injection
*/
async function createHarness(configPath: string): Promise<TestHarness> {
const output: string[] = [];
// Create a wrapper script that injects our mock DNS module
const wrapperPath = join(tmpdir(), `dns-mock-${Date.now()}.mjs`);
const mockResponses = JSON.stringify(Array.from(mockDnsResponses.entries()));
writeFileSync(wrapperPath, `
import { promises as dns } from 'node:dns';
// Mock DNS responses
const mockResponses = new Map(${mockResponses});
// Override dns.resolve4
const originalResolve4 = dns.resolve4.bind(dns);
dns.resolve4 = async (hostname) => {
const mock = mockResponses.get(hostname);
if (mock !== undefined) {
if (mock.length === 0) {
const err = new Error('NXDOMAIN');
err.code = 'ENOTFOUND';
throw err;
}
return mock;
}
// For non-mocked queries, return NXDOMAIN (not listed)
const err = new Error('NXDOMAIN');
err.code = 'ENOTFOUND';
throw err;
};
// Now import and run the actual daemon
import('${join(import.meta.dirname, '..', 'src', 'index.ts').replace(/\\/g, '/')}');
`);
const proc = spawn('npx', ['tsx', wrapperPath, '-v', '-c', configPath], {
cwd: join(import.meta.dirname, '..'),
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
const rl = createInterface({ input: proc.stdout! });
rl.on('line', (line) => output.push(line));
proc.stderr?.on('data', (data) => {
// Uncomment for debugging:
// console.error('stderr:', data.toString());
});
const send = (line: string) => {
proc.stdin!.write(line + '\n');
};
const waitFor = (pattern: RegExp, timeout = 5000): Promise<string> => {
return new Promise((resolve, reject) => {
// Check existing output
for (const line of output) {
if (pattern.test(line)) {
resolve(line);
return;
}
}
const startLen = output.length;
const interval = setInterval(() => {
for (let i = startLen; i < output.length; i++) {
if (pattern.test(output[i])) {
clearInterval(interval);
clearTimeout(timer);
resolve(output[i]);
return;
}
}
}, 10);
const timer = setTimeout(() => {
clearInterval(interval);
reject(new Error(`Timeout waiting for: ${pattern}\nOutput was:\n${output.join('\n')}`));
}, timeout);
});
};
const getMarks = (clientId: number): string[] => {
const marks: string[] = [];
const pattern = new RegExp(`^m ${clientId} .* MARK (\\S+)`);
for (const line of output) {
const match = line.match(pattern);
if (match) {
marks.push(match[1]);
}
}
return marks;
};
const wasAccepted = (clientId: number): boolean => {
return output.some(line => line.startsWith(`D ${clientId} `));
};
const wasRejected = (clientId: number): boolean => {
return output.some(line => line.startsWith(`k ${clientId} `));
};
const close = async (): Promise<void> => {
try { unlinkSync(wrapperPath); } catch {}
return new Promise((resolve) => {
proc.on('close', () => resolve());
proc.stdin!.end();
setTimeout(() => {
proc.kill('SIGTERM');
resolve();
}, 1000);
});
};
// Wait for startup
await waitFor(/^S iauthd-ts/);
await new Promise(r => setTimeout(r, 100));
return { proc, output, send, waitFor, getMarks, wasAccepted, wasRejected, close };
}
/**
* Simulate a full client connection flow
*/
async function connectClient(
harness: TestHarness,
clientId: number,
ip: string,
options: { authenticated?: string } = {}
): Promise<void> {
harness.send(`${clientId} C ${ip} ${10000 + clientId} 10.0.0.1 6667`);
if (options.authenticated) {
harness.send(`${clientId} R ${options.authenticated}`);
}
// Small delay to let lookups start
await new Promise(r => setTimeout(r, 50));
harness.send(`${clientId} H Users`);
// Wait for decision
await harness.waitFor(new RegExp(`^[Dk] ${clientId} `), 3000);
}
describe('DNSBL Integration Tests', () => {
let tempDir: string;
let configPath: string;
let harness: TestHarness;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'iauthd-dnsbl-test-'));
configPath = join(tempDir, 'test.conf');
clearMockDns();
});
afterEach(async () => {
await harness?.close();
try { unlinkSync(configPath); } catch {}
});
describe('Index Matching', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=test.dnsbl index=2,3,4 mark=testbl block=all
`);
});
it('should block when index matches exactly', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasRejected(0)).toBe(true);
expect(harness.wasAccepted(0)).toBe(false);
});
it('should block when any configured index matches', async () => {
mockDns('192.168.1.101', 'test.dnsbl', ['127.0.0.4']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.101');
expect(harness.wasRejected(0)).toBe(true);
});
it('should pass when index does not match', async () => {
mockDns('192.168.1.102', 'test.dnsbl', ['127.0.0.5']); // index=5 not in 2,3,4
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.102');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.wasRejected(0)).toBe(false);
});
it('should pass when not listed (NXDOMAIN)', async () => {
// No mock = NXDOMAIN
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.103');
expect(harness.wasAccepted(0)).toBe(true);
});
});
describe('Bitmask Matching', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=test.dnsbl bitmask=2,4 mark=testbl block=all
`);
});
it('should block when bitmask matches (bit 1 set)', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']); // 2 = 0010
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasRejected(0)).toBe(true);
});
it('should block when bitmask matches (bit 2 set)', async () => {
mockDns('192.168.1.101', 'test.dnsbl', ['127.0.0.4']); // 4 = 0100
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.101');
expect(harness.wasRejected(0)).toBe(true);
});
it('should block when multiple bits match', async () => {
mockDns('192.168.1.102', 'test.dnsbl', ['127.0.0.6']); // 6 = 0110 (bits 1 and 2)
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.102');
expect(harness.wasRejected(0)).toBe(true);
});
it('should pass when no bitmask bits match', async () => {
mockDns('192.168.1.103', 'test.dnsbl', ['127.0.0.1']); // 1 = 0001 (bit 0 only)
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.103');
expect(harness.wasAccepted(0)).toBe(true);
});
});
describe('Block Modes', () => {
describe('block=all', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=test.dnsbl index=2 mark=testbl block=all
`);
});
it('should block unauthenticated users', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasRejected(0)).toBe(true);
});
it('should block authenticated users too', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100', { authenticated: 'testuser' });
expect(harness.wasRejected(0)).toBe(true);
});
});
describe('block=anonymous', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH BLOCKMSG You must authenticate to connect.
#IAUTH DNSBL server=test.dnsbl index=2 mark=testbl block=anonymous
`);
});
it('should block unauthenticated users', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasRejected(0)).toBe(true);
});
it('should allow SASL-authenticated users', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100', { authenticated: 'gooduser' });
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.wasRejected(0)).toBe(false);
});
it('should mark authenticated users even though not blocked', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100', { authenticated: 'gooduser' });
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('testbl');
});
});
describe('mark only (no block)', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=test.dnsbl index=2 mark=infomark
`);
});
it('should accept client but apply mark', async () => {
mockDns('192.168.1.100', 'test.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('infomark');
});
});
});
describe('Whitelist', () => {
beforeEach(async () => {
// Whitelist first, then blocking DNSBLs - mimics AfterNET config
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=whitelist.dnsbl index=2 mark=whitelist whitelist
#IAUTH DNSBL server=blocklist.dnsbl index=2 mark=blocked block=all
`);
});
it('should not block whitelisted clients even if they match blocking DNSBL', async () => {
mockDns('192.168.1.100', 'whitelist.dnsbl', ['127.0.0.2']); // Whitelisted
mockDns('192.168.1.100', 'blocklist.dnsbl', ['127.0.0.2']); // Also on blocklist
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.wasRejected(0)).toBe(false);
});
it('should block non-whitelisted clients that match blocking DNSBL', async () => {
// Not on whitelist, but on blocklist
mockDns('192.168.1.101', 'blocklist.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.101');
expect(harness.wasRejected(0)).toBe(true);
});
it('should apply whitelist mark', async () => {
mockDns('192.168.1.100', 'whitelist.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.getMarks(0)).toContain('whitelist');
});
});
describe('Multiple DNSBLs', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=dnsbl1.test index=2 mark=list1
#IAUTH DNSBL server=dnsbl2.test index=2 mark=list2
#IAUTH DNSBL server=dnsbl3.test index=2 mark=list3 block=anonymous
`);
});
it('should accumulate marks from multiple DNSBLs', async () => {
mockDns('192.168.1.100', 'dnsbl1.test', ['127.0.0.2']);
mockDns('192.168.1.100', 'dnsbl2.test', ['127.0.0.2']);
// Not on dnsbl3
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasAccepted(0)).toBe(true);
const marks = harness.getMarks(0);
expect(marks).toContain('list1');
expect(marks).toContain('list2');
expect(marks).not.toContain('list3');
});
it('should block if any blocking DNSBL matches', async () => {
mockDns('192.168.1.100', 'dnsbl3.test', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasRejected(0)).toBe(true);
});
});
describe('Same Server, Different Indices (AfterNET pattern)', () => {
beforeEach(async () => {
// Mimics AfterNET's dnsbl.afternet.org with different indices
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=dnsbl.example.org index=2 mark=whitelist whitelist
#IAUTH DNSBL server=dnsbl.example.org index=250 mark=rbl block=anonymous
#IAUTH DNSBL server=dnsbl.example.org index=251 mark=cloud block=anonymous
`);
});
it('should whitelist on index=2', async () => {
mockDns('192.168.1.100', 'dnsbl.example.org', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('whitelist');
});
it('should block anonymous on index=250', async () => {
mockDns('192.168.1.101', 'dnsbl.example.org', ['127.0.0.250']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.101');
expect(harness.wasRejected(0)).toBe(true);
});
it('should block anonymous on index=251', async () => {
mockDns('192.168.1.102', 'dnsbl.example.org', ['127.0.0.251']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.102');
expect(harness.wasRejected(0)).toBe(true);
});
it('should allow authenticated on index=250', async () => {
mockDns('192.168.1.101', 'dnsbl.example.org', ['127.0.0.250']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.101', { authenticated: 'user' });
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('rbl');
});
});
describe('Real-world AfterNET Config Simulation', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH CACHETIMEOUT 21600
#IAUTH DNSTIMEOUT 5
#IAUTH BLOCKMSG Sorry! Your connection has been rejected due to poor reputation.
#IAUTH DNSBL server=dnsbl.afternet.org index=2 mark=whitelist whitelist
#IAUTH DNSBL server=dnsbl.afternet.org index=250 mark=afternetrbl block=anonymous
#IAUTH DNSBL server=dnsbl.afternet.org index=251 mark=cloud block=anonymous
#IAUTH DNSBL server=dnsbl.sorbs.net index=2,3,4,5,6,7,9 mark=sorbs block=anonymous
#IAUTH DNSBL server=dnsbl.dronebl.org index=2,3,5,6,7,8,9,10,13,14,15 mark=dronebl block=anonymous
#IAUTH DNSBL server=rbl.efnetrbl.org index=4 mark=tor block=anonymous
#IAUTH DNSBL server=rbl.efnetrbl.org index=1,2,3,5 mark=efnetrbl block=anonymous
#IAUTH DNSBL server=dnsbl-2.uceprotect.net index=2 mark=uce-2
#IAUTH DNSBL server=cbl.abuseat.org index=2 mark=cbl
`);
});
it('should accept clean clients', async () => {
harness = await createHarness(configPath);
await connectClient(harness, 0, '8.8.8.8');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toHaveLength(0);
});
it('should whitelist AfterNET trusted IPs', async () => {
mockDns('10.0.0.50', 'dnsbl.afternet.org', ['127.0.0.2']);
mockDns('10.0.0.50', 'dnsbl.dronebl.org', ['127.0.0.5']); // Also on dronebl!
harness = await createHarness(configPath);
await connectClient(harness, 0, '10.0.0.50');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('whitelist');
});
it('should block drones unless authenticated', async () => {
mockDns('10.0.0.60', 'dnsbl.dronebl.org', ['127.0.0.5']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '10.0.0.60');
expect(harness.wasRejected(0)).toBe(true);
});
it('should allow authenticated drones', async () => {
mockDns('10.0.0.60', 'dnsbl.dronebl.org', ['127.0.0.5']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '10.0.0.60', { authenticated: 'droneuser' });
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('dronebl');
});
it('should block Tor exit nodes unless authenticated', async () => {
mockDns('10.0.0.70', 'rbl.efnetrbl.org', ['127.0.0.4']); // index=4 = tor
harness = await createHarness(configPath);
await connectClient(harness, 0, '10.0.0.70');
expect(harness.wasRejected(0)).toBe(true);
});
it('should mark UCE-2 but not block', async () => {
mockDns('10.0.0.80', 'dnsbl-2.uceprotect.net', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '10.0.0.80');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('uce-2');
});
it('should mark CBL but not block', async () => {
mockDns('10.0.0.90', 'cbl.abuseat.org', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '10.0.0.90');
expect(harness.wasAccepted(0)).toBe(true);
expect(harness.getMarks(0)).toContain('cbl');
});
it('should accumulate multiple marks', async () => {
mockDns('10.0.0.95', 'dnsbl-2.uceprotect.net', ['127.0.0.2']);
mockDns('10.0.0.95', 'cbl.abuseat.org', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '10.0.0.95');
expect(harness.wasAccepted(0)).toBe(true);
const marks = harness.getMarks(0);
expect(marks).toContain('uce-2');
expect(marks).toContain('cbl');
});
});
describe('Class Assignment', () => {
beforeEach(async () => {
writeFileSync(configPath, `
#IAUTH POLICY RTAWUwFr
#IAUTH DNSBL server=trusted.dnsbl index=2 class=Trusted mark=trusted
#IAUTH DNSBL server=limited.dnsbl index=2 class=Limited mark=limited
`);
});
it('should assign class from matching DNSBL', async () => {
mockDns('192.168.1.100', 'trusted.dnsbl', ['127.0.0.2']);
harness = await createHarness(configPath);
await connectClient(harness, 0, '192.168.1.100');
expect(harness.wasAccepted(0)).toBe(true);
// Check the D message includes the class
const acceptLine = harness.output.find(l => l.startsWith('D 0 '));
expect(acceptLine).toContain('Trusted');
});
});
});