64 lines
3.9 KiB
TypeScript
Executable File
64 lines
3.9 KiB
TypeScript
Executable File
import { createServer } from 'node:http';
|
|
import { isIP } from 'node:net';
|
|
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
import { stat,statfs } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import type { AddressInfo } from 'node:net';
|
|
|
|
/** No hop-count/boolean trust. Match only explicit proxy addresses/subnets. The edge must
|
|
* overwrite forwarded headers and prevent direct backend access from trusted addresses. */
|
|
export function trustedProxies(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
const raw = env.WIKIDECK_TRUSTED_PROXIES;
|
|
if (!raw) return [];
|
|
const values=raw.split(',').map(s=>s.trim());
|
|
if(values.length>16)throw new Error('At most 16 trusted proxy addresses/subnets');
|
|
for(const value of values){
|
|
const [ip,prefix,...extra]=value.split('/'),family=isIP(ip);
|
|
if(!family||extra.length||(prefix!==undefined&&(!/^\d{1,3}$/.test(prefix)||Number(prefix)<(family===4?8:32)||Number(prefix)>(family===4?32:128))))
|
|
throw new Error('Trusted proxies must be literal IPs or bounded CIDRs, never booleans, names or hop counts');
|
|
}
|
|
return [...new Set(values)];
|
|
}
|
|
export function operationsPort(env: NodeJS.ProcessEnv = process.env): number | undefined {
|
|
if(env.WIKIDECK_OPS_PORT===undefined||env.WIKIDECK_OPS_PORT==='')return undefined;
|
|
const n=Number(env.WIKIDECK_OPS_PORT);
|
|
if(!/^\d+$/.test(env.WIKIDECK_OPS_PORT)||!Number.isInteger(n)||n<1||n>65535)throw new Error('Invalid WIKIDECK_OPS_PORT');
|
|
return n;
|
|
}
|
|
/** Separate loopback listener, disabled by default. No bearer tokens or details on public RPC.
|
|
* Local users/processes share this trust boundary; never forward/publish this port. */
|
|
export function startOperations(port: number | undefined, snapshot: ()=>unknown, directory?:string) {
|
|
if(port===undefined)return {close() {}};
|
|
const delay=monitorEventLoopDelay({resolution:20});delay.enable();
|
|
let disk:unknown=null,lastDiskSample=0;
|
|
const sampleDisk=async()=>{
|
|
if(!directory)return;
|
|
try {
|
|
const fs=await statfs(directory);
|
|
const sizes=await Promise.all(['ledger.sqlite','ledger.sqlite-wal'].map(async name=>{
|
|
try{return {name,bytes:(await stat(join(directory,name))).size};}
|
|
catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return {name,bytes:0};throw error;}
|
|
}));
|
|
disk={availableBytes:fs.bavail*fs.bsize,files:sizes};lastDiskSample=Date.now();
|
|
}catch{disk={available:false,error:'Disk sample failed; inspect local filesystem and permissions.'};lastDiskSample=Date.now();}
|
|
};
|
|
void sampleDisk();const diskTimer=setInterval(()=>{void sampleDisk();},30000);diskTimer.unref();
|
|
const server=createServer((req,res)=>{
|
|
res.setHeader('Cache-Control','no-store');res.setHeader('Content-Type','application/json');
|
|
if(req.method!=='GET'||req.url!=='/status'){res.writeHead(404).end('{"error":"Not found"}');return;}
|
|
try {
|
|
const data=JSON.stringify({schema:'wikideck-operations-v1',observedAt:new Date().toISOString(),
|
|
uptimeSeconds:process.uptime(),nodeRssBytes:process.memoryUsage().rss,
|
|
eventLoop:{meanMs:Number.isFinite(delay.mean)?delay.mean/1e6:0,maxMs:delay.max/1e6},
|
|
disk,diskSampledAt:lastDiskSample||null,...snapshot() as object});
|
|
if(Buffer.byteLength(data)>64*1024)throw new Error('Diagnostics size');
|
|
res.end(data);
|
|
}catch{res.writeHead(503).end('{"error":"Diagnostics unavailable"}');}
|
|
});
|
|
server.maxConnections=4;server.headersTimeout=5000;server.requestTimeout=5000;server.keepAliveTimeout=1000;
|
|
server.setTimeout(5000,socket=>socket.destroy());
|
|
server.on('error',()=>{clearInterval(diskTimer);delay.disable();console.error('Private operations listener failed; check local monitoring configuration.');});
|
|
server.listen(port,'127.0.0.1');
|
|
return {close(){clearInterval(diskTimer);delay.disable();server.closeAllConnections();server.close();},address:()=>server.address() as AddressInfo|null};
|
|
}
|