From 456eff08d515e111683c05cb87873ee2b02509d3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:58:23 +0000 Subject: [PATCH] Igneum Miner round-4 fixes: the dashboard token is never logged and token lines never upload (R4.3.8); every helper by absolute path and Program Files read-only, fallback worker build under the app data folder (R4.3.3); the download and the staged bundle re-verified right before the swap or the elevated run, quarantine stripped only after that (R4.3.5); mutating POSTs refused unless same-origin (R4.3.7); no rollback below min_supported_version and no automatic re-apply of a failed version (R4.3.6); the signed manifest's consensus.override written to override.json for --override-params-file with a safe-moment node restart and 'consensus switch at DAA N' on the node tile, an old node that rejects the file runs without it; devnet vote-key note in the key sheet and READMEs Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/detect.rs | 12 +- app/igneum-app/src/engine.rs | 121 +++++++++++++++----- app/igneum-app/src/main.rs | 4 +- app/igneum-app/src/manifest.rs | 68 +++++++++++ app/igneum-app/src/ota.rs | 176 +++++++++++++++++++++++++---- app/igneum-app/src/platform.rs | 119 ++++++++++++++++--- app/igneum-app/src/server.rs | 45 +++++++- app/igneum-app/src/state.rs | 3 + app/igneum-app/src/update.rs | 9 +- app/igneum-app/ui/app.js | 3 +- app/igneum-app/ui/index.html | 1 + app/windows/host.cpp | 5 +- packaging/mac/README.md | 3 + packaging/windows/Igneum-Miner.iss | 5 +- packaging/windows/README.md | 4 + 15 files changed, 496 insertions(+), 82 deletions(-) diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 318ec820a..fd2e1b521 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -108,7 +108,7 @@ pub fn nvidia_power_limits() -> std::collections::HashMap std::collections::HashMap { let mut out = std::collections::HashMap::new(); - let Some(text) = run_timeout(Command::new("nvidia-smi").args(["--query-gpu=index,power.default_limit,power.limit,power.min_limit,power.max_limit", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)) else { return out }; + let Some(text) = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,power.default_limit,power.limit,power.min_limit,power.max_limit", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)) else { return out }; for line in text.lines() { let p: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); if p.len() >= 5 { @@ -138,7 +138,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1"); // cores and memory from system_profiler (a second or two); optional let mut detail = String::new(); - if let Some(sp) = run_timeout(Command::new("system_profiler").args(["SPDisplaysDataType", "-json"]), None, Duration::from_secs(8)) { + if let Some(sp) = run_timeout(Command::new(crate::platform::tool("system_profiler")).args(["SPDisplaysDataType", "-json"]), None, Duration::from_secs(8)) { if let Ok(v) = serde_json::from_str::(&sp) { if let Some(g) = v.get("SPDisplaysDataType").and_then(|a| a.as_array()).and_then(|a| a.first()) { if let Some(c) = g.get("sppci_cores").and_then(|c| c.as_str()) { @@ -147,7 +147,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { } } } - if let Some(mem) = run_timeout(Command::new("sysctl").args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { + if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { if let Ok(b) = mem.trim().parse::() { let gb = b / (1024 * 1024 * 1024); detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") }; @@ -159,7 +159,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { let mut c = card(0, &name, "apple", "Metal", &detail, ""); c.kind = "apple".into(); c.path = "prebuilt".into(); - if let Some(mem) = run_timeout(Command::new("sysctl").args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { + if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { c.vram_mb = mem.trim().parse::().map(|b| b / (1024 * 1024)).unwrap_or(0); } apply_defaults(&mut c); @@ -171,7 +171,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { let mut cards: Vec = Vec::new(); // NVIDIA: nvidia-smi ships with the driver - let smi = run_timeout(Command::new("nvidia-smi").args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10)); + let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10)); match smi { Some(out) => { for line in out.lines() { @@ -241,7 +241,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { } if cards.is_empty() { // last resort: the names Windows knows, so the screen can at least say what is in the PC - if let Some(out) = run_timeout(Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) { + if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) { for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" }; let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0"); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index f83a291d1..a7d42cd16 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -73,6 +73,7 @@ pub struct Shared { cmd_tx: Mutex>, pub started: Instant, engine_log: Mutex>, + port: std::sync::atomic::AtomicU16, } impl Shared { @@ -112,14 +113,23 @@ impl Shared { cmd_tx: Mutex::new(cmd_tx), started: Instant::now(), engine_log: Mutex::new(engine_log), + port: std::sync::atomic::AtomicU16::new(0), } } + pub fn set_port(&self, p: u16) { + self.port.store(p, std::sync::atomic::Ordering::Relaxed); + } + pub fn port(&self) -> u16 { + self.port.load(std::sync::atomic::Ordering::Relaxed) + } + pub fn send(&self, c: Cmd) { let _ = self.cmd_tx.lock().unwrap().send(c); } pub fn log(&self, text: &str) { + let text = &crate::platform::redact(text); let stamp = crate::platform::unix_now_f(); if let Some(f) = self.engine_log.lock().unwrap().as_mut() { let _ = writeln!(f, "{stamp:.0} {text}"); @@ -128,6 +138,7 @@ impl Shared { } pub fn event(&self, kind: &str, text: &str) { + let text = &crate::platform::redact(text); self.rings.lock().unwrap().event(kind, text); self.log(&format!("[{kind}] {text}")); } @@ -370,6 +381,10 @@ pub struct Engine { power_restore_pending: bool, /// an elevated step handed to the window host: (command line, what, requested watts per device, since) power_via_host: Option<(String, String, std::collections::HashMap, Instant)>, + /// the manifest's consensus override changed: restart the node at a safe moment + node_override_restart: bool, + /// this node build refused the override file (an older igneumd without the field): start without it + node_override_unusable: bool, stability: std::collections::HashMap, last_stability: Instant, last_settings_save: Instant, @@ -439,6 +454,7 @@ impl Engine { power_busy: false, power_restore_pending: false, power_via_host: None, + node_override_restart: false, node_override_unusable: false, stability: std::collections::HashMap::new(), last_stability: now, @@ -582,17 +598,6 @@ impl Engine { } } Cmd::Ota(ev) => self.ota.event(&self.shared, ev), - Cmd::WorkerBuilt(card, result) => { - if let Some(m) = self.miners.iter_mut().find(|m| m.card == card) { - m.building = false; - match result { - Ok(p) => { - if p != m.worker { - self.shared.event("build", "GPU worker built from source"); - } - m.worker = p; - m.needs_rebuild = false; - m.prepared = true; Cmd::Job(ev) => { if let Some(a) = self.jobs.event(&self.shared, ev) { self.job_action(a); @@ -604,6 +609,17 @@ impl Engine { self.job_action(a); } } + Cmd::WorkerBuilt(card, result) => { + if let Some(m) = self.miners.iter_mut().find(|m| m.card == card) { + m.building = false; + match result { + Ok(p) => { + if p != m.worker { + self.shared.event("build", "GPU worker built from source"); + } + m.worker = p; + m.needs_rebuild = false; + m.prepared = true; m.restart_at = Some(Instant::now()); } Err(e) => { @@ -800,6 +816,13 @@ impl Engine { /// activation height) written to /override-params.json for --override-params-file. None when the /// package pins nothing, so the node runs on the network's defaults as before. fn node_override_file(&self) -> Option { + if self.node_override_unusable { + return None; + } + // the signed OTA manifest's consensus override wins over the packager's pin (src/ota.rs write_override) + if let Some(p) = self.ota.override_path() { + return Some(p); + } let v = self.shared.packaged.node_override_params.as_ref()?; if v.as_object().map(|o| o.is_empty()).unwrap_or(true) { return None; @@ -1135,7 +1158,7 @@ impl Engine { if (c.power_limit_w - watts).abs() < 1.0 && c.power_applied { continue; } - cmds.push(format!("nvidia-smi -i {} -pl {}", c.device, watts as u64)); + cmds.push(format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, watts as u64)); what.push(format!("{} {} W ({}% of {} W)", c.name, watts as u64, pct, c.power_default_w as u64)); c.power_note = "setting the power cap (administrator prompt)".into(); } @@ -1186,7 +1209,7 @@ impl Engine { .cards .iter() .filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0) - .map(|c| format!("nvidia-smi -i {} -pl {}", c.device, c.power_before_w.round() as u64)) + .map(|c| format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, c.power_before_w.round() as u64)) .collect(); drop(st); if cmds.is_empty() { @@ -1214,7 +1237,7 @@ impl Engine { if self.telemetry.is_none() && now >= self.telemetry_retry_at { let args: Vec = ["--query-gpu=index,power.draw,temperature.gpu,temperature.memory,power.limit", "--format=csv,noheader,nounits", "-l", "5"].iter().map(|s| s.to_string()).collect(); let log = self.shared.runtime.log_dir.join(format!("gpu-{}.log", self.stamp)); - match procs::spawn(Source::Telemetry, std::path::Path::new("nvidia-smi"), &args, None, &log, &self.lines_tx) { + match procs::spawn(Source::Telemetry, &crate::platform::tool("nvidia-smi"), &args, None, &log, &self.lines_tx) { Ok(p) => self.telemetry = Some(p), Err(_) => self.telemetry_retry_at = now + Duration::from_secs(300), } @@ -1358,19 +1381,31 @@ impl Engine { if let Some(crate::ota::Action::Apply) = self.ota.tick(&self.shared, &ctx) { self.apply_update(); } + if let Some(p) = self.ota.take_override_change() { + self.shared.log(&format!("consensus override changed ({}); the node restarts with it at a safe moment", p.display())); + self.node_override_restart = true; + } + if self.node_override_restart && self.node.is_some() && !self.node_external { + // between hourly boundaries unless the switch is close + let (eta, daa) = { let st = self.st(); (st.program.eta_s, st.node.daa) }; + let urgent = crate::manifest::fork_is_close(Some(self.ota.override_daa()).filter(|h| *h > 0), daa); + let safe = eta > crate::manifest::BOUNDARY_GUARD_S && eta < 3600 - crate::manifest::BOUNDARY_GUARD_S; + if urgent || safe || self.st().node.daa == 0 { + self.node_override_restart = false; + self.st().node.override_restart_wait = String::new(); + self.shared.event("info", "restarting the node with the new consensus parameters"); + self.stop_miners("consensus parameters changed"); + self.stop_node(); + self.start_node(); + for m in self.miners.iter_mut() { + m.restart_at = Some(Instant::now() + Duration::from_secs(5)); + } + } else { + self.st().node.override_restart_wait = format!("node restarts with the new consensus parameters after the hour boundary ({} s)", eta); + } + } } - /// Hands over to the update helper, then leaves through the quit path (miners first, then the node, the last - /// log upload, EXIT for the window). The helper waits for this process to end before it swaps the app. - fn apply_update(&mut self) { - if self.quitting { - return; - } - let v = self.ota.version(); - match self.ota.launch_apply(&self.shared, self.host_pid()) { - Ok(()) => { - { - let mut st = self.st(); /// The remote-job runner (src/jobrun.rs): polls and runs on its own threads; this tick starts queued jobs and /// does what a job asks of the engine (miners stopped and held, a restart, the updater). fn tick_jobs(&mut self) { @@ -1433,6 +1468,17 @@ impl Engine { } } + /// Hands over to the update helper, then leaves through the quit path (miners first, then the node, the last + /// log upload, EXIT for the window). The helper waits for this process to end before it swaps the app. + fn apply_update(&mut self) { + if self.quitting { + return; + } + let v = self.ota.version(); + match self.ota.launch_apply(&self.shared, self.host_pid()) { + Ok(()) => { + { + let mut st = self.st(); st.update.applying = true; st.update.status = "applying".into(); st.update.wait = String::new(); @@ -1478,6 +1524,15 @@ impl Engine { let ran = n.started.elapsed().as_secs(); let tail = tail_of(&n.log_path, 5); self.node = None; + // an igneumd that does not know a field in the override file dies at once: run it without the file + // rather than loop (the app update that carries the newer node fixes it) + if ran < 10 && !self.node_override_unusable && tail.iter().any(|l| l.contains("override params file")) { + self.node_override_unusable = true; + self.shared.event("error", "this node build does not understand the consensus parameters in the signed manifest; starting it without them (an app update carries the newer node)"); + self.st().node.override_restart_wait = "override not applied: the bundled node is too old for it".into(); + self.start_node(); + return; + } if ran < 5 && self.node_starts == 1 { // out at once: a port in use or an older database; say so and try once more, later self.shared.event("error", &format!("igneumd exited at once (code {code}). {}", tail.last().cloned().unwrap_or_default())); @@ -2246,10 +2301,20 @@ fn build_worker_from_source(shared: &Arc, bins: &Bins, vendor: &str) -> } else { ("proto-opencl", "igneum-bench-cl-devnet.exe", "build.bat devnet") }; - let dir = bins.dir.join(sub); - if !dir.join("build.bat").exists() { + // the sources ship under Program Files (read-only, R4.3.3); the build runs under the app data folder + let src = bins.dir.join(sub); + if !src.join("build.bat").exists() { return Err(format!("{sub}\\build.bat is not installed; the prebuilt worker is missing too")); } + let dir = shared.runtime.app_dir.join("build").join(sub); + let _ = std::fs::create_dir_all(&dir); + if let Ok(rd) = std::fs::read_dir(&src) { + for e in rd.flatten() { + if e.path().is_file() { + let _ = std::fs::copy(e.path(), dir.join(e.file_name())); + } + } + } // the pack where build.bat expects it let dest = dir.join("packs").join("devnet"); let _ = std::fs::create_dir_all(&dest); @@ -2276,7 +2341,7 @@ fn build_worker_from_source(shared: &Arc, bins: &Bins, vendor: &str) -> let vc = vcvars.ok_or("Visual Studio with the MSVC v143 x64 component is not installed (vcvarsall.bat not found)")?; let line = format!("\"{}\" x64 -vcvars_ver=14.30 >nul 2>&1 && cd /d \"{}\" && {}", vc.display(), dir.display(), cmd); shared.log(&format!("building the {sub} worker: cmd /s /c \"{line}\"")); - let out = crate::detect::run_timeout(Command::new("cmd").args(["/s", "/c", &format!("\"{line}\"")]), None, Duration::from_secs(900)).unwrap_or_default(); + let out = crate::detect::run_timeout(Command::new(crate::platform::tool("cmd")).args(["/s", "/c", &format!("\"{line}\"")]), None, Duration::from_secs(900)).unwrap_or_default(); for l in out.lines().rev().take(8).collect::>().into_iter().rev() { shared.log(&format!(" build: {l}")); } diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index e06de70a9..5737a0882 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -94,12 +94,14 @@ fn main() { std::process::exit(1); } }; + shared.set_port(port); let url = format!("http://127.0.0.1:{port}/t/{token}/"); // the URL file lets a second launch or a support script find the window; user-only permissions let url_file = shared.runtime.app_dir.join("app.url"); let _ = std::fs::write(&url_file, &url); platform::lock_permissions(&url_file, false); - shared.log(&format!("dashboard at {url} (log {})", stamp_file.display())); + // the token is never logged (the logs are uploaded); app.url, 0600, is the one place it is written + shared.log(&format!("dashboard listening on 127.0.0.1:{port} (the URL with its token is in app.url; log {})", stamp_file.display())); if wrapper || args.iter().any(|a| a == "--print-url") { println!("URL {url}"); let _ = std::io::stdout().flush(); diff --git a/app/igneum-app/src/manifest.rs b/app/igneum-app/src/manifest.rs index 890a72ca0..3346bc661 100644 --- a/app/igneum-app/src/manifest.rs +++ b/app/igneum-app/src/manifest.rs @@ -50,6 +50,9 @@ pub struct Manifest { pub notes: String, pub activation_height: Option, pub deadline_note: String, + /// consensus.override: the exact object the engine writes to /override.json for igneumd's + /// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest. + pub override_params: Option, } impl Manifest { @@ -149,6 +152,11 @@ pub fn parse(text: &str) -> Result { notes: s(&v, "notes"), activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()), deadline_note: s(&consensus, "deadline_note"), + override_params: match consensus.get("override") { + Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()), + Some(o) if !o.is_null() => return Err("consensus.override must be an object".into()), + _ => None, + }, }) } @@ -159,6 +167,40 @@ pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> parse(text) } +/// A digest of a whole directory (the staged app bundle): sha256 over "relative path\nsha256 of the file\n" for every +/// regular file in byte-sorted path order (symlinks skipped). The macOS helper recomputes the same with find, sort +/// and shasum right before the swap (R4.3.5). +pub fn digest_dir(root: &std::path::Path) -> std::io::Result { + fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec) -> std::io::Result<()> { + for e in std::fs::read_dir(dir)? { + let e = e?; + let ft = e.file_type()?; + let p = e.path(); + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + walk(&p, root, out)?; + } else if ft.is_file() { + out.push(p.strip_prefix(root).unwrap_or(&p).to_string_lossy().replace('\\', "/")); + } + } + Ok(()) + } + let mut files = Vec::new(); + walk(root, root, &mut files)?; + files.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes())); + let mut h = Sha256::new(); + for f in files { + let sum = sha256_file(&root.join(&f))?; + h.update(f.as_bytes()); + h.update(b"\n"); + h.update(sum.as_bytes()); + h.update(b"\n"); + } + Ok(hex_encode(&h.finalize())) +} + /// SHA-256 of a file, streamed, as hex. pub fn sha256_file(path: &std::path::Path) -> std::io::Result { use std::io::Read; @@ -293,6 +335,32 @@ mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; + /// The helper's bash recipe (find | sort | shasum per file | shasum) must equal digest_dir. + #[test] + #[cfg(target_os = "macos")] + fn digest_dir_matches_the_helper() { + let root = std::env::temp_dir().join(format!("igneum-digest-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("Contents/MacOS")).unwrap(); + std::fs::write(root.join("Contents/MacOS/igneum-app"), b"engine").unwrap(); + std::fs::write(root.join("Contents/Info.plist"), b"").unwrap(); + std::fs::write(root.join("Contents/zed.txt"), b"z").unwrap(); + let ours = digest_dir(&root).unwrap(); + let recipe = r#"(cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1"#; + let out = std::process::Command::new("/bin/bash").args(["-c", recipe, "x", &root.display().to_string()]).output().unwrap(); + let theirs = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let _ = std::fs::remove_dir_all(&root); + assert_eq!(ours, theirs); + } + + #[test] + fn consensus_override_parses() { + let m = parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"activation_height":5000,"override":{"difficulty_v2_activation_daa":5000}}}"#).unwrap(); + assert_eq!(m.activation_height, Some(5000)); + assert_eq!(m.override_params.unwrap()["difficulty_v2_activation_daa"], 5000); + assert!(parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"override":"no"}}"#).is_err()); + } + const SAMPLE: &str = r#"{"channel":"devnet","consensus":{"activation_height":120000,"deadline_note":"difficulty v2"},"min_supported_version":"0.3.0","notes":"difficulty v2 at height 120000","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":20588331,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-0.3.1.dmg"},"windows":{"kind":"inno-setup","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":43978429,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-Setup-0.3.1.exe"}},"published_at":"2026-10-04T13:00:00Z","version":"0.3.1"}"#; fn key() -> (SigningKey, String) { diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 5e1f0b3c3..765e83ca0 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -85,6 +85,15 @@ pub struct Updater { started: Instant, healthy_marked: bool, jitter: u64, + /// versions whose apply failed or that were rolled back: never re-applied automatically (R4.3.6) + failed_versions: Vec, + /// the last manifest's min_supported_version, kept across restarts (updates/manifest.json): the rollback floor + min_supported: String, + /// macOS: the digest of the staged bundle at stage time, re-checked right before the swap (R4.3.5) + staged_digest: String, + /// the consensus override file written from the manifest, when it changed since the last take + override_changed: Option, + override_daa: u64, } impl Updater { @@ -117,12 +126,76 @@ impl Updater { started: now, healthy_marked: false, jitter, + failed_versions: Vec::new(), + min_supported: String::new(), + staged_digest: String::new(), + override_changed: None, + override_daa: 0, }; + u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::>(&t).ok()).unwrap_or_default(); + // the cached manifest: the rollback floor and the consensus override are known before the first check + if let Ok(text) = std::fs::read_to_string(u.dir.join("manifest.json")) { + if let Ok(m) = manifest::parse(&text) { + u.min_supported = m.min_supported_version.clone(); + u.write_override(shared, &m); + } + } u.settle_previous(shared); u.publish(shared); u } + fn failed_path(&self) -> PathBuf { + self.app_dir.join("failed-versions.json") + } + + fn remember_failed(&mut self, shared: &Arc, ver: &str) { + if ver.is_empty() || self.failed_versions.iter().any(|v| v == ver) { + return; + } + self.failed_versions.push(ver.to_string()); + let _ = std::fs::write(self.failed_path(), serde_json::to_string(&self.failed_versions).unwrap_or_default()); + shared.log(&format!("update: {ver} is marked failed; it will not be applied again by itself (Install now still can)")); + } + + /// The consensus override from the manifest: consensus.override written as is, or + /// {"difficulty_v2_activation_daa": activation_height} when only the height is given. The engine takes the + /// path with take_override_change() and restarts the node at a safe moment. + fn write_override(&mut self, shared: &Arc, m: &Manifest) { + let obj = match (&m.override_params, m.activation_height) { + (Some(o), _) => o.clone(), + (None, Some(h)) => json!({ "difficulty_v2_activation_daa": h }), + (None, None) => return, + }; + let path = self.app_dir.join("override.json"); + let text = obj.to_string(); + let same = std::fs::read_to_string(&path).ok().as_deref() == Some(text.as_str()); + if !same { + if let Err(e) = std::fs::write(&path, &text) { + shared.log(&format!("could not write {}: {e}", path.display())); + return; + } + shared.event("info", &format!("consensus parameters from the signed manifest: {text}")); + self.override_changed = Some(path.clone()); + } + self.override_daa = m.activation_height.or_else(|| obj.get("difficulty_v2_activation_daa").and_then(|v| v.as_u64())).unwrap_or(0); + shared.state.lock().unwrap().node.consensus_switch_daa = self.override_daa; + } + + /// The override file to start the node with, once per change. + pub fn take_override_change(&mut self) -> Option { + self.override_changed.take() + } + + pub fn override_path(&self) -> Option { + let p = self.app_dir.join("override.json"); + if p.is_file() { Some(p) } else { None } + } + + pub fn override_daa(&self) -> u64 { + self.override_daa + } + // ---- the files the old engine, the helper and the new engine pass around ------------------------------------- fn pending_path(&self) -> PathBuf { @@ -164,6 +237,7 @@ impl Updater { drop(st); shared.event("error", &format!("update to {ver} failed: {err}")); let _ = std::fs::remove_file(self.pending_path()); + self.remember_failed(shared, &ver); return; } } @@ -184,6 +258,7 @@ impl Updater { // the old version runs again: the helper restored it, or the installer never ran shared.event("error", &format!("the update to {} did not take; still on {}", p.to, p.from)); let _ = std::fs::remove_file(self.pending_path()); + self.remember_failed(shared, &p.to); } else { let _ = std::fs::remove_file(self.pending_path()); } @@ -322,6 +397,11 @@ impl Updater { shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into(); return None; } + let v = self.version(); + if self.failed_versions.iter().any(|f| f == &v) && !self.install_asked { + shared.state.lock().unwrap().update.wait = format!("{v} failed to install before; it waits for Install now"); + return None; + } match manifest::safe_to_apply(&moment) { Ok(()) => Some(Action::Apply), Err(why) => { @@ -420,6 +500,8 @@ impl Updater { self.ready_since = None; } self.manifest = Some(m.clone()); + self.min_supported = m.min_supported_version.clone(); + self.write_override(shared, &m); if let Some(e) = entry { if changed { self.file = None; @@ -464,6 +546,11 @@ impl Updater { } Ok(p) => { self.clear_error(shared); + #[cfg(target_os = "macos")] + { + self.staged_digest = manifest::digest_dir(&p).unwrap_or_default(); + shared.log(&format!("update: staged bundle digest {}", self.staged_digest)); + } self.staged = Some(p); self.ready_since = Some(Instant::now()); let v = self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default(); @@ -536,9 +623,9 @@ impl Updater { pub fn open_file(&self) -> Result<(), String> { let f = self.file.as_ref().ok_or("nothing downloaded yet")?; #[cfg(target_os = "macos")] - let r = Command::new("open").arg(f).spawn(); + let r = Command::new(crate::platform::tool("open")).arg(f).spawn(); #[cfg(windows)] - let r = crate::platform::quiet(&mut Command::new("cmd")).args(["/c", "start", "", &f.display().to_string()]).spawn(); + let r = crate::platform::quiet(&mut Command::new(crate::platform::tool("cmd"))).args(["/c", "start", "", &f.display().to_string()]).spawn(); #[cfg(not(any(target_os = "macos", windows)))] let r = Command::new("xdg-open").arg(f).spawn(); r.map(|_| ()).map_err(|e| e.to_string()) @@ -567,6 +654,25 @@ impl Updater { pub fn launch_apply(&mut self, shared: &Arc, host_pid: u32) -> Result<(), String> { let staged = self.staged.clone().ok_or("no update is ready")?; let to = self.version(); + // R4.3.5: what is about to be swapped in is re-verified now, not only when it was downloaded + let entry = self.entry.clone().ok_or("no manifest entry")?; + if let Some(f) = &self.file { + let sum = manifest::sha256_file(f).map_err(|e| format!("cannot hash the download: {e}"))?; + if sum != entry.sha256 { + self.staged = None; + self.file = None; + return Err("the downloaded file no longer matches the manifest's sha256; it is discarded".into()); + } + } + #[cfg(target_os = "macos")] + { + let d = manifest::digest_dir(&staged).map_err(|e| format!("cannot digest the staged app: {e}"))?; + if d != self.staged_digest || d.is_empty() { + let _ = std::fs::remove_dir_all(&staged); + self.staged = None; + return Err("the staged app changed since it was verified; it is discarded".into()); + } + } let previous_installer = if cfg!(windows) { self.dir.join(installer_name_for(&self.current)).to_string_lossy().into_owned() } else { String::new() }; let previous_installer = if Path::new(&previous_installer).is_file() { previous_installer } else { String::new() }; self.write_pending(&Pending { from: self.current.clone(), to: to.clone(), at: crate::platform::unix_now_f(), starts: 0, previous_installer }); @@ -578,9 +684,9 @@ impl Updater { let script = self.app_dir.join("ota-apply.sh"); std::fs::write(&script, MAC_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; let env_file = self.write_env_file(); - let args = ["apply".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), staged.display().to_string(), to.clone(), result.display().to_string(), env_file]; + let args = ["apply".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), staged.display().to_string(), to.clone(), result.display().to_string(), env_file, self.staged_digest.clone()]; shared.log(&format!("update: starting the helper: bash {} {}", script.display(), args.join(" "))); - spawn_detached(Command::new("nohup").arg("bash").arg(&script).args(&args))?; + spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?; Ok(()) } #[cfg(windows)] @@ -589,9 +695,9 @@ impl Updater { let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?; let script = self.app_dir.join("ota-apply.ps1"); std::fs::write(&script, WIN_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; - let mut c = Command::new("powershell"); + let mut c = Command::new(crate::platform::tool("powershell")); c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([ - "-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &staged.display().to_string(), "-Version", &to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), + "-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &staged.display().to_string(), "-Version", &to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", &entry.sha256, ]); shared.log(&format!("update: starting the helper: {} (an administrator prompt follows)", script.display())); spawn_detached(&mut c)?; @@ -607,6 +713,13 @@ impl Updater { /// The new version failed to start twice: restore the previous one through the helper and exit. pub fn launch_rollback(&mut self, shared: &Arc, host_pid: u32) -> Result<(), String> { let p = self.pending.clone().ok_or("no update pending")?; + // R4.3.6: never below the network's minimum; this version stays and is marked failed so it is not re-applied + if !self.min_supported.is_empty() && manifest::newer(&self.min_supported, &p.from) { + let _ = std::fs::remove_file(self.pending_path()); + self.pending = None; + return Err(format!("not rolling back to {}: the network needs {} or newer; staying on {}", p.from, self.min_supported, p.to)); + } + self.remember_failed(shared, &p.to); let result = self.result_path(); shared.event("error", &format!("Igneum Miner {} did not stay up twice; restoring {}", p.to, p.from)); #[cfg(target_os = "macos")] @@ -615,8 +728,8 @@ impl Updater { let script = self.app_dir.join("ota-apply.sh"); std::fs::write(&script, MAC_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; let env_file = self.write_env_file(); - let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), String::new(), p.to.clone(), result.display().to_string(), env_file]; - spawn_detached(Command::new("nohup").arg("bash").arg(&script).args(&args))?; + let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), app.display().to_string(), String::new(), p.to.clone(), result.display().to_string(), env_file, String::new()]; + spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?; Ok(()) } #[cfg(windows)] @@ -628,9 +741,10 @@ impl Updater { let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?; let script = self.app_dir.join("ota-apply.ps1"); std::fs::write(&script, WIN_HELPER).map_err(|e| format!("cannot write the helper: {e}"))?; - let mut c = Command::new("powershell"); + let sha = manifest::sha256_file(Path::new(&p.previous_installer)).unwrap_or_default(); + let mut c = Command::new(crate::platform::tool("powershell")); c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([ - "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), + "-Sha256", &sha, "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), ]); spawn_detached(&mut c)?; Ok(()) @@ -656,7 +770,7 @@ fn installer_name_for(version: &str) -> String { } fn curl(args: &[&str], limit: Duration) -> Result<(), String> { - let mut c = Command::new("curl"); + let mut c = Command::new(crate::platform::tool("curl")); c.args(args); let out = crate::detect::run_timeout(&mut c, None, limit).ok_or("curl is not available")?; // run_timeout folds stdout and stderr; with -sS only errors are printed @@ -733,23 +847,28 @@ fn stage(e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result Result<(), String> { - let out = crate::detect::run_timeout(Command::new("ditto").arg(&source).arg(&staged), None, Duration::from_secs(300)).unwrap_or_default(); + let out = crate::detect::run_timeout(Command::new(crate::platform::tool("ditto")).arg(&source).arg(&staged), None, Duration::from_secs(300)).unwrap_or_default(); if !staged.join("Contents/MacOS/igneum-app").is_file() { return Err(format!("copy failed: {}", out.lines().last().unwrap_or(""))); } - let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(&staged).output(); + // the quarantine flag comes off only after the file this bundle came from verified again, now + let again = manifest::sha256_file(file).map_err(|e| e.to_string())?; + if again != e.sha256 { + return Err("the download changed while it was being unpacked; discarded".into()); + } + let _ = Command::new(crate::platform::tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(&staged).output(); let v = crate::detect::run_timeout(Command::new(staged.join("Contents/MacOS/igneum-app")).arg("--version"), None, Duration::from_secs(20)).unwrap_or_default(); let want = format!("igneum-app {version}"); if v.trim() != want { @@ -758,7 +877,7 @@ fn stage(e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result.previous" and the staged one in, opens the new app; if the new app does not start twice, puts the # previous one back. rollback: the previous bundle back, the failed one aside. Writes for the engine. -MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7"; ENVF="${8:-}" +MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7"; ENVF="${8:-}"; DIGEST="${9:-}" LOG="$(dirname "$RESULT")/ota-apply.log" exec >>"$LOG" 2>&1 echo "$(date -u +%FT%TZ) $MODE: engine $EPID host $HPID app '$APP' new '$NEW' version $VER" @@ -831,12 +950,15 @@ result() { printf '{"ok":%s,"version":"%s","error":"%s","rolled_back":%s,"at":%s PREV="$APP.previous" FAILED="$APP.failed" ENGINE="$APP/Contents/MacOS/igneum-app" +# the same digest the engine computed when it staged the bundle (src/manifest.rs digest_dir): every regular file, +# byte-sorted relative path, "path\nsha256\n" per file, sha256 of the whole +digest_dir() { (cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1; } started_ok() { local n=60; while [ "$n" -gt 0 ]; do pgrep -f "$ENGINE" >/dev/null 2>&1 && return 0; sleep 0.5; n=$((n-1)); done; return 1; } # a test run carries its private-devnet environment to the relaunch (open -n cannot); a normal run goes through LaunchServices -launch() { if [ -n "$ENVF" ] && [ -f "$ENVF" ]; then (set -a; . "$ENVF"; set +a; nohup "$APP/Contents/MacOS/Igneum Miner" >/dev/null 2>&1 &); else open -n "$APP"; fi; } +launch() { if [ -n "$ENVF" ] && [ -f "$ENVF" ]; then (set -a; . "$ENVF"; set +a; /usr/bin/nohup "$APP/Contents/MacOS/Igneum Miner" >/dev/null 2>&1 &); else /usr/bin/open -n "$APP"; fi; } wait_gone "$EPID" 240 || { echo "engine $EPID still running after 120 s; ending it"; kill -9 "$EPID" 2>/dev/null; sleep 1; } if [ -n "$HPID" ] && [ "$HPID" != 0 ] && ! gone "$HPID"; then - osascript -e 'tell application id "network.igneum.miner" to quit' >/dev/null 2>&1 || kill -TERM "$HPID" 2>/dev/null + /usr/bin/osascript -e 'tell application id "network.igneum.miner" to quit' >/dev/null 2>&1 || kill -TERM "$HPID" 2>/dev/null wait_gone "$HPID" 80 || { echo "window $HPID still running after 40 s; ending it"; kill -9 "$HPID" 2>/dev/null; sleep 1; } fi # anything else from this bundle (a stray engine of an older run) @@ -844,10 +966,17 @@ pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 0.5 case "$MODE" in apply) [ -d "$NEW" ] || { result false "the staged app is missing" false; launch; exit 1; } + if [ -n "$DIGEST" ]; then + have="$(digest_dir "$NEW")" + if [ "$have" != "$DIGEST" ]; then echo "digest mismatch: staged $have, verified $DIGEST"; rm -rf "$NEW"; result false "the staged app changed since it was verified; not installed" false; launch; exit 1; fi + echo "staged bundle digest verified" + else + echo "no digest given; not installing an unverified bundle"; result false "no digest for the staged app" false; launch; exit 1 + fi rm -rf "$PREV" mv "$APP" "$PREV" || { result false "could not move the old app aside" false; launch; exit 1; } mv "$NEW" "$APP" || { mv "$PREV" "$APP"; result false "could not move the new app in" false; launch; exit 1; } - xattr -dr com.apple.quarantine "$APP" 2>/dev/null + /usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null # only a bundle whose digest just verified echo "swapped; opening $APP" launch || echo "open failed" if started_ok; then result true "" false; echo "$VER is running"; exit 0; fi @@ -877,7 +1006,7 @@ const WIN_HELPER: &str = r#"# Igneum Miner update helper, written by the engine # administrator (one UAC prompt; the installer stops what is left, replaces the files and relaunches the app), writes # . If the installer does not run (prompt declined, error), the old app is started again. # rollback: the same with the previous version's installer. -param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir) +param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '') $log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log' function Log([string]$t) { Add-Content -Path $log -Value ("{0} {1}" -f (Get-Date -Format s), $t) } function Done([bool]$ok, [string]$err, [bool]$rb) { @@ -893,6 +1022,11 @@ $deadline = (Get-Date).AddSeconds(120) while ((Get-Date) -lt $deadline -and (Get-Process -Id $EnginePid -ErrorAction SilentlyContinue)) { Start-Sleep -Milliseconds 500 } if (Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) { Log 'engine still running; ending it'; Stop-Process -Id $EnginePid -Force -ErrorAction SilentlyContinue } if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false; Relaunch; exit 1 } +# the installer is hashed again right before it runs as administrator (R4.3.5) +if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false; Relaunch; exit 1 } +$have = (Get-FileHash -Path $Installer -Algorithm SHA256).Hash.ToLower() +if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false; Relaunch; exit 1 } +Log 'installer sha256 verified' $setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log' try { $args = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"')) diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index 0843b4e21..6058c3297 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -4,6 +4,81 @@ use std::path::{Path, PathBuf}; use std::process::Command; +/// The absolute path of a system helper (R4.3.3: never a bare name on PATH). Windows: System32 (and NVIDIA's own +/// folder for nvidia-smi); macOS: /usr/bin, /usr/sbin, /bin. Unknown names fall back to the bare name. +pub fn tool(name: &str) -> PathBuf { + #[cfg(windows)] + { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into()); + let sys = format!("{root}\\System32"); + let p = match name { + "powershell" => format!("{sys}\\WindowsPowerShell\\v1.0\\powershell.exe"), + "cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" => format!("{sys}\\{name}.exe"), + "nvidia-smi" => { + let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into()); + let a = format!("{pf}\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe"); + let b = format!("{sys}\\nvidia-smi.exe"); + if Path::new(&a).is_file() { a } else { b } + } + _ => name.to_string(), + }; + PathBuf::from(p) + } + #[cfg(target_os = "macos")] + { + let p = match name { + "curl" | "osascript" | "xattr" | "open" | "caffeinate" | "hdiutil" | "ditto" | "nohup" | "pgrep" | "pkill" | "shasum" | "xcrun" => format!("/usr/bin/{name}"), + "sntp" | "scutil" | "system_profiler" | "sysctl" => format!("/usr/sbin/{name}"), + "bash" | "sh" => format!("/bin/{name}"), + _ => name.to_string(), + }; + PathBuf::from(p) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + for dir in ["/usr/bin", "/bin", "/usr/sbin", "/usr/local/bin"] { + let p = Path::new(dir).join(name); + if p.is_file() { + return p; + } + } + PathBuf::from(name) + } +} + +/// Strips the dashboard token from a line: "/t/<32 hex>/" becomes "/t//" (R4.3.8: the token is never logged +/// and the logs are uploaded). +pub fn redact(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find("/t/") { + out.push_str(&rest[..i + 3]); + let after = &rest[i + 3..]; + let hex_len = after.chars().take_while(|c| c.is_ascii_hexdigit()).count(); + if hex_len >= 16 { + out.push_str(""); + rest = &after[hex_len..]; + } else { + rest = after; + } + } + out.push_str(rest); + out +} + +/// True when a line still carries something that looks like the dashboard token (the upload guard). +pub fn carries_token(s: &str) -> bool { + let mut rest = s; + while let Some(i) = rest.find("/t/") { + let after = &rest[i + 3..]; + if after.chars().take_while(|c| c.is_ascii_hexdigit()).count() >= 16 { + return true; + } + rest = after; + } + false +} + pub fn unix_now() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } @@ -61,7 +136,7 @@ pub fn host_label() -> String { let raw = { #[cfg(target_os = "macos")] { - Command::new("scutil").args(["--get", "LocalHostName"]).output().ok().and_then(|o| String::from_utf8(o.stdout).ok()) + Command::new(tool("scutil")).args(["--get", "LocalHostName"]).output().ok().and_then(|o| String::from_utf8(o.stdout).ok()) } #[cfg(windows)] { @@ -94,7 +169,7 @@ pub fn lock_permissions(path: &Path, dir: bool) { let _ = dir; let user = std::env::var("USERNAME").unwrap_or_default(); if !user.is_empty() { - let _ = Command::new("icacls") + let _ = Command::new(tool("icacls")) .arg(path) .args(["/inheritance:r", "/grant:r", &format!("{user}:F")]) .output(); @@ -105,9 +180,9 @@ pub fn lock_permissions(path: &Path, dir: bool) { /// Opens a URL in the default browser (the fallback when no window host runs). pub fn open_url(url: &str) { #[cfg(target_os = "macos")] - let _ = Command::new("open").arg(url).spawn(); + let _ = Command::new(tool("open")).arg(url).spawn(); #[cfg(windows)] - let _ = Command::new("cmd").args(["/c", "start", "", url]).spawn(); + let _ = quiet(&mut Command::new(tool("cmd"))).args(["/c", "start", "", url]).spawn(); #[cfg(not(any(target_os = "macos", windows)))] let _ = Command::new("xdg-open").arg(url).spawn(); } @@ -123,7 +198,7 @@ impl KeepAwake { pub fn start() -> KeepAwake { #[cfg(target_os = "macos")] { - let child = Command::new("caffeinate").args(["-dims", "-w", &std::process::id().to_string()]).spawn().ok(); + let child = Command::new(tool("caffeinate")).args(["-dims", "-w", &std::process::id().to_string()]).spawn().ok(); KeepAwake { child } } #[cfg(not(target_os = "macos"))] @@ -239,9 +314,9 @@ pub fn set_start_at_login(on: bool) -> Result<(), String> { let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; let out = if on { let cmd = login_command().iter().map(|a| format!("\"{a}\"")).collect::>().join(" "); - Command::new("reg").args(["add", key, "/v", "Igneum Miner", "/t", "REG_SZ", "/d", &cmd, "/f"]).output() + Command::new(tool("reg")).args(["add", key, "/v", "Igneum Miner", "/t", "REG_SZ", "/d", &cmd, "/f"]).output() } else { - Command::new("reg").args(["delete", key, "/v", "Igneum Miner", "/f"]).output() + Command::new(tool("reg")).args(["delete", key, "/v", "Igneum Miner", "/f"]).output() }; match out { Ok(o) if o.status.success() || !on => Ok(()), @@ -263,7 +338,7 @@ pub fn start_at_login_is_on() -> bool { } #[cfg(windows)] { - Command::new("reg") + Command::new(tool("reg")) .args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", "Igneum Miner"]) .output() .map(|o| o.status.success()) @@ -280,7 +355,7 @@ pub fn start_at_login_is_on() -> bool { pub fn clear_quarantine() { #[cfg(target_os = "macos")] if let Some(b) = bundle_path() { - let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(b.join("Contents")).output(); + let _ = Command::new(tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(b.join("Contents")).output(); } } @@ -305,7 +380,7 @@ pub fn clock_hint() -> &'static str { pub fn sync_clock() -> Result { #[cfg(target_os = "macos")] { - let out = Command::new("osascript") + let out = Command::new(tool("osascript")) .args(["-e", "do shell script \"/usr/bin/sntp -sS time.apple.com 2>&1\" with administrator privileges"]) .output() .map_err(|e| e.to_string())?; @@ -320,9 +395,10 @@ pub fn sync_clock() -> Result { } #[cfg(windows)] { - let script = "Start-Process -FilePath cmd.exe -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden"; - let mut c = Command::new("powershell"); - c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]); + let cmd = tool("cmd").display().to_string(); + let script = format!("Start-Process -FilePath '{cmd}' -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden"); + let mut c = Command::new(tool("powershell")); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]); quiet(&mut c); let out = c.output().map_err(|e| e.to_string())?; if out.status.success() { @@ -350,8 +426,9 @@ pub fn run_elevated(cmdline: &str) -> Result<(), String> { #[cfg(windows)] { let escaped = cmdline.replace('\'', "''"); - let script = format!("$p = Start-Process -FilePath cmd.exe -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode"); - let mut c = Command::new("powershell"); + let cmd = tool("cmd").display().to_string(); + let script = format!("$p = Start-Process -FilePath '{cmd}' -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode"); + let mut c = Command::new(tool("powershell")); c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]); quiet(&mut c); let out = c.output().map_err(|e| e.to_string())?; @@ -383,3 +460,15 @@ pub fn quiet(cmd: &mut Command) -> &mut Command { } cmd } + +#[cfg(test)] +mod tests { + #[test] + fn token_redaction() { + let l = "dashboard at http://127.0.0.1:58776/t/a3a01c537130bceeaa1f6118ba48d63e/ (log x)"; + assert_eq!(super::redact(l), "dashboard at http://127.0.0.1:58776/t// (log x)"); + assert!(super::carries_token(l)); + assert!(!super::carries_token("GET /t/short/ nothing")); + assert_eq!(super::redact("no token here"), "no token here"); + } +} diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index cd67aa076..818dbe41b 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -25,6 +25,7 @@ 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 }; @@ -40,6 +41,9 @@ struct Req { path: String, query: String, body: Vec, + origin: Option, + sec_fetch_site: Option, + host: Option, } fn read_request(stream: &mut TcpStream) -> Option { @@ -51,6 +55,7 @@ fn read_request(stream: &mut TcpStream) -> Option { 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()?; @@ -59,8 +64,15 @@ fn read_request(stream: &mut TcpStream) -> Option { break; } if let Some((k, v)) = h.split_once(':') { + let v = v.trim(); if k.eq_ignore_ascii_case("content-length") { - content_length = v.trim().parse().unwrap_or(0); + 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()); } } } @@ -75,7 +87,31 @@ fn read_request(stream: &mut TcpStream) -> Option { Some((p, q)) => (p.to_string(), q.to_string()), None => (target, String::new()), }; - Some(Req { method, path, query, body }) + 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) { @@ -83,6 +119,7 @@ fn respond(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], cache: 200 => "OK", 204 => "No Content", 400 => "Bad Request", + 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", _ => "Error", @@ -148,6 +185,10 @@ fn handle(mut stream: TcpStream, shared: Arc) { 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 { diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 69b206fae..95a19ebc1 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -21,6 +21,9 @@ pub struct NodeState { pub version: String, pub last_reading_age_s: f64, pub message: String, + /// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none + pub consensus_switch_daa: u64, + pub override_restart_wait: String, } #[derive(Clone, Serialize, Default)] diff --git a/app/igneum-app/src/update.rs b/app/igneum-app/src/update.rs index c95fa03b7..291656322 100644 --- a/app/igneum-app/src/update.rs +++ b/app/igneum-app/src/update.rs @@ -8,7 +8,7 @@ use std::time::Duration; /// The network's idea of now from an HTTPS Date header (1 s resolution), as unix seconds. The second clock source, /// independent of the node. pub fn https_time(url: &str) -> Option { - let out = crate::detect::run_timeout(Command::new("curl").args(["-sI", "--max-time", "10", url]), None, Duration::from_secs(12))?; + let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-sI", "--max-time", "10", url]), None, Duration::from_secs(12))?; let line = out.lines().find(|l| l.to_ascii_lowercase().starts_with("date:"))?; parse_http_date(line[5..].trim()) } @@ -45,7 +45,7 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { pub fn latest_block_time(evm_port: u16) -> Option { let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}"; let out = crate::detect::run_timeout( - Command::new("curl").args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]), + Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]), None, Duration::from_secs(7), )?; @@ -75,14 +75,15 @@ pub fn upload_log(url: &str, key: &str, label: &str, machine: &str, run_id: &str if data.len() > 262_144 { data = data.split_off(data.len() - 262_144); } - let text = String::from_utf8_lossy(&data).into_owned(); + // second guard (R4.3.8): no line that carries a dashboard token leaves the machine + let text: String = String::from_utf8_lossy(&data).lines().filter(|l| !crate::platform::carries_token(l)).map(|l| crate::platform::redact(l)).collect::>().join("\n"); let body = serde_json::json!({ "label": label, "machine": machine, "run_id": run_id, "lines": text }); let tmp = std::env::temp_dir().join(format!("igneum-upload-{}-{}.json", std::process::id(), label)); if std::fs::write(&tmp, body.to_string()).is_err() { return false; } let out = crate::detect::run_timeout( - Command::new("curl").args([ + Command::new(crate::platform::tool("curl")).args([ "-sS", "--max-time", "60", "-X", "POST", url, "-H", "Content-Type: application/json", "-H", &format!("x-igneum-key: {key}"), diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 92cee27b8..bb74e23a0 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -353,7 +353,8 @@ $('d-node-net').textContent = s.chain + (n.version ? ' ยท ' + n.version.replace(/^igneumd\s*/, '') : ''); $('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers; $('n-daa').textContent = withCommas(n.daa); $('n-diff').textContent = compact(n.difficulty); $('n-tips').textContent = n.tips; - $('n-note').textContent = n.state === 'synced' ? ('Reading every 10 s' + (n.last_reading_age_s >= 0 ? ', last ' + Math.round(n.last_reading_age_s) + ' s ago' : '') + '.') : (n.message ? n.message + '.' : ''); + var consensus = n.consensus_switch_daa > 0 ? ' Consensus switch at DAA ' + withCommas(n.consensus_switch_daa) + (n.daa > 0 && n.consensus_switch_daa > n.daa ? ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : '.') + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '') : ''; + $('n-note').textContent = (n.state === 'synced' ? ('Reading every 10 s' + (n.last_reading_age_s >= 0 ? ', last ' + Math.round(n.last_reading_age_s) + ' s ago' : '') + '.') : (n.message ? n.message + '.' : '')) + consensus; // finality if (f.last_lock > 0) { $('f-lock').textContent = '#' + withCommas(f.last_lock); $('f-age').textContent = rel(f.age_s); $('f-note').textContent = 'Checkpoints lock at 2/3 of the 30-day weight. Votes are sent by this miner.'; } else { $('f-lock').textContent = 'none yet'; $('f-age').textContent = 'n/a'; $('f-note').textContent = f.message || 'Locks appear once the miner votes on checkpoints.'; } diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index ed9555ddd..08cfd768d 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -225,6 +225,7 @@

Stored at , readable by your user only. Settings can show it again.

+

On devnet the vote keys are test keys derived from the miner's label. Mainnet vote keys will be random and stored like this wallet.

diff --git a/app/windows/host.cpp b/app/windows/host.cpp index df5466d6c..83d081786 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -112,10 +112,13 @@ static void applyState(const std::string& j) { static void runElevated(std::wstring line) { std::thread([line] { std::wstring params = L"/c " + line; + wchar_t sysdir[MAX_PATH]; + GetSystemDirectoryW(sysdir, MAX_PATH); + std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3) SHELLEXECUTEINFOW sei = { sizeof(sei) }; sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI; sei.lpVerb = L"runas"; - sei.lpFile = L"cmd.exe"; + sei.lpFile = cmdExe.c_str(); sei.lpParameters = params.c_str(); sei.nShow = SW_HIDE; if (!ShellExecuteExW(&sei) || !sei.hProcess) { diff --git a/packaging/mac/README.md b/packaging/mac/README.md index e3c2b1868..fc3f06f0e 100644 --- a/packaging/mac/README.md +++ b/packaging/mac/README.md @@ -45,6 +45,9 @@ boundary within 3 minutes, no worker starting) stops the miners and the node, sw `Igneum Miner.app.previous`) and opens the new one. Publish with `packaging/ota/publish-manifest.sh --version --mac dist/Igneum-Miner-.dmg --notes "..."`. The 0.1.0 and 0.2.0 Terminal launchers are not auto-updated. +Devnet vote keys are test keys derived from the miner's label (`--`); mainnet vote keys will be +random and stored like the wallet (R4.3.12, devnet-only by design). + Gatekeeper: the app is unsigned (ad hoc). Right-click > Open the first time. The engine strips `com.apple.quarantine` from the bundle's Contents on start, so the binaries inside are not refused one by one; that needs a writable volume, hence "drag to Applications first". diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index 89df0416e..17aaab61c 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -62,9 +62,8 @@ english.FinishedHeadingLabel=Igneum Miner is installed Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" Name: "firewall"; Description: "Let other Igneum nodes connect to this PC (Windows Firewall rule for igneumd.exe on private networks)"; GroupDescription: "Network:" -[Dirs] -; The engine writes its data under %LOCALAPPDATA%\igneum; the fallback worker build writes next to the sources. -Name: "{app}"; Permissions: users-modify +;; No [Dirs] entry: {app} stays read-only for users (R4.3.3). The engine writes under %LOCALAPPDATA%\igneum, and +;; the fallback worker build copies the sources there first. [Files] Source: "{#Payload}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion; Excludes: "*.log,*.seeds,*.DS_Store,packs\*,build\*,dist\*" diff --git a/packaging/windows/README.md b/packaging/windows/README.md index db02411a1..977ffcc12 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -119,6 +119,10 @@ OpenCL worker adds `--job-nonces 2097152`). Before each start the engine runs `i worker; without a prebuilt worker it runs today's build path (`proto-cuda\build.bat devnet sm_120` in the MSVC environment, rebuilt at every hour boundary after exit 42), exactly as `igneum-common.ps1` falls back. +Devnet vote keys are test keys derived from the miner's label; mainnet vote keys will be random and stored like the +wallet (R4.3.12, devnet-only by design). Program Files stays read-only for users: the engine writes only under +`%LOCALAPPDATA%\igneum`, and every helper (nvidia-smi, powershell, cmd, curl, reg, icacls) is launched by its absolute path. + Untested at the time of writing (written on a Mac): the window host (`app/windows/host.cpp`, first run is BUILD-APP.bat), the Inno build, nvidia-smi and OpenCL detection, the prebuilt workers under the engine. `embed-resources.sh` (windres + relink on the Mac) still works for `igneumd.exe` and `igneum-miner.exe`; the engine's icon comes from rcedit on the PC.