78 lines
6.7 KiB
JavaScript
78 lines
6.7 KiB
JavaScript
// Scratch-only lane ownership. No live Mosaic state is read or written.
|
|
import { realpathSync, statSync, lstatSync, existsSync, readdirSync, readFileSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync, linkSync, unlinkSync, renameSync } from 'node:fs';
|
|
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { randomBytes } from 'node:crypto';
|
|
export const SCRATCH='/mnt/storage/scratch';
|
|
export const BANDS={a:[61000,61127],b:[61128,61255]};
|
|
export function refuse(message){throw new Error(`gate-lane: refused: ${message}`);}
|
|
export function within(root,path){const r=relative(root,path);return r===''||(!r.startsWith('..')&&!isAbsolute(r));}
|
|
export function shortRoot(path){
|
|
let real;try{real=realpathSync(path);}catch{refuse('temporary root missing');}
|
|
if(real==='/tmp'||real.startsWith('/tmp/'))refuse('temporary root under /tmp');
|
|
if(Buffer.byteLength(real)>30)refuse('temporary root exceeds 30 bytes');
|
|
if(!within(SCRATCH,real)||!statSync(real).isDirectory()||statSync(real).uid!==process.getuid()||(statSync(real).mode&0o077))refuse('temporary root must be private owned scratch');
|
|
return real;
|
|
}
|
|
export function socketPath(path){if(Buffer.byteLength(path)>107)refuse('socket path exceeds 107 bytes');return path;}
|
|
export function hostFacts(){
|
|
const range=readFileSync('/proc/sys/net/ipv4/ip_local_port_range','utf8').trim().split(/\s+/).map(Number);
|
|
const unprivilegedPortStart=Number(readFileSync('/proc/sys/net/ipv4/ip_unprivileged_port_start','utf8').trim());
|
|
const text=execFileSync('ss',['-Hltn'],{encoding:'utf8'});
|
|
const listeners=text.trim().split('\n').filter(Boolean).map(line=>{const field=line.trim().split(/\s+/)[3];const m=field?.match(/^(.*):(\d+)$/);if(!m)refuse('unreadable listener inventory');return {address:m[1].replace(/^\[|\]$/g,''),port:Number(m[2])};});
|
|
return {ephemeral:range,unprivilegedPortStart,listeners};
|
|
}
|
|
export function checkHost(lane,facts){
|
|
if(!facts||!Array.isArray(facts.listeners)||facts.ephemeral?.length!==2||!facts.ephemeral.every(Number.isInteger))refuse('host inventory unavailable');
|
|
if(!Number.isInteger(facts.unprivilegedPortStart)||facts.unprivilegedPortStart<=9)refuse('unprivileged port range includes port 9');
|
|
if(facts.listeners.some(x=>x.port===9&&['127.0.0.1','0.0.0.0','*','::','::ffff:127.0.0.1'].includes(x.address.replace(/%[^%]+$/, ''))))refuse('live port 9 listener');
|
|
const [lo,hi]=BANDS[lane];const [first,last]=facts.ephemeral;
|
|
if(lo<=last&&hi>=first)refuse('lane port band overlaps ephemeral range');
|
|
if(facts.listeners.some(x=>x.port>=lo&&x.port<=hi))refuse('lane port band has a live listener');
|
|
}
|
|
export function identity(pid=process.pid){try{const s=readFileSync(`/proc/${pid}/stat`,'utf8');const f=s.slice(s.lastIndexOf(')')+2).split(' ');return {pid:Number(pid),start:f[19],boot:readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(),state:f[0]};}catch{return null;}}
|
|
export function sameProcess(a,b=identity(a?.pid)){return !!a&&!!b&&a.pid===b.pid&&a.start===b.start&&a.boot===b.boot&&b.state!=='Z';}
|
|
export function nonce(){return randomBytes(6).toString('hex');}
|
|
export function appendRecord(dir,record){
|
|
const publishedDir=dir;if(record.kind==='start'){if(existsSync(dir))refuse('run already exists');dir=dir+'.starting';}
|
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
const file=join(dir,`${Date.now()}-${nonce()}-${record.kind}.json`);
|
|
const pending=file+'.pending',fd=openSync(pending,'wx',0o600);try{writeSync(fd,JSON.stringify({...record,at:new Date().toISOString()})+'\n');fsyncSync(fd);}finally{closeSync(fd);}
|
|
linkSync(pending,file);unlinkSync(pending);if(record.kind==='start')renameSync(dir,publishedDir);return join(publishedDir,file.split('/').at(-1));
|
|
}
|
|
export function records(dir){if(!existsSync(dir))return [];return readdirSync(dir).filter(n=>n.endsWith('.json')).sort().map(n=>{try{return JSON.parse(readFileSync(join(dir,n),'utf8'));}catch{refuse(`invalid run receipt ${n}`);}});}
|
|
export function runRecords(stateRoot){
|
|
const runs=join(stateRoot,'runs');if(!existsSync(runs))return [];
|
|
return readdirSync(runs).filter(id=>!id.endsWith('.starting')).map(id=>({id,dir:join(runs,id),records:records(join(runs,id))}));
|
|
}
|
|
export function processInventory(){
|
|
const out=[];
|
|
for(const pid of readdirSync('/proc').filter(n=>/^\d+$/.test(n))){try{const argv=readFileSync(`/proc/${pid}/cmdline`,'utf8').split('\0').filter(Boolean);const raw=readFileSync(`/proc/${pid}/stat`,'utf8');const f=raw.slice(raw.lastIndexOf(')')+2).split(' ');out.push({...identity(Number(pid)),ppid:Number(f[1]),group:Number(f[2]),argv});}catch{}}
|
|
return out;
|
|
}
|
|
export function status(stateRoot,observations={}){
|
|
const procs=observations.processes??processInventory();const runs=runRecords(stateRoot);
|
|
const answer={a:{state:'CLEAR'},b:{state:'CLEAR'},image:'free',legacy:[],at:new Date().toISOString()};
|
|
const owned=new Set();
|
|
for(const run of runs){const start=run.records.find(x=>x.kind==='start');if(!start)refuse('run receipt has no start');const clean=run.records.some(x=>['terminal','recovered'].includes(x.kind)&&x.clean===true);
|
|
if(clean)continue;
|
|
const live=procs.find(p=>sameProcess(start.owner,p));const state=live?'BUSY':'QUARANTINED';
|
|
for(const lane of start.exclusive?['a','b']:[start.lane]){if(!BANDS[lane])refuse('invalid lane receipt');answer[lane]={state,run:run.id};}
|
|
answer.image=start.exclusive?'exclusive':'shared';
|
|
if(live){owned.add(live.pid);let changed=true;while(changed){changed=false;for(const p of procs)if(owned.has(p.ppid)&&!owned.has(p.pid)){owned.add(p.pid);changed=true;}}}
|
|
}
|
|
for(const p of procs){if(p.state==='Z'||p.pid===process.pid||owned.has(p.pid))continue;
|
|
const args=p.argv??[];const executable=args[0]?.split('/').at(-1);
|
|
const test=(executable==='node'&&args.includes('--test'))||args.some(a=>/^(?:.*\/)?(?:gate\.sh|test-[a-z-]+\.sh)$/.test(a))||(executable==='docker'&&(args.includes('compose')||args.includes('build')))||args.some(a=>a.endsWith('/landing.mjs')&&args.includes('gate'))||args.some(a=>/(?:^|\/)[^/]*mutant[^/]*\.(?:mjs|py|sh)$/.test(a));
|
|
if(test)answer.legacy.push({pid:p.pid,start:p.start});
|
|
}
|
|
for(const lane of ['a','b','image']){
|
|
if(observations.ignoreLocks?.includes(lane)||!existsSync(join(stateRoot,`${lane}.lock`)))continue;
|
|
const lock=lstatSync(join(stateRoot,`${lane}.lock`));if(!lock.isFile())refuse('lock is not an ordinary file');
|
|
const probe=spawnSync('flock',['-n','-x',join(stateRoot,`${lane}.lock`),'true']);
|
|
if(probe.status!==0&&probe.status!==1)refuse('cannot inspect lane lock');
|
|
if(probe.status===1){if(lane==='image')answer.image=answer.image==='free'?'held':answer.image;else if(answer[lane].state==='CLEAR')answer[lane]={state:'BUSY',run:null};}
|
|
}
|
|
return answer;
|
|
}
|