Files
safetynet/scripts/debug-collector.mjs
T

60 lines
6.4 KiB
JavaScript
Raw Normal View History

// Local development receiver. This is not a production SIEM or audit store.
import {createServer} from 'node:http';
import {mkdirSync,existsSync,readFileSync,writeFileSync} from 'node:fs';
import {collectorStorage} from './debug-collector-storage.mjs';
import {normalizeCollectorBatch} from './debug-collector-batch.mjs';
import {collectorAdmission} from './debug-collector-admission.mjs';
import {protectCollectorDirectory} from './debug-collector-acl.mjs';
import {monitorEventLoopDelay} from 'node:perf_hooks';
import {resolve} from 'node:path';
import {spawnSync} from 'node:child_process';
import {randomBytes,createCipheriv} from 'node:crypto';
const port=Number(process.env.DEBUG_COLLECTOR_PORT||4318),directory=resolve(process.env.DEBUG_COLLECTOR_DIRECTORY||'data/observability');
if(process.platform!=='win32')throw Error('Local protected collector currently requires Windows DPAPI');
mkdirSync(directory,{recursive:true});
const storage=collectorStorage(directory,{segmentBytes:Number(process.env.DEBUG_COLLECTOR_SEGMENT_BYTES||50*1024*1024),retiredSegments:Number(process.env.DEBUG_COLLECTOR_RETIRED_SEGMENTS||2)});
protectCollectorDirectory(directory);
function protect(operation,value){const result=spawnSync(resolve('.runtime/windows/python/python.exe'),[resolve('apps/inspector/dpapi.py'),operation],{input:value,encoding:'utf8',windowsHide:true,timeout:5000,env:{SystemRoot:process.env.SystemRoot,USERPROFILE:process.env.USERPROFILE,LOCALAPPDATA:process.env.LOCALAPPDATA,PYTHONDONTWRITEBYTECODE:'1'}});if(result.status!==0)throw Error('Collector DPAPI key protection failed');return result.stdout.trim();}
const keyFile=resolve(directory,'collector-key.dpapi');
let key;if(existsSync(keyFile)){const value=protect('unprotect',readFileSync(keyFile,'utf8'));if(!value.startsWith('observability-v1:'))throw Error('Collector key binding rejected');key=Buffer.from(value.slice(17),'base64');}else{key=randomBytes(32);writeFileSync(keyFile,protect('protect','observability-v1:'+key.toString('base64')),{flag:'wx',mode:0o600});}
if(key.length!==32)throw Error('Invalid collector key');
const admission=collectorAdmission({maxRequests:Number(process.env.DEBUG_COLLECTOR_MAX_REQUESTS||8),maxBufferedBytes:Number(process.env.DEBUG_COLLECTOR_MAX_BUFFERED_BYTES||8*1024*1024)});
const eventLoop=monitorEventLoopDelay({resolution:20});eventLoop.enable();
const lag=()=>({meanMs:Number.isFinite(eventLoop.mean)?eventLoop.mean/1e6:0,maxMs:eventLoop.max/1e6,p99Ms:eventLoop.percentile(99)/1e6});
const counts={traces:0,logs:0,metrics:0},recent=[];let requests=0;
function summary(){return {service:'safetynet-local-debug',protectedStorage:'Windows DPAPI + AES-256-GCM',requests,counts,admission:admission.summary(),eventLoopLag:lag(),...storage.summary(),retainedSpans:recent.length,slowest:recent.slice().sort((a,b)=>b.durationMs-a.durationMs).slice(0,15)};}
const server=createServer((req,res)=>{
res.setHeader('cache-control','no-store');res.setHeader('x-content-type-options','nosniff');
const origin=`http://127.0.0.1:${port}`;
if(req.headers.host!==`127.0.0.1:${port}`||(req.headers.origin&&req.headers.origin!==origin)){res.writeHead(403);res.end();return;}
if(req.method==='GET'&&['/','/health','/api/summary'].includes(req.url)){res.setHeader('content-type','application/json');try{res.end(JSON.stringify(summary(),null,2));}catch{res.writeHead(503);res.end('{"error":"Protected collector storage unavailable"}');}return;}
if(req.method==='GET'&&/^\/api\/trace\/[a-f0-9]{32}$/.test(req.url)){res.setHeader('content-type','application/json');res.end(JSON.stringify({traceId:req.url.slice(11),spans:recent.filter(span=>span.traceId===req.url.slice(11))},null,2));return;}
if(req.method!=='POST'||!['/v1/traces','/v1/logs','/v1/metrics'].includes(req.url)||req.headers['content-encoding']){res.writeHead(404);res.end();return;}
const lease=admission.acquire();
const rejectBusy=()=>{res.setHeader('connection','close');res.setHeader('retry-after','1');res.writeHead(503);res.end('{"error":"Collector busy"}');};
if(!lease){rejectBusy();return;}
const chunks=[];let finished=false;
const release=()=>{lease.release();chunks.length=0;};
const fail=(status,body)=>{if(finished)return;finished=true;release();res.setHeader('connection','close');res.writeHead(status);res.end(body);};
req.once('aborted',()=>{finished=true;release();});req.once('error',()=>{finished=true;release();});
res.once('close',()=>{if(!finished){finished=true;release();}});
req.setTimeout(10000,()=>fail(408,'{"error":"Collector request timed out"}'));
req.on('data',chunk=>{if(finished)return;const rejected=lease.add(chunk.length);if(rejected){if(rejected==='body-limit')fail(413,'{"error":"Telemetry batch too large"}');else{finished=true;release();rejectBusy();}}else chunks.push(chunk);});
req.on('end',()=>{if(finished)return;req.setTimeout(0);
void lease.enqueue(()=>{if(finished)return;
try{
const payload=JSON.parse(Buffer.concat(chunks).toString('utf8')),signal=req.url.slice(4);if(!payload||typeof payload!=='object'||Array.isArray(payload))throw Error('Invalid OTLP');
const normalized=normalizeCollectorBatch(signal,payload);
const nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(Buffer.from('safetynet-debug-v1'));
const plain=Buffer.from(JSON.stringify({signal,receivedAt:new Date().toISOString(),payload})),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);
const line=JSON.stringify({v:1,nonce:nonce.toString('base64'),tag:cipher.getAuthTag().toString('base64'),ciphertext:encrypted.toString('base64')})+'\n';
// HTTP success remains after the guarded encrypted append and fsync.
try{storage.append(line);}catch{fail(507,'{"error":"Protected collector storage unavailable"}');throw Error('Collector commit failed');}
requests++;counts.traces+=normalized.spans.length;counts.logs+=normalized.logs;counts.metrics+=normalized.metrics;recent.push(...normalized.spans);while(recent.length>5000)recent.shift();
finished=true;res.setHeader('content-type','application/json');res.end('{}');
}catch{fail(400,'{"error":"Invalid telemetry batch"}');throw Error('Collector batch failed');}
}).catch(()=>{if(!finished)fail(503,'{"error":"Collector busy"}');}).finally(release);
});
});
server.listen(port,'127.0.0.1',()=>console.log(`Local debug collector: http://127.0.0.1:${port}/api/summary`));
function stop(){eventLoop.disable();server.closeAllConnections();server.close(()=>{key.fill(0);process.exit(0);});}process.on('SIGINT',stop);process.on('SIGTERM',stop);