One administrator approval, ever (Josh, 6 October 2026 11:50 UTC): the first Power control approval also registers the per-user scheduled task 'Igneum Power Helper' (RunLevel Highest, no trigger, action = the app's own exe --power-helper); every later cap and every tune's helper starts the task and writes the command file, no prompt, across restarts, updates and reboots; Power control off sends remove and the task unregisters itself; the helper runs only fixed verbs with digit-only nvidia-smi arguments (threat note in ember-tune.md 7a); 4 tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
5a261e7894
commit
53b50e81ba
4 changed files with 376 additions and 3 deletions
|
|
@ -596,6 +596,10 @@ pub struct Engine {
|
|||
quit_source: &'static str,
|
||||
/// the over-the-air updater never runs: IGNEUM_APP_NO_OTA=1 or --sweep (a second engine beside the installed app)
|
||||
no_ota: bool,
|
||||
/// the Igneum Power Helper task is registered (src/powertask.rs): once, ever; None = not asked yet
|
||||
power_task: Option<bool>,
|
||||
/// the running tune helper is the task (quit ends it; no SweepHelperDone comes from a thread)
|
||||
sweep_helper_is_task: bool,
|
||||
/// the request number the vendor tool last carried out (the run's acknowledgement)
|
||||
tune_acked: Option<u64>,
|
||||
/// cards whose confirm check found a better neighbour: the full plan runs next
|
||||
|
|
@ -700,6 +704,8 @@ impl Engine {
|
|||
sweep: None,
|
||||
quit_source: "unknown",
|
||||
no_ota,
|
||||
power_task: None,
|
||||
sweep_helper_is_task: false,
|
||||
tune_acked: None,
|
||||
tune_full_due: std::collections::HashSet::new(),
|
||||
sweep_pending: None,
|
||||
|
|
@ -989,6 +995,7 @@ impl Engine {
|
|||
self.clock_next_https = Instant::now() + Duration::from_secs(6);
|
||||
}
|
||||
Cmd::PowerApplied(what, r, readback) => {
|
||||
self.power_task = None; // the one elevated step may have registered the task: ask again next time
|
||||
self.power_busy = false;
|
||||
self.power_via_host = None;
|
||||
// the truth is what nvidia-smi reads back, not whether the prompt said yes
|
||||
|
|
@ -1779,8 +1786,45 @@ impl Engine {
|
|||
self.power_busy = true;
|
||||
self.power_restore_pending = true;
|
||||
self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & ")));
|
||||
let line = cmds.join(" & ");
|
||||
let what = what.join(", ");
|
||||
// once, ever (src/powertask.rs): a registered task sets the caps with no prompt; the readback judges it
|
||||
if cfg!(windows) && self.power_task_registered() {
|
||||
let pairs: Vec<(String, u64)> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0 && !c.power_applied).map(|c| (c.device.clone(), requested_watts(c).round() as u64)).collect();
|
||||
let dir = self.sweep_dir();
|
||||
let shared = self.shared.clone();
|
||||
self.shared.log("power cap: through the Igneum Power Helper task (no prompt)");
|
||||
std::thread::spawn(move || {
|
||||
let r = crate::powertask::start().and_then(|_| {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let mut seq = crate::platform::unix_now() % 1_000_000;
|
||||
let mut text = String::new();
|
||||
for (dev, w) in &pairs {
|
||||
seq += 1;
|
||||
text.push_str(&format!("{seq} dev {dev}\n"));
|
||||
seq += 1;
|
||||
text.push_str(&format!("{seq} pl {w}\n"));
|
||||
}
|
||||
std::fs::write(dir.join("cmd.txt"), text).map_err(|e| e.to_string())
|
||||
});
|
||||
std::thread::sleep(Duration::from_secs(6));
|
||||
let back: std::collections::HashMap<String, f64> = crate::detect::nvidia_power_limits().into_iter().map(|(k, v)| (k, v.1)).collect();
|
||||
shared.send(Cmd::PowerApplied(what, r, back));
|
||||
});
|
||||
return;
|
||||
}
|
||||
// the first approval registers the task in the same elevated step as the caps (Windows), so no later step
|
||||
// needs a prompt: the registration script is written next to the command file
|
||||
let line = if cfg!(windows) {
|
||||
let dir = self.sweep_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let script = dir.join("register-power-task.ps1");
|
||||
match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&exe).as_bytes()].concat())) {
|
||||
Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()),
|
||||
_ => cmds.join(" & "),
|
||||
}
|
||||
} else {
|
||||
cmds.join(" & ")
|
||||
};
|
||||
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
|
||||
if self.wrapper && cfg!(windows) {
|
||||
// the window host has a UI context: it shows the administrator prompt and reports back on stdin
|
||||
|
|
@ -1830,6 +1874,14 @@ impl Engine {
|
|||
self.shared.log(&format!("GPU power limits left as set (they reset at the next reboot; no prompt on quit): {}", cmds.join(" & ")));
|
||||
}
|
||||
|
||||
/// Is the Igneum Power Helper task registered (src/powertask.rs)? Asked once per run and after every elevated step.
|
||||
fn power_task_registered(&mut self) -> bool {
|
||||
if self.power_task.is_none() {
|
||||
self.power_task = Some(crate::powertask::registered());
|
||||
}
|
||||
self.power_task.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Power control (config.rs power_control): may the engine ask for administrator rights for the cap or the sweep?
|
||||
/// The elevated PC sweep job (--sweep) sets caps directly and counts as allowed.
|
||||
fn elevation_allowed(&self) -> bool {
|
||||
|
|
@ -1854,6 +1906,16 @@ impl Engine {
|
|||
if self.sweep.is_some() || self.sweep_pending.is_some() {
|
||||
self.sweep_abort("power control is off");
|
||||
}
|
||||
if cfg!(windows) && self.power_task_registered() {
|
||||
// the kill switch: the task unregisters itself (elevated) and exits; nothing is left behind
|
||||
let dir = self.sweep_dir();
|
||||
let _ = std::fs::write(dir.join("cmd.txt"), "remove\n");
|
||||
match crate::powertask::start() {
|
||||
Ok(()) => self.shared.log("power control off: the Igneum Power Helper task removes itself"),
|
||||
Err(e) => self.shared.log(&format!("power control off: the task could not be started to remove itself ({e}); remove it in Task Scheduler")),
|
||||
}
|
||||
self.power_task = None;
|
||||
}
|
||||
self.shared.event(if note.starts_with("power control off:") { "error" } else { "info" }, note);
|
||||
}
|
||||
|
||||
|
|
@ -2343,8 +2405,18 @@ impl Engine {
|
|||
std::fs::write(&script, crate::sweep::helper_script_unix()).map_err(|e| e.to_string())?;
|
||||
format!("sh \"{}\" \"{}\" \"{}\" {} {}", script.display(), dir.display(), smi, c.device, restore)
|
||||
};
|
||||
if cfg!(windows) && self.power_task_registered() {
|
||||
// once, ever: the registered task is the helper; it reads the same command file, no prompt
|
||||
let _ = std::fs::write(dir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device));
|
||||
crate::powertask::start()?;
|
||||
self.shared.log("tune helper: the Igneum Power Helper task (no prompt)");
|
||||
self.sweep_helper = true;
|
||||
self.sweep_helper_is_task = true;
|
||||
return Ok(());
|
||||
}
|
||||
self.shared.log(&format!("tune helper (administrator prompt): {line}"));
|
||||
self.sweep_helper = true;
|
||||
self.sweep_helper_is_task = false;
|
||||
let shared = self.shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = crate::platform::run_elevated(&line);
|
||||
|
|
@ -2356,6 +2428,11 @@ impl Engine {
|
|||
fn sweep_helper_quit(&mut self) {
|
||||
if self.sweep_helper {
|
||||
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), "quit\n");
|
||||
if self.sweep_helper_is_task {
|
||||
// the task exits on quit and reports nothing back; the next tune starts it again
|
||||
self.sweep_helper = false;
|
||||
self.sweep_helper_is_task = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2400,7 +2477,8 @@ impl Engine {
|
|||
// measure only: nothing is set; the run notices the missing acknowledgement and measures
|
||||
return;
|
||||
}
|
||||
let cmd = format!("{seq} pl {w}\n{seq} {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() });
|
||||
let dev = device.to_string();
|
||||
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() });
|
||||
let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd);
|
||||
// the helper polls twice a second and nvidia-smi answers within a second or two
|
||||
std::thread::spawn(move || {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ mod verifier;
|
|||
mod wslhost;
|
||||
mod sweep;
|
||||
mod ember;
|
||||
mod powertask;
|
||||
mod watchdog;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
|
@ -53,6 +54,12 @@ fn main() {
|
|||
println!("igneum-app {}", engine::VERSION);
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|a| a == "--power-helper") {
|
||||
// the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands
|
||||
// from <app data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes
|
||||
let dir = powertask::sweep_dir(&platform::data_root().join("app"));
|
||||
std::process::exit(powertask::run_helper(&dir));
|
||||
}
|
||||
if args.iter().any(|a| a == "--launch") {
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
let host = dir.join("Igneum Miner.exe");
|
||||
|
|
|
|||
265
app/igneum-app/src/powertask.rs
Normal file
265
app/igneum-app/src/powertask.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
//! One administrator approval, ever (Josh, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
|
||||
//! "can we make sure all these popups are not needed in future?").
|
||||
//!
|
||||
//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app
|
||||
//! start, a reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. This module
|
||||
//! makes the first approval the last: the one elevated step also registers a per-user Windows scheduled task,
|
||||
//! `Igneum Power Helper`, principal = the signed-in user, RunLevel Highest, no trigger, whose action is this very
|
||||
//! executable with `--power-helper`. A task the user owns can be STARTED by the user's unelevated processes without a
|
||||
//! prompt (`Start-ScheduledTask`), and it runs elevated; so every later cap and tune starts the task and talks to it
|
||||
//! through the command file `<app data>/app/sweep/cmd.txt` (the protocol the 0.3.9 helper scripts spoke: `<seq> pl
|
||||
//! <watts>`, `<seq> lgc <MHz>`, `<seq> rgc`, `quit`; plus `remove`, the kill switch). The task survives app restarts,
|
||||
//! updates (the per-user installer replaces the exe in place; the task's action path is the install folder) and
|
||||
//! reboots (a task, not a process). Power control off starts the task once and sends `remove`: the helper unregisters
|
||||
//! the task (elevated) and exits; nothing is left behind.
|
||||
//!
|
||||
//! Threat note (what the helper will and will not run):
|
||||
//! - The action is fixed at registration: the app's own exe in the install folder with `--power-helper`. The task
|
||||
//! has no trigger and no arguments from outside; only `Start-ScheduledTask` by the owning user starts it.
|
||||
//! - The helper reads ONE file, `<app data>/app/sweep/cmd.txt`, in the user's own profile. Every command it accepts
|
||||
//! is a fixed verb with digit-only arguments: `pl <watts>` runs `nvidia-smi -i <dev> -pl <watts>`, `lgc <MHz>` runs
|
||||
//! `nvidia-smi -i <dev> -lgc 0,<MHz>`, `rgc` runs `nvidia-smi -i <dev> -rgc`, `quit` ends it, `remove` unregisters
|
||||
//! the task and ends it. The device index is digits only too (`dev <n>` sets it). No shell, no path, no string from
|
||||
//! the file reaches a process: `Command::new(nvidia-smi).args([...])`, never `cmd /c`.
|
||||
//! - nvidia-smi is resolved to the driver's install path (platform::tool), never from PATH.
|
||||
//! - What an attacker running as the user gains: the power limit and the clock cap of the user's own NVIDIA cards,
|
||||
//! within the ranges the driver allows, which the same user could set with one approved prompt anyway. Nothing
|
||||
//! else: no file, no process, no registry, no other binary.
|
||||
//! - The helper exits after 20 idle minutes; a stale command file is cleared at start (sequence numbers must rise).
|
||||
//! - Linux keeps pkexec per step (no scheduled task); macOS has no cap to set.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The task name in the Windows Task Scheduler (per user).
|
||||
pub const TASK_NAME: &str = "Igneum Power Helper";
|
||||
/// The helper ends after this long without a new command.
|
||||
pub const IDLE_S: u64 = 20 * 60;
|
||||
|
||||
/// One parsed command from cmd.txt.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HelperCmd {
|
||||
Dev(String),
|
||||
PowerLimit(u64),
|
||||
ClockCap(u64),
|
||||
ClockReset,
|
||||
Quit,
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and
|
||||
/// `remove` need no sequence). Anything that is not a fixed verb with digit-only arguments is None.
|
||||
pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
|
||||
let t = line.trim();
|
||||
if t == "quit" {
|
||||
return Some((0, HelperCmd::Quit));
|
||||
}
|
||||
if t == "remove" {
|
||||
return Some((0, HelperCmd::Remove));
|
||||
}
|
||||
let p: Vec<&str> = t.split_whitespace().collect();
|
||||
let digits = |s: &str| !s.is_empty() && s.len() <= 6 && s.chars().all(|c| c.is_ascii_digit());
|
||||
let seq: u64 = p.first().filter(|s| digits(s)).and_then(|s| s.parse().ok())?;
|
||||
match p.as_slice() {
|
||||
[_, w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))),
|
||||
[_, "pl", w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))),
|
||||
[_, "lgc", m] if digits(m) => Some((seq, HelperCmd::ClockCap(m.parse().ok()?))),
|
||||
[_, "rgc"] => Some((seq, HelperCmd::ClockReset)),
|
||||
[_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
|
||||
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
|
||||
match c {
|
||||
HelperCmd::PowerLimit(w) => Some(vec!["-i".into(), dev.into(), "-pl".into(), w.to_string()]),
|
||||
HelperCmd::ClockCap(m) => Some(vec!["-i".into(), dev.into(), "-lgc".into(), format!("0,{m}")]),
|
||||
HelperCmd::ClockReset => Some(vec!["-i".into(), dev.into(), "-rgc".into()]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this
|
||||
/// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no
|
||||
/// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs.
|
||||
pub fn register_script(exe: &Path) -> String {
|
||||
let exe = exe.display().to_string().replace('\'', "''");
|
||||
format!(
|
||||
"$a = New-ScheduledTaskAction -Execute '{exe}' -Argument '--power-helper' -WorkingDirectory '{dir}'\r\n\
|
||||
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest\r\n\
|
||||
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew -Hidden\r\n\
|
||||
Register-ScheduledTask -TaskName '{name}' -Action $a -Principal $p -Settings $s -Force | Out-Null\r\n\
|
||||
exit 0\r\n",
|
||||
dir = exe.rfind(['\\', '/']).map(|i| exe[..i].to_string()).unwrap_or_default(),
|
||||
name = TASK_NAME
|
||||
)
|
||||
}
|
||||
|
||||
/// The PowerShell that starts the task from an unelevated process (no prompt: the user owns the task).
|
||||
pub fn start_command() -> String {
|
||||
format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0")
|
||||
}
|
||||
|
||||
/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1).
|
||||
pub fn query_command() -> String {
|
||||
format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}")
|
||||
}
|
||||
|
||||
/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left.
|
||||
pub fn remove_command() -> String {
|
||||
format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false; exit 0")
|
||||
}
|
||||
|
||||
/// Is the task registered? Windows only; false elsewhere.
|
||||
pub fn registered() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]);
|
||||
crate::platform::quiet(&mut c);
|
||||
c.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Starts the task (no prompt). Ok when Start-ScheduledTask returned 0.
|
||||
pub fn start() -> Result<(), String> {
|
||||
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &start_command()]);
|
||||
crate::platform::quiet(&mut c);
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Start-ScheduledTask failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper process (`igneum-app --power-helper`): polls `<dir>/cmd.txt` twice a second, runs the parsed commands
|
||||
/// through nvidia-smi, logs what it ran to `<dir>/helper.log`, ends on `quit`, on `remove` (after unregistering the
|
||||
/// task) or after 20 idle minutes. `dir` is `<app data>/app/sweep`.
|
||||
pub fn run_helper(dir: &Path) -> i32 {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let cmd_file = dir.join("cmd.txt");
|
||||
let log_file = dir.join("helper.log");
|
||||
let log = |line: &str| {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).create(true).open(&log_file) {
|
||||
let _ = writeln!(f, "{} {line}", crate::platform::unix_now());
|
||||
}
|
||||
};
|
||||
log("helper started (scheduled task, elevated)");
|
||||
// a stale file from an earlier run is not a command: only lines after the start count
|
||||
let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0);
|
||||
let mut last_text = String::new();
|
||||
let mut dev = "0".to_string();
|
||||
let mut idle = Instant::now();
|
||||
let smi = crate::platform::tool("nvidia-smi");
|
||||
loop {
|
||||
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||
if text != last_text {
|
||||
last_text = text.clone();
|
||||
for (seq, c) in text.lines().filter_map(parse_line) {
|
||||
match c {
|
||||
HelperCmd::Quit => {
|
||||
log("quit");
|
||||
return 0;
|
||||
}
|
||||
HelperCmd::Remove => {
|
||||
let mut p = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &remove_command()]);
|
||||
crate::platform::quiet(&mut p);
|
||||
let ok = p.status().map(|s| s.success()).unwrap_or(false);
|
||||
log(&format!("remove: the task is {}", if ok { "unregistered" } else { "still registered (Unregister-ScheduledTask failed)" }));
|
||||
return if ok { 0 } else { 1 };
|
||||
}
|
||||
_ if seq <= last_seq => continue,
|
||||
HelperCmd::Dev(d) => {
|
||||
last_seq = seq;
|
||||
idle = Instant::now();
|
||||
dev = d;
|
||||
log(&format!("{seq} dev {dev}"));
|
||||
}
|
||||
other => {
|
||||
last_seq = seq;
|
||||
idle = Instant::now();
|
||||
let args = smi_args(&dev, &other).unwrap_or_default();
|
||||
let mut p = std::process::Command::new(&smi);
|
||||
p.args(&args);
|
||||
crate::platform::quiet(&mut p);
|
||||
let out = p.output().map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_else(|e| e.to_string());
|
||||
log(&format!("{seq} nvidia-smi {} : {}", args.join(" "), out.replace('\n', " ").trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
|
||||
log("idle 20 min: exit (the engine starts the task again when it needs it)");
|
||||
return 0;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the command file lives for a data root.
|
||||
pub fn sweep_dir(app_dir: &Path) -> PathBuf {
|
||||
app_dir.join("sweep")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_fixed_verbs_with_digit_arguments_parse() {
|
||||
assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460))));
|
||||
assert_eq!(parse_line("8 lgc 2472"), Some((8, HelperCmd::ClockCap(2472))));
|
||||
assert_eq!(parse_line("9 rgc"), Some((9, HelperCmd::ClockReset)));
|
||||
assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into()))));
|
||||
assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form");
|
||||
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
|
||||
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
|
||||
// nothing else: no shell, no path, no string argument, no oversized number
|
||||
for bad in ["7 pl 460; calc", "7 pl -460", "7 pl 4.60", "7 lgc 0,2472", "7 rm C:\\x", "x pl 460", "7 pl", "7 lgc 12345678", "7 dev ../1", "", "7 pl 460 extra"] {
|
||||
assert_eq!(parse_line(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arguments_reach_nvidia_smi_as_a_list_never_a_shell() {
|
||||
assert_eq!(smi_args("0", &HelperCmd::PowerLimit(460)).unwrap(), vec!["-i", "0", "-pl", "460"]);
|
||||
assert_eq!(smi_args("1", &HelperCmd::ClockCap(2472)).unwrap(), vec!["-i", "1", "-lgc", "0,2472"]);
|
||||
assert_eq!(smi_args("1", &HelperCmd::ClockReset).unwrap(), vec!["-i", "1", "-rgc"]);
|
||||
assert_eq!(smi_args("0", &HelperCmd::Quit), None);
|
||||
assert_eq!(smi_args("0", &HelperCmd::Remove), None);
|
||||
assert_eq!(smi_args("0", &HelperCmd::Dev("1".into())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registration_is_per_user_highest_no_trigger_fixed_action() {
|
||||
let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"));
|
||||
assert!(s.contains("-Execute 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner\\igneum-app.exe' -Argument '--power-helper'"), "{s}");
|
||||
assert!(s.contains("-WorkingDirectory 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}");
|
||||
assert!(s.contains("-RunLevel Highest") && s.contains("-LogonType Interactive"), "{s}");
|
||||
assert!(s.contains("[System.Security.Principal.WindowsIdentity]::GetCurrent().Name"), "the signed-in user, never a literal");
|
||||
assert!(!s.contains("-Trigger"), "no trigger: only the app starts it");
|
||||
assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}");
|
||||
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
|
||||
// a quote in the path cannot break out of the literal
|
||||
let q = register_script(Path::new(r"C:\it's\igneum-app.exe"));
|
||||
assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}");
|
||||
assert!(start_command().starts_with("Start-ScheduledTask -TaskName 'Igneum Power Helper'"));
|
||||
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Power Helper' -Confirm:$false"));
|
||||
assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_command_file_does_not_run_at_start() {
|
||||
// the helper's start reads the highest sequence already in the file and runs nothing below or at it
|
||||
let text = "3 pl 460\n4 lgc 2472\n";
|
||||
let last = text.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
|
||||
assert_eq!(last, 4);
|
||||
let newer: Vec<_> = "3 pl 460\n4 lgc 2472\n5 rgc\n".lines().filter_map(parse_line).filter(|(s, _)| *s > last).collect();
|
||||
assert_eq!(newer, vec![(5, HelperCmd::ClockReset)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -161,13 +161,36 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r
|
|||
10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power
|
||||
ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout.
|
||||
|
||||
## 7a. One administrator approval, ever (0.3.13; Josh, 6 October 2026, 11:50 UTC)
|
||||
|
||||
What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a
|
||||
reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. Not "once, ever".
|
||||
|
||||
What `src/powertask.rs` does: the first approval's elevated step also registers a per-user Windows scheduled task,
|
||||
`Igneum Power Helper` (principal = the signed-in user, interactive logon, RunLevel Highest, no trigger, hidden, one
|
||||
hour limit, new starts ignored while one runs), whose action is the app's own exe in the install folder with
|
||||
`--power-helper`. A task the user owns is started by the user's unelevated engine with `Start-ScheduledTask`, no
|
||||
prompt, and runs elevated. Every later cap and every tune's helper starts the task and writes the command file
|
||||
`<app data>/app/sweep/cmd.txt` (`<seq> dev <n>`, `<seq> pl <W>`, `<seq> lgc <MHz>`, `<seq> rgc`, `quit`). The task
|
||||
survives app restarts, updates (the per-user installer replaces the exe in place; the task's action path is the
|
||||
install folder) and reboots. Power control off starts the task once and sends `remove`: the helper unregisters the
|
||||
task (elevated) and exits; nothing is left behind. Linux keeps pkexec per step; macOS has no cap.
|
||||
|
||||
Threat note: the helper runs only fixed verbs with digit-only arguments through `Command::new(nvidia-smi).args`
|
||||
(the driver's own path, never PATH, never a shell); a line that is anything else is ignored; the sequence must rise
|
||||
(a stale file runs nothing); an attacker running as the user gains the power limit and clock cap of the user's own
|
||||
NVIDIA cards inside the driver's ranges, which the same user could set with one approved prompt anyway; no file,
|
||||
process, registry key or other binary is reachable through it. Tests: `powertask::tests` (the parser refuses every
|
||||
non-digit or extra argument, the arguments reach nvidia-smi as a list, the registration is per-user, highest,
|
||||
trigger-less and quote-safe, a stale command file runs nothing).
|
||||
|
||||
## 8a. Next-cut notes (for the 0.3.12 shipper)
|
||||
|
||||
| Commit | What | Where |
|
||||
|---|---|---|
|
||||
| b671c8b | every `quit:` names its source; Power control alone decides; no cap at start under `--sweep` | main.rs, server.rs, engine.rs (separable) |
|
||||
| e600e63 | a second engine never runs the updater (`IGNEUM_APP_NO_OTA`, implied by `--sweep`) | engine.rs (6 lines, separable) |
|
||||
| 1e9550e (this commit, amended) | the elevated job path's output file is followed while the script runs, so the 5-minute progress reports carry its lines (a 35-minute run that never mined showed only "script running" on 6 October 2026); the tune playbook's watchdog fails a run that mines nothing within 120 s of its first status line, with the engine's last log line in the RESULT | jobrun.rs `follow_file`, relay/playbooks/ember-tune-pc1.ps1 |
|
||||
| 1e9550e | the elevated job path's output file is followed while the script runs, so the 5-minute progress reports carry its lines (a 35-minute run that never mined showed only "script running" on 6 October 2026); the tune playbook's watchdog fails a run that mines nothing within 120 s of its first status line, with the engine's last log line in the RESULT | jobrun.rs `follow_file`, relay/playbooks/ember-tune-pc1.ps1 |
|
||||
|
||||
## 9. Open
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue