Skip to content

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';
2const http=require('node:http');const fs=require('node:fs');const path=require('node:path');
3const {readConfig}=require('./config');const {openDatabase,rows,one,run,now,syncServices}=require('./database');
4const {serviceDefinitions}=require('./probes');const {startMonitor,uptimeStats,incidentMaintenanceState}=require('./monitor');
5const {notifyQueue,processQueue,unsubscribeToken}=require('./notifications');
6const {esc,cookies,randomToken,sha,safeEqual,verifyPassword,decrypt,verifyTotp,rateLimiter,validEmail}=require('./security');
7const views=require('./views');
8const {presentService}=require('./branding');
9const MAX_POST=16384;
10function 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 set
31 // Sec-Fetch-Site from the actual request initiator, not from an optional
32 // Origin/Referer header that a privacy extension can suppress or rewrite.
33 // Requests with cross-site or same-site (but not same-origin) metadata are
34 // 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 when
47 // 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 CSRF
54 // 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();}};
139if(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}`));}
140module.exports={makeApp};