'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;indexObject.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};