Files
safetynet/apps/network-broker/https-inspection-server.cjs
T

80 lines
7.5 KiB
JavaScript
Raw Normal View History

'use strict';
const https=require('node:https');
const {verifyInnerAuthority}=require('./tls-authority.cjs');
const {gateRequest,GateError}=require('./http-request-gate.cjs');
const {validateResponse}=require('./http-response-gate.cjs');
const {createWorkAdmission}=require('./work-admission.cjs');
const {recordLifecycle}=require('./bounded-audit.cjs');
/** Isolated decrypted HTTPS leg for one caller-authenticated CONNECT binding.
* Trusted caller supplies certificate, authorization and HTTPS forwarder.
* This is NOT a CONNECT proxy, certificate issuer or OS network boundary.
*/
function createInspectionServer({key,cert,binding,authority,current,inspect,forward,maxBytes=1048576,maxResponseBytes=1048576,deadlineMs=5000,maxClients=8,workAdmission,lifecycle,auditAdmission,connectionContext}) {
if(typeof authority!=='string'||!authority||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000||!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>16777216||!Number.isSafeInteger(maxResponseBytes)||maxResponseBytes<0||maxResponseBytes>16777216||!Number.isSafeInteger(maxClients)||maxClients<1||maxClients>64||typeof current!=='function'||typeof inspect!=='function'||typeof forward!=='function')throw new GateError('invalid-configuration');
const sockets=new Set();
const admission=workAdmission||createWorkAdmission({maxJobs:maxClients});
const controlAdmission=auditAdmission||createWorkAdmission({maxJobs:maxClients});
const rejectAudit=(context,code,facts,scope='request')=>recordLifecycle({admission:controlAdmission,deadlineMs,context,code,facts,scope});
let server;
try {
server=https.createServer({key,cert,minVersion:'TLSv1.2',handshakeTimeout:deadlineMs},async(req,res)=>{
const controller=new AbortController();let phase='request',totalExpired=false,authorized=false;const expires=performance.now()+deadlineMs;
const totalTimer=setTimeout(()=>{totalExpired=true;controller.abort();},deadlineMs);
const auditContext=lifecycle?.begin(connectionContext?.requestId);
let receivedBytes=0;
const received=chunk=>{receivedBytes=Math.min(20*1024*1024,receivedBytes+chunk.length);};
req.on('data',received);
const abort=()=>controller.abort();
res.once('close',abort);
try {
verifyInnerAuthority(binding,{rawHeaders:req.rawHeaders,url:req.url,servername:req.socket.servername||undefined});
const target=req.url.startsWith('/')?`https://${binding.authority}${req.url}`:req.url;
const parsed=new URL(target);
const rawHeaders=req.rawHeaders.slice();
for(let index=0;index<rawHeaders.length;index+=2)if(rawHeaders[index].toLowerCase()==='host')rawHeaders[index+1]=binding.authority;
// Stream methods remain bound to the actual IncomingMessage. Only
// immutable routing metadata is adapted for the reusable HTTP gate.
const adapted=new Proxy(req,{get(object,name){if(name==='url')return `http://${binding.authority}${parsed.pathname}${parsed.search}`;if(name==='rawHeaders')return rawHeaders;const value=Reflect.get(object,name);return typeof value==='function'?value.bind(object):value;}});
const secureSnapshot=value=>Object.freeze({...value,auditContext,markStage:value=>{if(value==='scan'||value==='forward')phase=value;},request:Object.freeze({...value.request,url:target})});
const response=await gateRequest(adapted,{authority,current,maxBytes,deadlineMs:Math.max(1,Math.ceil(expires-performance.now())),signal:controller.signal,inspect:value=>{phase='scan';return admission.run(()=>inspect(secureSnapshot(value)),{payload:value.body});},forward:async value=>{
phase='forward';authorized=true;
let cancelled;
let forwardExpired=false;
const forwardTimer=setTimeout(()=>{forwardExpired=true;controller.abort();},Math.max(1,Math.ceil(expires-performance.now())));
try {
const cancellation=new Promise((_,reject)=>{cancelled=()=>reject(new GateError('upstream-failed'));controller.signal.addEventListener('abort',cancelled,{once:true});if(controller.signal.aborted)cancelled();});
const result=await Promise.race([admission.run(()=>forward(Object.freeze({...secureSnapshot(value),signal:controller.signal,maxResponseBytes})),{payload:value.body}),cancellation]);
return validateResponse(result,maxResponseBytes);
} catch(error) {throw new GateError(totalExpired?(phase==='scan'?'inspection-deadline':'forward-deadline'):forwardExpired?'forward-deadline':controller.signal.aborted?'aborted':['invalid-upstream-response','unsupported-upstream-encoding','authority-expired','audit-unavailable','audit-persistence-failed'].includes(error?.code)?error.code:'upstream-failed');}
finally {clearTimeout(forwardTimer);controller.signal.removeEventListener('abort',cancelled);}
}});
if(controller.signal.aborted||res.destroyed)return;
res.writeHead(response.statusCode,response.headers);res.end(response.body);
} catch(error) {
const code=totalExpired?(phase==='scan'?'inspection-deadline':phase==='forward'?'forward-deadline':'deadline'):error instanceof GateError||error?.name==='AuthorityError'?error.code:'inspection-failed';
try {await rejectAudit(auditContext,code,{bytes:receivedBytes,detectedType:'unknown',host:binding.hostname});}catch{}
const upstream=code==='inspection-deadline'?authorized:['upstream-failed','forward-deadline','invalid-upstream-response','unsupported-upstream-encoding'].includes(code);
if(!res.destroyed&&!res.headersSent){res.writeHead(upstream?502:403,{'connection':'close','content-type':'text/plain'});res.end(upstream?'upstream-failed':code);}
} finally {clearTimeout(totalTimer);res.removeListener('close',abort);req.removeListener('data',received);}
});
} catch {throw new GateError('invalid-tls-configuration');}
server.maxConnections=maxClients;
server.headersTimeout=deadlineMs;
server.requestTimeout=deadlineMs;
server.keepAliveTimeout=1;
// Idle fallback leaves room for a complete inspection phase followed by
// its separately bounded forward phase. Neither wall clock is renewable.
server.on('connection',socket=>{sockets.add(socket);socket.setTimeout(deadlineMs*3,()=>socket.destroy());socket.once('close',()=>sockets.delete(socket));});
const malformedContexts=new WeakMap();
server.on('clientError',(_error,socket)=>{if(!malformedContexts.has(socket))malformedContexts.set(socket,lifecycle?.begin(connectionContext?.requestId));const context=malformedContexts.get(socket);void rejectAudit(context,'invalid-headers',{bytes:0,detectedType:'unknown',host:binding.hostname}).catch(()=>{}).finally(()=>socket.destroy());});
server.on('tlsClientError',(_error,socket)=>{void rejectAudit(connectionContext,'connect-denied',{bytes:0,detectedType:'unknown',host:binding.hostname},'connect').catch(()=>{}).finally(()=>socket.destroy());});
return Object.freeze({
// Trusted authenticated CONNECT acceptor only; no public IPC exposure.
// HTTPS Server owns TLS negotiation on this accepted raw TCP socket.
acceptAuthenticatedSocket(socket){server.emit('connection',socket);},
async listen(){await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.removeListener('error',reject);resolve();});});return server.address().port;},
async close(){if(!workAdmission)admission.close();if(!auditAdmission)controlAdmission.close();for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));}
});
}
module.exports={createInspectionServer};