Initial commit baby!
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const key = process.env.ENCRYPTION_KEY || 'defaultkey32byteslongencryptkey!';
|
||||
// Ensure exactly 32 bytes via SHA-256 hash if needed
|
||||
if (key.length === 32) {
|
||||
return Buffer.from(key, 'utf8');
|
||||
}
|
||||
return crypto.createHash('sha256').update(key).digest();
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv:tag:encrypted (all hex)
|
||||
return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':');
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const key = getKey();
|
||||
const parts = encryptedText.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted text format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const tag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = Buffer.from(parts[2], 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Local IEEE OUI database lookup.
|
||||
* Downloads the official IEEE MA-L CSV on first use, caches it for 30 days,
|
||||
* then serves all vendor lookups from memory — no rate limits, no external calls
|
||||
* per client.
|
||||
*/
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const CACHE_FILE = '/tmp/oui-ieee.json';
|
||||
const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const IEEE_CSV_URL = 'https://standards-oui.ieee.org/oui/oui.csv';
|
||||
|
||||
let db: Map<string, string> | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
export function initOuiDatabase(): Promise<void> {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = _load();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function lookupVendor(mac: string): string {
|
||||
if (!db) return '';
|
||||
const oui = mac.replace(/[:\-.]/g, '').substring(0, 6).toUpperCase();
|
||||
return db.get(oui) ?? '';
|
||||
}
|
||||
|
||||
async function _load(): Promise<void> {
|
||||
// Try valid cache first
|
||||
try {
|
||||
const stat = fs.statSync(CACHE_FILE);
|
||||
if (Date.now() - stat.mtimeMs < CACHE_MAX_AGE_MS) {
|
||||
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as Record<string, string>;
|
||||
const map = new Map(Object.entries(raw));
|
||||
if (map.size > 10_000) {
|
||||
db = map;
|
||||
console.log(`OUI database loaded from cache: ${db.size} entries`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* cache miss */ }
|
||||
|
||||
// Download fresh from IEEE
|
||||
try {
|
||||
console.log('Downloading IEEE OUI database…');
|
||||
const csv = await _fetch(IEEE_CSV_URL);
|
||||
const map = _parseCsv(csv);
|
||||
if (map.size > 10_000) {
|
||||
db = map;
|
||||
console.log(`OUI database downloaded: ${db.size} entries`);
|
||||
// Persist cache
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of map) obj[k] = v;
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj));
|
||||
} else {
|
||||
console.warn('OUI: Downloaded file too small, ignoring');
|
||||
db = new Map();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`OUI: Download failed (${(err as Error).message}). Vendor lookups will be empty.`);
|
||||
db = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function _parseCsv(csv: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of csv.split('\n')) {
|
||||
// IEEE CSV: Registry,Assignment,Organization Name,Organization Address
|
||||
// Quoted fields may contain commas, e.g. "Cisco Systems, Inc"
|
||||
const fields = _csvSplit(line);
|
||||
if (fields[0]?.trim() !== 'MA-L') continue;
|
||||
const oui = fields[1]?.trim().toUpperCase();
|
||||
const name = fields[2]?.trim();
|
||||
if (oui?.length === 6 && name) map.set(oui, name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function _csvSplit(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let cur = '';
|
||||
let inQuote = false;
|
||||
for (const ch of line) {
|
||||
if (ch === '"') { inQuote = !inQuote; }
|
||||
else if (ch === ',' && !inQuote) { fields.push(cur); cur = ''; }
|
||||
else { cur += ch; }
|
||||
}
|
||||
fields.push(cur);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function _fetch(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: 60_000 }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Read the backend server's kernel ARP cache from /proc/net/arp.
|
||||
* Returns a MAC → IPv4 map of all currently cached entries.
|
||||
* Works on Linux; on macOS Docker Desktop the container is NATted so
|
||||
* this will only show Docker-internal bridge entries.
|
||||
*/
|
||||
async function readKernelArpCache(): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {};
|
||||
try {
|
||||
const content = await readFile('/proc/net/arp', 'utf8');
|
||||
for (const line of content.split('\n').slice(1)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
// Format: IP HW-type Flags HW-address Mask Device
|
||||
if (parts.length >= 4) {
|
||||
const ip = parts[0];
|
||||
const mac = parts[3].toLowerCase();
|
||||
if (ip && mac && mac !== '00:00:00:00:00:00' && ip !== '0.0.0.0') {
|
||||
map[mac] = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /proc/net/arp not available (non-Linux or permission denied)
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run arp-scan on all local network interfaces to actively discover
|
||||
* MAC → IPv4 mappings on the same subnet as the backend container.
|
||||
*
|
||||
* Requires: arp-scan installed + NET_RAW capability (set via docker-compose
|
||||
* cap_add / setcap on the binary). Fails gracefully if unavailable.
|
||||
*
|
||||
* Note: On macOS Docker Desktop the container is NATted inside a Linux VM,
|
||||
* so arp-scan will only see other Docker containers, NOT physical network hosts.
|
||||
* On a Linux host with bridge or macvlan Docker networking this WILL find
|
||||
* devices on the same physical subnet.
|
||||
*/
|
||||
async function runArpScan(): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {};
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'arp-scan',
|
||||
['--localnet', '--quiet', '--retry=2'],
|
||||
{ timeout: 12000 }
|
||||
);
|
||||
for (const line of stdout.split('\n')) {
|
||||
// Output lines: "<IP>\t<MAC>\t<description>"
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2 && /^\d+\.\d+\.\d+\.\d+$/.test(parts[0])) {
|
||||
const ip = parts[0];
|
||||
const mac = parts[1].toLowerCase();
|
||||
if (ip && mac) map[mac] = ip;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// arp-scan not installed, lacks permissions, or timed out
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combined MAC → IPv4 map using the server's own ARP data.
|
||||
* Called once per collectNeighbors() run to avoid re-scanning per neighbour.
|
||||
*/
|
||||
export async function buildServerArpMap(): Promise<Record<string, string>> {
|
||||
const [passive, active] = await Promise.all([
|
||||
readKernelArpCache(),
|
||||
runArpScan(),
|
||||
]);
|
||||
// Passive cache takes precedence over scan (it's live kernel state)
|
||||
return { ...active, ...passive };
|
||||
}
|
||||
Reference in New Issue
Block a user