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:
parent
91db5c6c65
commit
2d08e7b2dc
15 changed files with 496 additions and 82 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 + '"'))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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.'; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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\*"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue