'use strict'; const http=require('node:http'); const {randomBytes,timingSafeEqual}=require('node:crypto'); const {bindConnectAuthority}=require('./tls-authority.cjs'); const {createInspectionServer}=require('./https-inspection-server.cjs'); const {GateError}=require('./http-request-gate.cjs'); const {createWorkAdmission}=require('./work-admission.cjs'); const {recordLifecycle}=require('./bounded-audit.cjs'); /** Isolated authenticated CONNECT-to-inspection chain. Scoped certificates * are supplied by a trusted resolver, never generated/trusted globally here. * No opaque tunnel or target connection is opened by this acceptor. */ function createConnectProxy({authority,current,resolveCertificate,inspect,forward,deadlineMs=5000,maxClients=8,maxBytes=1048576,maxResponseBytes=1048576,authMode='basic',lifecycle}) { if(typeof authority!=='string'||!authority||typeof current!=='function'||typeof resolveCertificate!=='function'||typeof inspect!=='function'||typeof forward!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000||!Number.isSafeInteger(maxClients)||maxClients<1||maxClients>64||!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>16777216||!Number.isSafeInteger(maxResponseBytes)||maxResponseBytes<0||maxResponseBytes>16777216)throw new GateError('invalid-configuration'); if(lifecycle!==undefined&&(!lifecycle||typeof lifecycle.begin!=='function'))throw new GateError('invalid-configuration'); if(!['basic','bearer'].includes(authMode))throw new GateError('invalid-configuration'); const capability=randomBytes(32).toString('base64url'),username=`safetynet-${randomBytes(16).toString('hex')}`; const credentials=Object.freeze({username,password:capability}); const expected=Buffer.from(authMode==='basic'?`Basic ${Buffer.from(`${username}:${capability}`).toString('base64')}`:`Bearer ${capability}`); const sockets=new Set(),legs=new Set(); const admission=createWorkAdmission({maxJobs:maxClients}); const auditAdmission=createWorkAdmission({maxJobs:maxClients}),contexts=new WeakMap(); const contextFor=socket=>{if(!lifecycle)return undefined;if(!contexts.has(socket))contexts.set(socket,lifecycle.begin());return contexts.get(socket);}; const rejected=(context,code,facts={bytes:0,detectedType:'unknown'})=>recordLifecycle({admission:auditAdmission,deadlineMs,context,code,facts,scope:'connect'}); const server=http.createServer({maxHeaderSize:8192},(req,res)=>{void rejected(contextFor(req.socket),'unsupported-method').catch(()=>{}).finally(()=>{if(!res.destroyed){res.writeHead(403,{'connection':'close'});res.end('connect-required');}});}); const reject=(socket,status,code)=>socket.end(`HTTP/1.1 ${status}\r\n${status.startsWith('407')?`Proxy-Authenticate: ${authMode==='basic'?'Basic realm="SafetyNet private broker", charset="UTF-8"':'Bearer realm="SafetyNet private broker"'}\r\n`:''}Connection: close\r\nContent-Length: ${Buffer.byteLength(code)}\r\n\r\n${code}`); server.on('connect',async(req,socket,head)=>{ socket.pause(); let context,binding; try { context=contextFor(socket); const headers=Object.create(null); for(let index=0;indexresolverController.abort(); socket.once('close',cancelResolver); const resolved=await Promise.race([admission.run(()=>resolveCertificate(binding,{signal:resolverController.signal})),new Promise((_,rejectPromise)=>{timer=setTimeout(()=>{resolverController.abort();rejectPromise(new GateError('certificate-deadline'));},deadlineMs);})]).finally(()=>{clearTimeout(timer);socket.removeListener('close',cancelResolver);resolverController.abort();}); if(socket.destroyed||current(authority)!==true)throw new GateError('authority-expired'); if(!resolved||resolved.authority!==binding.authority)throw new GateError('destination-unapproved'); const leg=createInspectionServer({key:resolved.key,cert:resolved.cert,binding,authority,current,inspect,forward,deadlineMs,maxClients,maxBytes,maxResponseBytes,workAdmission:admission,lifecycle,auditAdmission,connectionContext:context}); legs.add(leg);socket.once('close',()=>{legs.delete(leg);void leg.close().catch(()=>{});}); socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); leg.acceptAuthenticatedSocket(socket);socket.resume(); } catch(error) { try {await rejected(context,error instanceof GateError?error.code:'connect-denied',{bytes:0,detectedType:'unknown',...(binding?{host:binding.hostname}:{})});}catch{} if(!socket.destroyed)reject(socket,'403 Forbidden','connect-denied'); } }); server.maxConnections=maxClients;server.maxHeadersCount=32;server.headersTimeout=deadlineMs;server.requestTimeout=deadlineMs; server.on('connection',socket=>{sockets.add(socket);socket.setTimeout(deadlineMs*3,()=>socket.destroy());socket.once('close',()=>sockets.delete(socket));}); server.on('clientError',(_error,socket)=>{void rejected(contextFor(socket),'invalid-headers').catch(()=>{}).finally(()=>socket.destroy());}); return Object.freeze({capability,authMode,credentials, async listen(port=0){if(!Number.isInteger(port)||port<0||port>65535)throw new GateError('invalid-configuration');await new Promise((resolve,rejectPromise)=>{server.once('error',rejectPromise);server.listen(port,'127.0.0.1',()=>{server.removeListener('error',rejectPromise);resolve();});});return server.address().port;}, async close(){admission.close();auditAdmission.close();for(const socket of sockets)socket.destroy();await Promise.all([...legs].map(leg=>leg.close()));await new Promise(resolve=>server.close(resolve));} }); } module.exports={createConnectProxy};