Decision of 4 October 2026 (evening): the Igneum Miner software takes a visible, switchable 1% dev fee, the norm for GPU miners; the protocol stays fee-free. The miner side (--dev-fee, the 1-in-100 template counter, the audit command) is on branch dev-fee of the node fork. - app: settings.dev_fee (default on) passes --dev-fee 0 to igneum-miner when off; Settings shows the miner's own "dev fee 1% (1 block in 100) to 0x..." line next to the rewards address with a switch; the engine parses the miner's start line and its dev-fee block lines (fee_session, lifetime fee_total, an event per fee block) - packaging/hive: h-manifest.conf, h-config.sh, h-run.sh, h-stats.sh, make-hive-package.sh (igneum-hive-<v>.tar.gz with the Linux igneumd, igneum-miner and both GPU workers), README with the Flight Sheet, selftest.sh (bash -n, stub binaries, the three hooks the way Hive runs them, the stats JSON parsed). Hive itself is untested - infra/cross/build-workers-linux.sh: the NVRTC and OpenCL workers cross-compiled for Linux with zig; proto-opencl/cl_dynamic.h gains the Linux dlopen branch (libOpenCL.so.1) - tools/dev-fee/run.mjs: the fee-block test network (two nodes on 29900+, three CPU miners, payouts audit) - docs/design/miner-dev-fee.md (mechanism, flag, lines, the DEV_FEE_ADDRESS placeholder and the devnet address), docs/fud-ledger.md E18 and the E5/L9 status line, litepaper "What a miner's hour looks like" paragraph, homepage miner section note Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
318 lines
14 KiB
Rust
318 lines
14 KiB
Rust
//! 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/<token>/...`); 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<Shared>) -> std::io::Result<u16> {
|
|
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<u8>,
|
|
origin: Option<String>,
|
|
sec_fetch_site: Option<String>,
|
|
host: Option<String>,
|
|
}
|
|
|
|
fn read_request(stream: &mut TcpStream) -> Option<Req> {
|
|
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<String> {
|
|
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<Shared>) {
|
|
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<Shared>, path: &str, body: Value) -> Result<Value, String> {
|
|
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<crate::engine::CardChoice> = 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()),
|
|
}
|
|
}
|