Files
safetynet/apps/browser/broker-supervisor.cjs

112 lines
8.5 KiB
JavaScript

'use strict';
const {fork}=require('node:child_process');
const path=require('node:path');
const {randomUUID,X509Certificate}=require('node:crypto');
const UUID=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
const ENTRY='apps/network-broker/production-entry.cjs';
const STARTUP_CODES=new Set(['broker-bootstrap-invalid','broker-route-unsupported','broker-startup-inspector','broker-startup-certificate','broker-startup-transport','broker-startup-managed','broker-startup-failed']);
// Parent-only: resourceRoot and verifyPackage come from the trusted embedded
// installer contract. Never accept executable/module paths from renderer IPC.
function createBrokerSupervisor({resourceRoot,verifyPackage,persistAudit,acceptCertificate,onQuarantine,spawn=fork,deadlineMs=15000,shutdownMs=5000,traceOperation=(_name,_attributes,run)=>run()}={}){
if(!path.isAbsolute(resourceRoot||'')||![verifyPackage,persistAudit,acceptCertificate,onQuarantine,spawn].every(f=>typeof f==='function')||!Number.isInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000)throw Error('broker-supervisor-invalid');
if(!Number.isInteger(shutdownMs)||shutdownMs<1||shutdownMs>30000)throw Error('broker-supervisor-invalid');
if(typeof traceOperation!=='function')throw Error('broker-supervisor-invalid');
// Instrumentation may neither skip a protected operation nor replace its
// result, retry it, or invoke it twice. Only fixed stage names are exported.
async function stage(name,operation){
let original,called=false;
const once=()=>{if(called)throw Error('broker-diagnostics-invalid');called=true;try{original=Promise.resolve(operation());}catch(error){original=Promise.reject(error);}original.catch(()=>{});return original;};
try{await traceOperation(name,{component:'network'},once);}catch(error){if(called)throw error;return once();}
if(!called)once();return original;
}
let child,current,failed=false,closed=false,starting=false,timer,readyResolve,readyReject,lastSequence=0,stopping,pendingCertificates=0,startupCode;
const stopChild=()=>{
clearTimeout(timer);if(stopping)return stopping;
const previous=child;if(!previous)return Promise.resolve();
previous.removeAllListeners('message');
stopping=stage('browser.broker.shutdown',()=>new Promise((resolve,reject)=>{
let done=false;
const finish=()=>{if(done)return;done=true;clearTimeout(limit);child=undefined;resolve();};
const limit=setTimeout(()=>{if(done)return;done=true;failed=true;try{onQuarantine('broker-shutdown-unconfirmed');}catch{}reject(Error('broker-shutdown-unconfirmed'));},shutdownMs);
previous.once('exit',finish);
// ChildProcess retains ownership state; no numeric PID/taskkill operation
// is performed after exit, avoiding a reused-PID tree kill. Native job-based
// descendant containment remains an installer/launcher prerequisite.
if(previous.exitCode!==null&&previous.exitCode!==undefined||previous.signalCode)finish();
else try{previous.kill('SIGKILL');}catch{}
}));
// Fault callers do not await shutdown, but close() receives its real outcome.
stopping.catch(()=>{});return stopping;
};
const fault=()=>{if(failed||closed)return;failed=true;stopChild();try{onQuarantine('broker-supervisor-unavailable');}catch{}readyReject?.(Error('broker-supervisor-unavailable'));};
const send=value=>{if(!child||failed||closed)throw Error('broker-supervisor-unavailable');child.send(value,error=>{if(error)fault();});};
const context=()=>({version:1,generation:current.generation,policyVersion:current.policyVersion});
const exact=(m,keys)=>Object.keys(m).sort().join(',')===keys.sort().join(',');
async function message(m,owner,generation){
if(owner!==child||generation!==current.generation)return;
try{
const bytes=Buffer.byteLength(JSON.stringify(m));
if(bytes>65536||!m||typeof m!=='object'||Array.isArray(m)||m.version!==1||m.generation!==current.generation||m.policyVersion!==current.policyVersion)throw Error();
const common=['type','version','generation','policyVersion'];
if(m.type==='startup-fault'){
if(current.ready||!exact(m,[...common,'code'])||!STARTUP_CODES.has(m.code))throw Error();
// Bounded local pre-enrollment state only; no raw errors or unauthorised
// telemetry exporter. This never grants readiness or permits a retry.
startupCode=m.code;fault();return;
}
if(m.type==='ready'){
if(m.sequence!==0||m.authMode!=='basic'||current.ready||!exact(m,[...common,'port','credentials','authMode','sequence'])||!Number.isInteger(m.port)||m.port!==current.port||!m.credentials||!exact(m.credentials,['username','password'])||!/^safetynet-[a-f0-9]{32}$/.test(m.credentials.username)||!/^[A-Za-z0-9_-]{43}$/.test(m.credentials.password))throw Error();
current.ready=true;clearTimeout(timer);readyResolve(Object.freeze({generation:current.generation,policyVersion:current.policyVersion,port:m.port,credentials:Object.freeze({...m.credentials})}));return;
}
if(!UUID.test(m.requestId)||!Number.isSafeInteger(m.sequence)||m.sequence!==lastSequence+1)throw Error();lastSequence=m.sequence;
if(m.type==='audit'&&exact(m,[...common,'requestId','sequence','event'])){
const receipt=persistAudit(m.event);
if(!receipt||receipt.persisted!==true||receipt.eventId!==m.event.eventId||receipt.then)throw Error();
if(owner!==child||generation!==current.generation)throw Error();
send({type:'audit-receipt',...context(),requestId:m.requestId,sequence:m.sequence,receipt});return;
}
if(m.type==='certificate'&&exact(m,[...common,'requestId','sequence','authority','cert'])){
if(pendingCertificates>=16)throw Error();
if(typeof m.authority!=='string'||m.authority.length>300||typeof m.cert!=='string'||m.cert.length>16384||!/^-----BEGIN CERTIFICATE-----/.test(m.cert))throw Error();
const certificate=new X509Certificate(m.cert);if(certificate.ca)throw Error();
pendingCertificates++;
const certificateDeadline=setTimeout(fault,shutdownMs);
let accepted;
// Admission remains occupied until the ORIGINAL callback settles even if
// the deadline quarantines. A timed-out operation cannot free capacity.
try{accepted=await acceptCertificate(Object.freeze({...context(),authority:m.authority,cert:m.cert,fingerprint256:certificate.fingerprint256}));}
finally{clearTimeout(certificateDeadline);pendingCertificates--;}
if(accepted!==true||owner!==child||generation!==current.generation)throw Error();
send({type:'certificate-receipt',...context(),requestId:m.requestId,sequence:m.sequence,accepted:true});return;
}
throw Error();
}catch{fault();}
}
return Object.freeze({
async start({policyVersion,port,config}={}){
if(failed||closed||child||starting||!Number.isSafeInteger(policyVersion)||policyVersion<1||!Number.isInteger(port)||port<1||port>65535)throw Error('broker-supervisor-invalid');
starting=true;
try{
if(await stage('browser.broker.verify',()=>verifyPackage(resourceRoot))!==true)throw Error();
if(failed||closed)throw Error();
current={generation:randomUUID(),policyVersion,port,ready:false};lastSequence=0;
const envelope={type:'start',...context(),config};if(Buffer.byteLength(JSON.stringify(envelope))>2097152)throw Error();
// Allowlisted Windows runtime essentials only; inherited Node injection,
// proxy credentials and developer environment never reach the child.
const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'])if(process.env[key])env[key]=process.env[key];
const pending=new Promise((resolve,reject)=>{readyResolve=resolve;readyReject=reject;});
const owner=spawn(path.join(resourceRoot,ENTRY),[],{execPath:path.join(resourceRoot,'node.exe'),cwd:resourceRoot,env,execArgv:[],windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],serialization:'json'});
child=owner;const generation=current.generation;
owner.on('message',m=>{void message(m,owner,generation);});
for(const event of ['error','exit','disconnect'])owner.once(event,()=>{if(owner===child)fault();});
timer=setTimeout(fault,deadlineMs);send(envelope);return await stage('browser.broker.start',()=>pending);
}catch{fault();throw Error('broker-supervisor-unavailable');}
},
async close(){closed=true;readyReject?.(Error('broker-supervisor-closed'));await stopChild();},
// Replacement is deliberately stop-and-new-supervisor: no automatic retry
// and no stale ready state or credentials survive a policy generation.
status(){return Object.freeze({ready:!!current?.ready&&!failed&&!closed,failed,closed,generation:current?.generation,startupCode});}
});
}
module.exports={createBrokerSupervisor};