igneum/app/igneum-app/src/server.rs
igneum-labs 1028c2579f Miner dev fee: app switch and UI line, HiveOS package, Linux worker cross-build, docs and public text
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>
2026-10-04 21:46:41 +00:00

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()),
}
}