Repository files, collaboration, releases and project activity.
archforge-external-status / src/probes.jsmain
▤ src/probes.js7259 bytes
99 lines · Select a line number for a permalink
1
'use strict';2
const dns=require('node:dns').promises,net=require('node:net'),tls=require('node:tls');3
const {URL}=require('node:url');4
const PROBES=[5
{id:'website',label:'Main Website',description:'HTTPS reachability (safe same-origin redirects allowed)',category:'Platform',configured:true},6
{id:'api',label:'Public API',description:'Read-only HTTP API reachability',category:'Platform',configured:false},7
{id:'git_http',label:'ArchForge HTTPS',description:'Public repository upload-pack discovery (read-only)',category:'ArchForge',configured:false},8
{id:'git_ssh',label:'ArchForge SSH',description:'SSH server handshake/banner',category:'ArchForge',configured:true},9
{id:'dns',label:'DNS',description:'Public address resolution',category:'Infrastructure',configured:true},10
{id:'tls',label:'TLS Certificate',description:'HTTPS certificate validity / expiration',category:'Infrastructure',configured:true}11
];12
function serviceDefinitions(cfg){return PROBES.map(s=>({...s,configured:s.id==='api'?!!cfg.api:s.id==='git_http'?!!cfg.gitHttp&&new URL(cfg.gitHttp).pathname.endsWith('.archforge/info/refs'):s.configured}));}13
function checkError(err){return String(err?.code||err?.name||'CHECK_FAILED').slice(0,80);}14
// Only the public website reachability probe may follow redirects. API and ArchForge15
// probes must verify their actual endpoint and must never mistake a login page16
// for a healthy API or a valid ArchForge HTTPS advertisement.17
const REDIRECT_CODES=new Set([301,302,303,307,308]);18
const MAX_WEBSITE_REDIRECTS=3;19
async function checkHttp(url,timeout,{git=false,website=false}={}){20
const start=Date.now(),controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout);21
let current;22
try {23
const target=new URL(url);24
if(!['https:','http:'].includes(target.protocol)||target.username||target.password||target.hash)throw Error('INVALID_TARGET');25
current=target;26
const seen=new Set();27
let redirects=0,firstRedirect=0;28
for(;;){29
if(seen.has(current.href))return {success:false,latency:Date.now()-start,detail:'HTTP redirect loop'};30
seen.add(current.href);31
const response=await fetch(current.href,{method:'GET',redirect:'manual',signal:controller.signal,headers:{'user-agent':'ArchForge-External-Monitor/1.0.9','accept':'*/*'}});32
const status=response.status;33
const location=response.headers.get('location');34
const type=String(response.headers.get('content-type')||'');35
if(response.body)await response.body.cancel().catch(()=>{});36
if(REDIRECT_CODES.has(status)){37
// Only the public website probe follows redirects, with HTTPS and38
// exact origin pinned to the configured website. No downgrade,39
// cross-host hop, user-info credentials, or unlimited redirect chain.40
if(!website||git)return {success:false,latency:Date.now()-start,detail:`HTTP ${status} (redirect rejected)`};41
if(!location)return {success:false,latency:Date.now()-start,detail:`HTTP ${status} (missing Location)`};42
if(redirects>=MAX_WEBSITE_REDIRECTS)return {success:false,latency:Date.now()-start,detail:'HTTP redirect limit exceeded'};43
let next;44
try{next=new URL(location,current);}catch{return {success:false,latency:Date.now()-start,detail:`HTTP ${status} (invalid Location)`};}45
if(next.origin!==target.origin||next.protocol!==target.protocol||next.username||next.password||next.hash)46
return {success:false,latency:Date.now()-start,detail:`HTTP ${status} (unsafe redirect rejected)`};47
if(!firstRedirect)firstRedirect=status;48
current=next;49
redirects++;50
continue;51
}52
const good=status===200 && (!git||type.includes('application/x-git-upload-pack-advertisement'));53
const detail=good54
? redirects?`HTTP 200 via ${firstRedirect} (${redirects} same-origin hop${redirects===1?'':'s'}; reachability only)`:'HTTP 200'55
: `HTTP ${status}${redirects?' after safe redirect':''}${git?' (ArchForge advertisement required)':''}`;56
return {success:good,latency:Date.now()-start,detail};57
}58
}catch(e){return {success:false,latency:Date.now()-start,detail:checkError(e)};}59
finally{clearTimeout(timer);}60
}62
function checkTcp(host,port,timeout,ssh=false){return new Promise(resolve=>{63
const start=Date.now();let ended=false,buf='';64
const socket=net.connect({host,port});65
function done(ok,detail){if(ended)return;ended=true;socket.destroy();resolve({success:ok,latency:Date.now()-start,detail});}66
socket.setTimeout(timeout,()=>done(false,'TCP timeout'));67
socket.on('error',e=>done(false,checkError(e)));68
socket.on('connect',()=>{if(!ssh)done(true,'TCP connected');});69
socket.on('data',chunk=>{buf+=(chunk.toString('ascii')).slice(0,256);if(buf.includes('\n'))done(buf.split('\n').some(line=>/^SSH-2\.0-/.test(line)),/^SSH-2\.0-/.test(buf)?'SSH banner received':'Unexpected SSH banner');});70
socket.on('close',()=>done(false,'Connection closed before SSH banner'));71
});}72
async function checkDns(host,timeout){const start=Date.now();try{const result=await Promise.race([dns.lookup(host,{all:true}),new Promise((_,reject)=>setTimeout(()=>reject(Error('DNS timeout')),timeout).unref())]);return {success:result.length>0,latency:Date.now()-start,detail:result.length?'DNS resolved':'No address found'};}catch(e){return {success:false,latency:Date.now()-start,detail:checkError(e)};}}73
function checkTls(host,port,timeout){return new Promise(resolve=>{74
const start=Date.now();let done=false;75
const sock=tls.connect({host,port,servername:host,rejectUnauthorized:true});76
const finish=(success,detail)=>{if(done)return;done=true;sock.destroy();resolve({success,latency:Date.now()-start,detail});};77
sock.setTimeout(timeout,()=>finish(false,'TLS timeout'));78
sock.on('error',err=>finish(false,checkError(err)));79
sock.on('secureConnect',()=>{80
const cert=sock.getPeerCertificate();const days=cert.valid_to?Math.floor((Date.parse(cert.valid_to)-Date.now())/86400000):-1;81
const detail=days<0?'Certificate expired':`Certificate expires in ${days} days`;82
if(done)return;done=true;sock.destroy();resolve({success:days>=0,warning:days>=0&&days<14,latency:Date.now()-start,detail});83
});84
});}85
async function runProbe(id,cfg){switch(id){86
case 'website':return checkHttp(cfg.website,cfg.timeout,{website:true});87
case 'api':return cfg.api?checkHttp(cfg.api,cfg.timeout):{success:false,latency:null,detail:'Not configured'};88
case 'git_http':{89
if(!cfg.gitHttp)return {success:false,latency:null,detail:'Not configured'};90
const u=new URL(cfg.gitHttp);if(!u.pathname.endsWith('/info/refs'))throw Error('ArchForge URL must end with /info/refs');91
if(!u.pathname.endsWith('.archforge/info/refs'))return {success:false,latency:null,detail:'Configure .archforge ArchForge HTTPS URL'};u.searchParams.set('service','git-upload-pack');return checkHttp(u.toString(),cfg.timeout,{git:true});92
}93
case 'git_ssh':return checkTcp(cfg.gitHost,cfg.gitPort,cfg.timeout,true);94
case 'dns':return checkDns(cfg.websiteHost,cfg.timeout);95
case 'tls':return checkTls(cfg.websiteHost,443,cfg.timeout);96
default:throw Error('Unknown service '+id);97
}}98
module.exports={serviceDefinitions,checkHttp,checkTcp,checkDns,checkTls,runProbe};