first commit

This commit is contained in:
Mathieu BOURBON
2026-04-18 16:24:44 +02:00
commit fbb6138c28
72 changed files with 3509 additions and 0 deletions

View 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);
}

View 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()];
},
};

View 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;
}

View 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];
}