Files
wikideck/server/operations.ts
T
2026-10-28 18:33:52 -04:00

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};
}