Initial commit baby!

This commit is contained in:
Rich Teslow
2026-04-13 18:15:41 -07:00
parent 10e6227931
commit 6b0fc9fdf1
99 changed files with 34064 additions and 3 deletions
+44
View File
@@ -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');
}
+108
View File
@@ -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')); });
});
}
+80
View File
@@ -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 };
}