//! The local web server: static dashboard files from the binary and a JSON API, on 127.0.0.1 with a random port. //! Every path carries the per-launch token (`/t//...`); anything else is a 404, so another local user cannot //! drive the miner. Plain HTTP/1.1, one thread per connection, Connection: close. use crate::engine::{Cmd, Shared}; use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; const INDEX: &str = include_str!("../ui/index.html"); const CSS: &str = include_str!("../ui/app.css"); const JS: &str = include_str!("../ui/app.js"); const MARK: &str = include_str!("../ui/mark.svg"); const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png"); const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2"); const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2"); const FONT_SANS_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexSans-400.woff2"); const FONT_SANS_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexSans-500.woff2"); const FONT_SANS_600: &[u8] = include_bytes!("../ui/fonts/IBMPlexSans-600.woff2"); const FONT_UNB_500: &[u8] = include_bytes!("../ui/fonts/Unbounded-500.woff2"); const FONT_UNB_700: &[u8] = include_bytes!("../ui/fonts/Unbounded-700.woff2"); const FONT_UNB_900: &[u8] = include_bytes!("../ui/fonts/Unbounded-900.woff2"); pub fn start(shared: Arc) -> std::io::Result { let listener = TcpListener::bind("127.0.0.1:0")?; let port = listener.local_addr()?.port(); shared.set_port(port); std::thread::spawn(move || { for conn in listener.incoming() { let Ok(stream) = conn else { continue }; let shared = shared.clone(); std::thread::spawn(move || handle(stream, shared)); } }); Ok(port) } struct Req { method: String, path: String, query: String, body: Vec, origin: Option, sec_fetch_site: Option, host: Option, } fn read_request(stream: &mut TcpStream) -> Option { let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); let mut reader = BufReader::new(stream.try_clone().ok()?); let mut line = String::new(); reader.read_line(&mut line).ok()?; let mut parts = line.split_whitespace(); let method = parts.next()?.to_string(); let target = parts.next()?.to_string(); let mut content_length = 0usize; let (mut origin, mut sec_fetch_site, mut host) = (None, None, None); loop { let mut h = String::new(); reader.read_line(&mut h).ok()?; let h = h.trim_end(); if h.is_empty() { break; } if let Some((k, v)) = h.split_once(':') { let v = v.trim(); if k.eq_ignore_ascii_case("content-length") { content_length = v.parse().unwrap_or(0); } else if k.eq_ignore_ascii_case("origin") { origin = Some(v.to_string()); } else if k.eq_ignore_ascii_case("sec-fetch-site") { sec_fetch_site = Some(v.to_ascii_lowercase()); } else if k.eq_ignore_ascii_case("host") { host = Some(v.to_string()); } } } if content_length > 1 << 20 { return None; } let mut body = vec![0u8; content_length]; if content_length > 0 { reader.read_exact(&mut body).ok()?; } let (path, query) = match target.split_once('?') { Some((p, q)) => (p.to_string(), q.to_string()), None => (target, String::new()), }; Some(Req { method, path, query, body, origin, sec_fetch_site, host }) } /// R4.3.7: a mutating request must come from the dashboard itself. A browser sends Sec-Fetch-Site (same-origin for /// the dashboard; cross-site, same-site or none otherwise) and, on POST, an Origin; a cross-site page can reach /// 127.0.0.1 only through the browser, so both are checked. A request without either header (curl, the window host) /// still needs the token in the path. fn from_dashboard(req: &Req, port: u16) -> bool { if let Some(s) = &req.sec_fetch_site { if s != "same-origin" && s != "none" { return false; } } if let Some(o) = &req.origin { let ok = o == &format!("http://127.0.0.1:{port}") || o == &format!("http://localhost:{port}"); if !ok { return false; } } if let Some(h) = &req.host { if h != &format!("127.0.0.1:{port}") && h != &format!("localhost:{port}") { return false; } } true } fn respond(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], cache: bool) { let reason = match status { 200 => "OK", 204 => "No Content", 400 => "Bad Request", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", _ => "Error", }; let head = format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n\r\n", body.len(), if cache { "public, max-age=86400" } else { "no-store" } ); let _ = stream.write_all(head.as_bytes()); let _ = stream.write_all(body); let _ = stream.flush(); } fn json_resp(stream: &mut TcpStream, status: u16, v: Value) { respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false); } fn query_param(q: &str, key: &str) -> Option { q.split('&').find_map(|kv| { let (k, v) = kv.split_once('=')?; if k == key { Some(v.to_string()) } else { None } }) } fn handle(mut stream: TcpStream, shared: Arc) { let Some(req) = read_request(&mut stream) else { return }; let prefix = format!("/t/{}", shared.token); if req.path == "/" || req.path == format!("/t/{}", shared.token) { // a bare root or the token without a slash: redirect-free hint, keep it a 404 for the root (no token leak) if req.path == "/" { respond(&mut stream, 404, "text/plain", b"Igneum Miner: open the app window.", false); return; } } let Some(rest) = req.path.strip_prefix(&prefix) else { respond(&mut stream, 404, "text/plain", b"not found", false); return; }; let rest = if rest.is_empty() { "/" } else { rest }; match (req.method.as_str(), rest) { ("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false), ("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false), ("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false), ("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true), ("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true), ("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true), ("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true), ("GET", "/fonts/IBMPlexSans-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_400, true), ("GET", "/fonts/IBMPlexSans-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_500, true), ("GET", "/fonts/IBMPlexSans-600.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_600, true), ("GET", "/fonts/Unbounded-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_500, true), ("GET", "/fonts/Unbounded-700.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_700, true), ("GET", "/fonts/Unbounded-900.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_900, true), ("GET", "/api/state") => { let v = shared.state_json(); json_resp(&mut stream, 200, v); } ("GET", "/api/log") => { let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64); let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000); let lines = shared.rings.lock().unwrap().since(after, limit); json_resp(&mut stream, 200, json!({ "lines": lines })); } ("POST", p) => { if !from_dashboard(&req, shared.port()) { json_resp(&mut stream, 403, json!({ "ok": false, "error": "not from the dashboard" })); return; } let body: Value = if req.body.is_empty() { json!({}) } else { serde_json::from_slice(&req.body).unwrap_or(json!({})) }; let out = api_post(&shared, p, body); match out { Ok(v) => json_resp(&mut stream, 200, v), Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })), } } _ => respond(&mut stream, 404, "text/plain", b"not found", false), } } fn api_post(shared: &Arc, path: &str, body: Value) -> Result { let s = |k: &str| body.get(k).and_then(|v| v.as_str()).map(|v| v.to_string()); match path { "/api/detect" => { shared.send(Cmd::Detect); Ok(json!({ "ok": true })) } "/api/phase" => { let phase = s("phase").ok_or("phase missing")?; shared.state.lock().unwrap().phase = phase; Ok(json!({ "ok": true })) } "/api/setup" => { let mode = s("mode").unwrap_or_else(|| "generate".into()); let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32); let out = shared.setup(&mode, s("address").as_deref(), identities)?; Ok(out) } "/api/key/saved" => { shared.key_saved(); Ok(json!({ "ok": true })) } "/api/key/reveal" => shared.reveal_key(), "/api/start" => { shared.send(Cmd::Start); Ok(json!({ "ok": true })) } "/api/pause" => { shared.send(Cmd::Pause); Ok(json!({ "ok": true })) } "/api/resume" => { shared.send(Cmd::Resume); Ok(json!({ "ok": true })) } "/api/cards" => { let list = body.get("cards").and_then(|v| v.as_array()).ok_or("cards missing")?; let choices: Vec = list .iter() .filter_map(|c| { Some(crate::engine::CardChoice { key: c.get("key")?.as_str()?.to_string(), enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), identities: c.get("identities").and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 64) as u32, power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|v| v.clamp(60, 100) as u32), }) }) .collect(); shared.send(Cmd::ApplyCards(choices)); Ok(json!({ "ok": true })) } "/api/settings" => { let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32); let vote = body.get("vote").and_then(|v| v.as_bool()); let login = body.get("start_at_login").and_then(|v| v.as_bool()); let address = s("address"); let display_name = s("display_name"); let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool()); shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee) } "/api/prove" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)), "/api/prove/setup" => crate::prover::setup(shared), "/api/update/check" => { shared.send(Cmd::CheckUpdate); Ok(json!({ "ok": true })) } "/api/update/install" => { shared.send(Cmd::InstallUpdate); Ok(json!({ "ok": true })) } "/api/update/auto" => { let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; shared.send(Cmd::AutoUpdate(on)); Ok(json!({ "ok": true })) } "/api/update/open" => { shared.send(Cmd::OpenUpdateFile); Ok(json!({ "ok": true })) } "/api/jobs/allow" => { let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; shared.send(Cmd::JobsAllow(on)); Ok(json!({ "ok": true })) } "/api/jobs/check" => { shared.send(Cmd::JobsCheck); Ok(json!({ "ok": true })) } "/api/open" => { let url = s("url").ok_or("url missing")?; if url.starts_with("https://") || url.starts_with("http://") { crate::platform::open_url(&url); Ok(json!({ "ok": true })) } else { Err("only http(s) links open".into()) } } "/api/power/apply" => { shared.send(Cmd::ApplyPower); Ok(json!({ "ok": true })) } "/api/clock/sync" => { shared.send(Cmd::ClockSync); Ok(json!({ "ok": true })) } "/api/clock/check" => { shared.send(Cmd::ClockCheck); Ok(json!({ "ok": true })) } "/api/quit" => { shared.send(Cmd::Quit); Ok(json!({ "ok": true })) } _ => Err("unknown api".into()), } }