Repository files, collaboration, releases and project activity.
archforge-external-status / src/server.jsmain
▤ src/server.js16334 bytes
141 lines · Select a line number for a permalink
1
'use strict';2
const http=require('node:http');const fs=require('node:fs');const path=require('node:path');3
const {readConfig}=require('./config');const {openDatabase,rows,one,run,now,syncServices}=require('./database');4
const {serviceDefinitions}=require('./probes');const {startMonitor,uptimeStats,incidentMaintenanceState}=require('./monitor');5
const {notifyQueue,processQueue,unsubscribeToken}=require('./notifications');6
const {esc,cookies,randomToken,sha,safeEqual,verifyPassword,decrypt,verifyTotp,rateLimiter,validEmail}=require('./security');7
const views=require('./views');8
const {presentService}=require('./branding');9
const MAX_POST=16384;10
function makeApp({db,cfg,disableMonitor=false}={}){11
cfg=cfg||readConfig();db=db||openDatabase(cfg.dbPath);12
syncServices(db,serviceDefinitions(cfg));13
const loginLimit=rateLimiter(15*60000,12),subscribeLimit=rateLimiter(3600000,10),modLimit=rateLimiter(60000,40);14
const style=fs.readFileSync(path.resolve(__dirname,'../public/style.css'));15
const base=new URL(cfg.publicUrl).origin;16
const root=cfg.publicUrl.replace(/\/$/,'');17
const send=(res,status,text,{type='text/html; charset=utf-8',headers={}}={})=>{18
res.writeHead(status,{'content-type':type,'cache-control':'no-store','x-content-type-options':'nosniff','referrer-policy':'no-referrer','x-frame-options':'DENY','content-security-policy':"default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",...headers});res.end(text);19
};20
function clientIP(req){const direct=req.socket.remoteAddress||'unknown';if(['127.0.0.1','::1','::ffff:127.0.0.1'].includes(direct)){const x=req.headers['x-forwarded-for'];return typeof x==='string'?x.split(',')[0].trim().slice(0,75):direct;}return direct;}21
function session(req){const token=cookies(req).af_session;if(!token||!/^[-\w]{40,}$/.test(token))return null;return one(db,'SELECT * FROM sessions WHERE token_hash=? AND expires_at>?',sha(token),now())||null;}22
function csrf(req,res,s){if(s)return s.csrf;let token=cookies(req).af_csrf;23
if(!token||!/^[\w-]{32,100}$/.test(token)){token=randomToken(24);res.setHeader('Set-Cookie',`af_csrf=${token}; Path=/; Secure; SameSite=Strict; Max-Age=3600`);}return token;24
}25
function checkCsrf(req,s,data){return !!data.csrf&&safeEqual(data.csrf,s?s.csrf:cookies(req).af_csrf||'');}26
const redirect=(res,url,code=303)=>send(res,code,'',{headers:{location:url}});27
const fail=(res,status,message)=>send(res,status,views.layout('Error',`<div class="page-head"><h1>${esc(status)}</h1><p>${esc(message)}</p><a class="back" href="/">Back to status →</a></div>`));28
async function form(req){return new Promise((resolve,reject)=>{let size=0,chunks=[];req.on('data',c=>{size+=c.length;if(size>MAX_POST){reject(Object.assign(Error('Form too large'),{status:413}));req.destroy();}else chunks.push(c);});req.on('end',()=>{try{const data=new URLSearchParams(Buffer.concat(chunks).toString('utf8'));resolve(Object.fromEntries(data));}catch(e){reject(e);}});req.on('error',reject);});}29
// CSRF is enforced separately on every POST. For browser form submissions,30
// Fetch Metadata is an additional reliable same-origin signal: browsers set31
// Sec-Fetch-Site from the actual request initiator, not from an optional32
// Origin/Referer header that a privacy extension can suppress or rewrite.33
// Requests with cross-site or same-site (but not same-origin) metadata are34
// ALWAYS blocked, even if other headers appear correct.35
function validOriginHeader(value){36
if(typeof value!=='string'||value.length>512||!/^https:\/\/[a-z0-9.\-]+(?::443)?\/?$/i.test(value))return false;37
try{const url=new URL(value);return url.origin===base;}catch{return false;}38
}39
function validRefererHeader(value){40
if(typeof value!=='string'||value.length>2048)return false;41
try{const url=new URL(value);return url.protocol==='https:'&&url.origin===base&&!url.username&&!url.password;}catch{return false;}42
}43
function requireOrigin(req){44
const site=req.headers['sec-fetch-site'];45
if(site!==undefined && site!=='same-origin' && site!=='none')return false;46
// Sec-Fetch-Site: same-origin is browser-generated and only applies when47
// the initiating document is the same origin. A valid, host-only,48
// SameSite=Strict cookie and 192-bit CSRF token are still mandatory.49
if(site==='same-origin')return true;50
const origin=req.headers.origin,referer=req.headers.referer;51
if(origin!==undefined&&!validOriginHeader(origin))return false;52
if(referer!==undefined&&!validRefererHeader(referer))return false;53
// Older clients may omit Fetch Metadata, Origin or Referer. Strict CSRF54
// validation below still prevents unauthorized browser form submissions.55
return true;56
}57
function adminScreen(res,req,s,notice=''){const token=csrf(req,res,s);send(res,200,views.adminDashboard({csrf:token,services:rows(db,'SELECT * FROM services ORDER BY category,label'),incidents:rows(db,'SELECT * FROM incidents ORDER BY started_at DESC LIMIT 20'),maintenance:rows(db,'SELECT * FROM maintenance WHERE ends_at>? ORDER BY starts_at LIMIT 20',now()),queue:one(db,'SELECT COUNT(*) c FROM messages WHERE delivered_at IS NULL AND attempts<8').c,notice}));}58
async function router(req,res){let url;try{url=new URL(req.url,base);}catch{return fail(res,400,'Invalid URL');}59
const p=url.pathname,s=session(req),isAdmin=!!s;60
if(req.method==='GET'){61
if(p==='/assets/style.css')return send(res,200,style,{type:'text/css; charset=utf-8',headers:{'cache-control':'public, max-age=3600'}});62
if(p==='/healthz')return send(res,200,JSON.stringify({ok:true,service:'archforge-external-status',version:require('../package.json').version}),{type:'application/json; charset=utf-8'});63
if(p==='/robots.txt')return send(res,200,'User-agent: *\nDisallow: /admin\nDisallow: /confirm\nDisallow: /unsubscribe\n',{type:'text/plain'});64
if(p==='/'){const days=[7,30,90].includes(Number(url.searchParams.get('days')))?Number(url.searchParams.get('days')):30;65
return send(res,200,views.homepage({services:uptimeStats(db,days),days,incidents:rows(db,'SELECT * FROM incidents ORDER BY started_at DESC LIMIT 12'),maintenance:rows(db,'SELECT * FROM maintenance WHERE ends_at>? ORDER BY starts_at LIMIT 8',now()),config:cfg,admin:isAdmin}));}66
if(p==='/api/status'){const services=rows(db,'SELECT id,label,description,current_state,last_checked FROM services WHERE configured=1 ORDER BY id').map(presentService);const incidents=rows(db,'SELECT id,title,state,severity,started_at FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC');return send(res,200,JSON.stringify({source:'independent-external-monitor',measured_at:now(),services,active_incidents:incidents}),{type:'application/json; charset=utf-8'});}67
const service=p.match(/^\/services\/([a-z_]+)$/);if(service){const days=[7,30,90].includes(Number(url.searchParams.get('days')))?Number(url.searchParams.get('days')):30;const found=uptimeStats(db,days,service[1])[0];return found?send(res,200,views.details(found,rows(db,'SELECT * FROM incidents WHERE service_id=? ORDER BY started_at DESC LIMIT 30',found.id),days,isAdmin)):fail(res,404,'Service not found');}68
const incident=p.match(/^\/incidents\/(\d+)$/);if(incident){const i=one(db,'SELECT * FROM incidents WHERE id=?',Number(incident[1]));return i?send(res,200,views.incidentPage(i,rows(db,'SELECT * FROM incident_updates WHERE incident_id=? ORDER BY created_at,id',i.id),isAdmin)):fail(res,404,'Incident not found');}69
if(p==='/subscribe')return send(res,200,views.subscribe(csrf(req,res,s)));70
const confirmation=p.match(/^\/confirm\/([\w-]{35,150})$/);71
if(confirmation)return send(res,200,views.layout('Confirm email',`<div class="page-head narrow"><h1>Confirm your email</h1><p>Activate incident notifications from the independent status server.</p></div><div class="panel narrow"><form action="${esc(p)}" method="post">${views.formCsrf?views.formCsrf(csrf(req,res,s)):`<input type="hidden" name="csrf" value="${esc(csrf(req,res,s))}">`}<button class="button">Confirm notifications</button></form></div>`));72
const unsubscribe=p.match(/^\/unsubscribe\/([\w-]{35,150})$/);73
if(unsubscribe)return send(res,200,views.layout('Unsubscribe',`<div class="page-head narrow"><h1>Unsubscribe</h1><p>You can stop email notifications at any time.</p></div><div class="panel narrow"><form action="${esc(p)}" method="post"><input type="hidden" name="csrf" value="${esc(csrf(req,res,s))}"><button class="button secondary">Unsubscribe from alerts</button></form></div>`));74
if(p==='/admin/login')return isAdmin?redirect(res,'/admin'):send(res,200,views.adminLogin(csrf(req,res,s)));75
if(p==='/admin')return isAdmin?adminScreen(res,req,s):redirect(res,'/admin/login');76
return fail(res,404,'Page not found');77
}78
if(req.method!=='POST')return fail(res,405,'Method not allowed');79
if(!requireOrigin(req))return fail(res,403,'Invalid origin');80
let data;try{data=await form(req);}catch(e){return fail(res,e.status||400,'Unable to read submitted form');}81
if(!checkCsrf(req,s,data))return fail(res,403,'Invalid or expired form token');82
if(p==='/subscribe'){83
if(data.website)return redirect(res,'/subscribe');84
if(!subscribeLimit(clientIP(req)))return fail(res,429,'Too many requests; please retry later');85
if(!validEmail(data.email))return fail(res,400,'Invalid email');86
if(!cfg.smtpAvailable)return send(res,503,views.subscribe(csrf(req,res,s),{message:'Email subscriptions are temporarily unavailable.'}));87
const email=data.email.trim().toLowerCase(),token=randomToken(32),time=now();88
const record=one(db,'SELECT id,confirmed FROM subscriptions WHERE email=?',email);89
if(!record||!record.confirmed){90
if(record)run(db,'UPDATE subscriptions SET confirm_hash=?,confirm_until=? WHERE id=?',sha(token),time+86400,record.id);91
else run(db,'INSERT INTO subscriptions(email,confirmed,confirm_hash,confirm_until,unsubscribe_hash,created_at) VALUES(?,?,?,?,?,?)',email,0,sha(token),time+86400,sha(unsubscribeToken(cfg,email)),time);92
run(db,'INSERT INTO messages(channel,destination,subject,body,created_at,next_at) VALUES(?,?,?,?,?,?)','email',email,'Confirm ArchForge Status alerts',`Confirm your subscription (valid 24 hours):\n${root}/confirm/${token}\n\nIf you did not request this, ignore this message.`,time,time);93
}94
return send(res,200,views.subscribe(csrf(req,res,s),{message:'If this address can subscribe, a confirmation message will arrive shortly.'}));95
}96
const confirmation=p.match(/^\/confirm\/([\w-]{35,150})$/);97
if(confirmation){const r=one(db,'SELECT id FROM subscriptions WHERE confirm_hash=? AND confirm_until>? AND confirmed=0',sha(confirmation[1]),now());if(!r)return fail(res,410,'Invalid or expired confirmation link');run(db,'UPDATE subscriptions SET confirmed=1,confirm_hash=NULL,confirm_until=NULL WHERE id=?',r.id);return send(res,200,views.layout('Confirmed','<div class="page-head"><h1>Email confirmed</h1><p>Independent status notifications are now enabled.</p><a href="/">Back to status →</a></div>'));}98
const unsubscribe=p.match(/^\/unsubscribe\/([\w-]{35,150})$/);99
if(unsubscribe){run(db,'DELETE FROM subscriptions WHERE unsubscribe_hash=?',sha(unsubscribe[1]));return send(res,200,views.layout('Unsubscribed','<div class="page-head"><h1>Unsubscribed</h1><p>You will no longer receive status email notifications.</p><a href="/">Back to status →</a></div>'));}100
if(p==='/admin/login'){101
if(!loginLimit(clientIP(req)+'/'+String(data.username).slice(0,80).toLowerCase()))return fail(res,429,'Too many login attempts');102
const admin=one(db,'SELECT * FROM admin WHERE id=1'),valid=admin&&data.username===admin.username&&verifyPassword(data.password||'',admin.password_hash)&&verifyTotp(decrypt(cfg.key,admin.totp_encrypted),data.totp||'');103
if(!valid)return send(res,401,views.adminLogin(csrf(req,res,s),'Invalid credentials or authenticator code'));104
const token=randomToken(40);run(db,'INSERT INTO sessions(token_hash,csrf,expires_at,created_at) VALUES(?,?,?,?)',sha(token),randomToken(32),now()+12*3600,now());105
run(db,'INSERT INTO audit(event,created_at) VALUES(?,?)','Admin login',now());106
res.setHeader('Set-Cookie',`af_session=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200`);107
return redirect(res,'/admin');108
}109
if(!isAdmin)return fail(res,401,'Admin login required');110
if(!modLimit(clientIP(req)))return fail(res,429,'Too many admin requests');111
if(p==='/admin/logout'){run(db,'DELETE FROM sessions WHERE token_hash=?',s.token_hash);res.setHeader('Set-Cookie','af_session=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0');return redirect(res,'/admin/login');}112
if(p==='/admin/incidents'){113
const title=String(data.title||'').trim(),description=String(data.description||'').trim(),severity=data.severity,service=String(data.service||'');114
if(!title||title.length>140||!description||description.length>3000||!['degraded','outage'].includes(severity)||(service&&!one(db,'SELECT id FROM services WHERE id=?',service)))return fail(res,400,'Invalid incident fields');115
const time=now(),insert=run(db,'INSERT INTO incidents(service_id,title,description,state,severity,automatic,started_at) VALUES(?,?,?,?,?,?,?)',service||null,title,description,'investigating',severity,0,time);116
run(db,'INSERT INTO incident_updates(incident_id,state,body,created_at) VALUES(?,?,?,?)',Number(insert.lastInsertRowid),'investigating',description,time);117
notifyQueue(db,cfg,'ArchForge incident: '+title,description+'\n\n'+root);118
run(db,'INSERT INTO audit(event,created_at) VALUES(?,?)','Incident published '+insert.lastInsertRowid,time);119
return redirect(res,'/admin');120
}121
const update=p.match(/^\/admin\/incidents\/(\d+)\/update$/);122
if(update){const i=one(db,'SELECT * FROM incidents WHERE id=?',Number(update[1]));if(!i||i.resolved_at)return fail(res,404,'Active incident not found');const state=data.state,body=String(data.body||'').trim();if(!['investigating','identified','monitoring','resolved'].includes(state)||!body||body.length>3000)return fail(res,400,'Invalid incident update');123
run(db,'UPDATE incidents SET state=?,resolved_at=? WHERE id=?',state,state==='resolved'?now():null,i.id);124
run(db,'INSERT INTO incident_updates(incident_id,state,body,created_at) VALUES(?,?,?,?)',i.id,state,body,now());notifyQueue(db,cfg,`ArchForge incident ${state}: ${i.title}`,body+'\n\n'+root+'/incidents/'+i.id);return redirect(res,'/admin');125
}126
if(p==='/admin/maintenance'){127
const title=String(data.title||'').trim(),description=String(data.description||'').trim(),start=Date.parse(data.start+'Z')/1000,end=Date.parse(data.end+'Z')/1000,service=data.service||'';128
if(!title||title.length>140||!description||description.length>3000||!Number.isFinite(start)||!Number.isFinite(end)||end<=start||end-start>30*86400||start<now()-3600||(service&&!one(db,'SELECT id FROM services WHERE id=?',service)))return fail(res,400,'Invalid maintenance window');129
run(db,'INSERT INTO maintenance(title,description,starts_at,ends_at,service_id,created_at) VALUES(?,?,?,?,?,?)',title,description,Math.floor(start),Math.floor(end),service||null,now());notifyQueue(db,cfg,'Scheduled maintenance: '+title,description+'\nStarting: '+new Date(start*1000).toISOString()+'\n'+root);return redirect(res,'/admin');130
}131
const remove=p.match(/^\/admin\/maintenance\/(\d+)\/delete$/);132
if(remove){run(db,'DELETE FROM maintenance WHERE id=?',Number(remove[1]));return redirect(res,'/admin');}133
return fail(res,404,'Not found');134
}135
const server=http.createServer((req,res)=>router(req,res).catch(err=>{console.error('Request error',err?.message);if(!res.headersSent)fail(res,500,'Internal server error');else res.destroy();}));136
const monitor=disableMonitor?null:startMonitor(db,cfg);137
return {server,db,cfg,monitor,close:async()=>{monitor?.stop();await new Promise(r=>server.close(r));db.close();}};138
}139
if(require.main===module){const cfg=readConfig();const app=makeApp({cfg});app.server.listen(cfg.port,cfg.host,()=>console.log(`Independent status listening on ${cfg.host}:${cfg.port}`));}140
module.exports={makeApp};