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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 13:58:23 +00:00
parent c8b026b8d9
commit 456eff08d5
15 changed files with 496 additions and 82 deletions

View file

@ -108,7 +108,7 @@ pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64
#[cfg(not(target_os = "macos"))]
pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64, f64)> {
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<String>) -> Vec<CardState> {
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::<serde_json::Value>(&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<String>) -> Vec<CardState> {
}
}
}
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::<u64>() {
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<String>) -> Vec<CardState> {
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::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
}
apply_defaults(&mut c);
@ -171,7 +171,7 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
let mut cards: Vec<CardState> = 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<String>) -> Vec<CardState> {
}
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");

View file

@ -73,6 +73,7 @@ pub struct Shared {
cmd_tx: Mutex<Sender<Cmd>>,
pub started: Instant,
engine_log: Mutex<Option<std::fs::File>>,
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<String, f64>, 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<usize, Stability>,
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 <app data>/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<std::path::PathBuf> {
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<String> = ["--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<Shared>, 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<Shared>, 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::<Vec<_>>().into_iter().rev() {
shared.log(&format!(" build: {l}"));
}

View file

@ -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();

View file

@ -50,6 +50,9 @@ pub struct Manifest {
pub notes: String,
pub activation_height: Option<u64>,
pub deadline_note: String,
/// consensus.override: the exact object the engine writes to <app data>/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<serde_json::Value>,
}
impl Manifest {
@ -149,6 +152,11 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
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<String> {
fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<String>) -> 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<String> {
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"<plist/>").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) {

View file

@ -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<String>,
/// 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<PathBuf>,
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::<Vec<String>>(&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<Shared>, 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<Shared>, 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<PathBuf> {
self.override_changed.take()
}
pub fn override_path(&self) -> Option<PathBuf> {
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<Shared>, 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<Shared>, 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<Pa
if e.kind == "dmg" {
let mnt = work.join("mnt");
std::fs::create_dir_all(&mnt).map_err(|e| e.to_string())?;
let out = crate::detect::run_timeout(Command::new("hdiutil").args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", &mnt.display().to_string(), &file.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default();
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("hdiutil")).args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", &mnt.display().to_string(), &file.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default();
if !mnt.join("Igneum Miner.app").is_dir() {
return Err(format!("the disk image has no Igneum Miner.app ({})", out.lines().last().unwrap_or("hdiutil said nothing")));
}
mounted = Some(mnt.clone());
source = mnt.join("Igneum Miner.app");
} else {
let out = crate::detect::run_timeout(Command::new("ditto").args(["-x", "-k", &file.display().to_string(), &work.display().to_string()]), None, Duration::from_secs(300)).unwrap_or_default();
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("ditto")).args(["-x", "-k", &file.display().to_string(), &work.display().to_string()]), None, Duration::from_secs(300)).unwrap_or_default();
let found = find_app(&work).ok_or(format!("the zip has no Igneum Miner.app ({})", out.lines().last().unwrap_or("")))?;
source = found;
}
let r = (|| -> 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<Pa
Ok(())
})();
if let Some(m) = mounted {
let _ = Command::new("hdiutil").args(["detach", "-force", &m.display().to_string()]).output();
let _ = Command::new(crate::platform::tool("hdiutil")).args(["detach", "-force", &m.display().to_string()]).output();
}
let _ = std::fs::remove_dir_all(&work);
if let Err(err) = r {
@ -821,7 +940,7 @@ const MAC_HELPER: &str = r#"#!/bin/bash
# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running
# bundle to "<app>.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 <result json> 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
# <json>. 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 + '"'))

View file

@ -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/<token>/" (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("<token>");
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::<Vec<_>>().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<String, String> {
#[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<String, String> {
}
#[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/<token>/ (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");
}
}

View file

@ -25,6 +25,7 @@ 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 };
@ -40,6 +41,9 @@ struct Req {
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> {
@ -51,6 +55,7 @@ fn read_request(stream: &mut TcpStream) -> Option<Req> {
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<Req> {
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<Req> {
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<Shared>) {
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 {

View file

@ -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)]

View file

@ -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<f64> {
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<f64> {
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::<Vec<_>>().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}"),

View file

@ -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.'; }

View file

@ -225,6 +225,7 @@
<div class="box mono key"><span id="key-private"></span><button class="btn tiny" data-copy="key-private">Copy</button></div>
</div>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Settings can show it again.</p>
<p class="note">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.</p>
<label class="check"><input type="checkbox" id="key-ack"><span>I have saved my key</span></label>
<div class="cta">
<button class="btn primary big" id="btn-key-done" disabled>Start mining</button>

View file

@ -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) {

View file

@ -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 <v>
--mac dist/Igneum-Miner-<v>.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 (`<platform>-<machine id8>-<n>`); 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".

View file

@ -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\*"

View file

@ -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.