first commit
This commit is contained in:
61
src/modules/discovery/scanners/arp.scanner.ts
Normal file
61
src/modules/discovery/scanners/arp.scanner.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { env } from '@/config/env';
|
||||
import type { DiscoveryResult, DiscoveryTarget, Scanner } from '../types';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Lit la table ARP locale via `ip neigh` (Linux) ou `arp -a` (fallback).
|
||||
* Ne scanne pas activement : retourne ce que le kernel a déjà vu.
|
||||
* Pour forcer la résolution, il faut ping-sweeper d'abord (voir discovery.service).
|
||||
*/
|
||||
export const arpScanner: Scanner = {
|
||||
kind: 'arp',
|
||||
label: 'Table ARP locale',
|
||||
enabled: env.DISCOVERY_ENABLE_ARP,
|
||||
|
||||
async run(_target: DiscoveryTarget): Promise<DiscoveryResult[]> {
|
||||
const { stdout } = await execAsync('ip neigh show').catch(() => ({ stdout: '' }));
|
||||
if (stdout) return parseIpNeigh(stdout);
|
||||
|
||||
const { stdout: arpOut } = await execAsync('arp -a').catch(() => ({ stdout: '' }));
|
||||
return parseArpA(arpOut);
|
||||
},
|
||||
};
|
||||
|
||||
function parseIpNeigh(output: string): DiscoveryResult[] {
|
||||
// Exemple : 192.168.1.10 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const ipMatch = line.match(/^(\d{1,3}(?:\.\d{1,3}){3})/);
|
||||
const macMatch = line.match(/lladdr\s+([0-9a-fA-F:]{17})/);
|
||||
if (!ipMatch) return null;
|
||||
return {
|
||||
ipAddress: ipMatch[1],
|
||||
macAddress: macMatch?.[1],
|
||||
} satisfies DiscoveryResult;
|
||||
})
|
||||
.filter((r): r is DiscoveryResult => r !== null);
|
||||
}
|
||||
|
||||
function parseArpA(output: string): DiscoveryResult[] {
|
||||
// Exemple : ? (192.168.1.10) at aa:bb:cc:dd:ee:ff [ether] on eth0
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const ipMatch = line.match(/\((\d{1,3}(?:\.\d{1,3}){3})\)/);
|
||||
const macMatch = line.match(/([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})/);
|
||||
if (!ipMatch) return null;
|
||||
return {
|
||||
ipAddress: ipMatch[1],
|
||||
macAddress: macMatch?.[1],
|
||||
} satisfies DiscoveryResult;
|
||||
})
|
||||
.filter((r): r is DiscoveryResult => r !== null);
|
||||
}
|
||||
40
src/modules/discovery/scanners/mdns.scanner.ts
Normal file
40
src/modules/discovery/scanners/mdns.scanner.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Bonjour } from 'bonjour-service';
|
||||
import { env } from '@/config/env';
|
||||
import type { DiscoveryResult, DiscoveryTarget, Scanner } from '../types';
|
||||
|
||||
/**
|
||||
* Découverte via mDNS / DNS-SD (_services._dns-sd._udp.local).
|
||||
* Écoute pendant `timeout` ms et agrège les annonces reçues.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 4000;
|
||||
|
||||
export const mdnsScanner: Scanner = {
|
||||
kind: 'mdns',
|
||||
label: 'mDNS / Bonjour',
|
||||
enabled: env.DISCOVERY_ENABLE_MDNS,
|
||||
|
||||
async run(_target: DiscoveryTarget): Promise<DiscoveryResult[]> {
|
||||
const bonjour = new Bonjour();
|
||||
const byIp = new Map<string, DiscoveryResult>();
|
||||
|
||||
const browser = bonjour.find({});
|
||||
browser.on('up', (svc) => {
|
||||
const ips = (svc.addresses ?? []).filter((a) => a && !a.includes(':')); // IPv4
|
||||
for (const ip of ips) {
|
||||
const existing = byIp.get(ip) ?? { ipAddress: ip, hostname: svc.host, services: [] };
|
||||
existing.services!.push({
|
||||
name: svc.name,
|
||||
type: `_${svc.type}._${svc.protocol}.local`,
|
||||
port: svc.port,
|
||||
});
|
||||
byIp.set(ip, existing);
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, DEFAULT_TIMEOUT_MS));
|
||||
browser.stop();
|
||||
bonjour.destroy();
|
||||
|
||||
return [...byIp.values()];
|
||||
},
|
||||
};
|
||||
49
src/modules/discovery/scanners/ping.scanner.ts
Normal file
49
src/modules/discovery/scanners/ping.scanner.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Netmask } from 'netmask';
|
||||
import pingLib from 'ping';
|
||||
import { env } from '@/config/env';
|
||||
import type { DiscoveryResult, DiscoveryTarget, ProgressCallback, Scanner } from '../types';
|
||||
import { runWithConcurrency } from '../utils/concurrency';
|
||||
|
||||
/**
|
||||
* Scanner ICMP (ping sweep).
|
||||
* Ne requiert PAS de privilèges si la lib fallback utilise l'exécutable système `ping`.
|
||||
* Dans Docker : bien ajouter `cap_add: [NET_RAW]` (voir docker-compose).
|
||||
*/
|
||||
export const pingScanner: Scanner = {
|
||||
kind: 'ping',
|
||||
label: 'Ping sweep (ICMP)',
|
||||
enabled: env.DISCOVERY_ENABLE_PING,
|
||||
|
||||
async run(target: DiscoveryTarget, onProgress?: ProgressCallback): Promise<DiscoveryResult[]> {
|
||||
if (!target.cidr && !target.ip) return [];
|
||||
|
||||
const ips: string[] = target.ip
|
||||
? [target.ip]
|
||||
: [...expandCidr(target.cidr!)];
|
||||
|
||||
let done = 0;
|
||||
const results: DiscoveryResult[] = [];
|
||||
|
||||
await runWithConcurrency(ips, env.DISCOVERY_CONCURRENCY, async (ip) => {
|
||||
const res = await pingLib.promise.probe(ip, {
|
||||
timeout: Math.ceil(env.DISCOVERY_PING_TIMEOUT / 1000),
|
||||
extra: ['-c', '1'],
|
||||
});
|
||||
if (res.alive) {
|
||||
results.push({ ipAddress: ip, hostname: res.host ?? undefined });
|
||||
}
|
||||
done += 1;
|
||||
onProgress?.({ total: ips.length, done, currentTarget: ip });
|
||||
});
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
|
||||
function* expandCidr(cidr: string): IterableIterator<string> {
|
||||
const block = new Netmask(cidr);
|
||||
// forEach inclut la gateway, exclut broadcast/network → parfait pour un ping sweep
|
||||
const ips: string[] = [];
|
||||
block.forEach((ip) => ips.push(ip));
|
||||
yield* ips;
|
||||
}
|
||||
75
src/modules/discovery/scanners/port.scanner.ts
Normal file
75
src/modules/discovery/scanners/port.scanner.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import net from 'node:net';
|
||||
import { env } from '@/config/env';
|
||||
import type { DiscoveryResult, DiscoveryTarget, ProgressCallback, Scanner } from '../types';
|
||||
import { runWithConcurrency } from '../utils/concurrency';
|
||||
|
||||
/**
|
||||
* Scanner de ports TCP simple (connect scan).
|
||||
* Pour UDP ou SYN-scan plus avancés, on pourra brancher nmap via child_process
|
||||
* dans une version ultérieure sans changer l'interface du scanner.
|
||||
*/
|
||||
export const portScanner: Scanner = {
|
||||
kind: 'port',
|
||||
label: 'Scan de ports TCP',
|
||||
enabled: env.DISCOVERY_ENABLE_PORT_SCAN,
|
||||
|
||||
async run(target: DiscoveryTarget, onProgress?: ProgressCallback): Promise<DiscoveryResult[]> {
|
||||
if (!target.ip) return [];
|
||||
|
||||
const ports =
|
||||
target.ports?.length
|
||||
? target.ports
|
||||
: env.DISCOVERY_DEFAULT_PORTS.split(',').map((p) => Number(p.trim())).filter(Boolean);
|
||||
|
||||
let done = 0;
|
||||
const open: DiscoveryResult['openPorts'] = [];
|
||||
|
||||
await runWithConcurrency(ports, env.DISCOVERY_CONCURRENCY, async (port) => {
|
||||
if (await isPortOpen(target.ip!, port, env.DISCOVERY_PORT_TIMEOUT)) {
|
||||
open.push({ number: port, protocol: 'TCP', service: guessService(port) });
|
||||
}
|
||||
done += 1;
|
||||
onProgress?.({ total: ports.length, done, currentTarget: `${target.ip}:${port}` });
|
||||
});
|
||||
|
||||
return open.length ? [{ ipAddress: target.ip, openPorts: open }] : [];
|
||||
},
|
||||
};
|
||||
|
||||
function isPortOpen(host: string, port: number, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const onDone = (ok: boolean) => {
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once('connect', () => onDone(true));
|
||||
socket.once('timeout', () => onDone(false));
|
||||
socket.once('error', () => onDone(false));
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
// Table extensible — à enrichir ou remplacer par une lookup via `nmap-services`.
|
||||
const COMMON_SERVICES: Record<number, string> = {
|
||||
22: 'ssh',
|
||||
53: 'dns',
|
||||
80: 'http',
|
||||
81: 'http-alt',
|
||||
443: 'https',
|
||||
445: 'smb',
|
||||
3000: 'nodejs',
|
||||
3306: 'mysql',
|
||||
5432: 'postgres',
|
||||
6379: 'redis',
|
||||
8080: 'http-alt',
|
||||
8096: 'jellyfin',
|
||||
8123: 'homeassistant',
|
||||
8443: 'https-alt',
|
||||
32400: 'plex',
|
||||
};
|
||||
|
||||
function guessService(port: number) {
|
||||
return COMMON_SERVICES[port];
|
||||
}
|
||||
Reference in New Issue
Block a user