ui-ota (0.3.20): the manifest's interface channel and the engine that swaps a signed dashboard bundle in
The signed manifest gains a "ui" object {version, sha256, size, url, min_engine, signature}; the entry's own Ed25519
signature (the release key, manifest::ui_sign_bytes) and the manifest's cover it. src/uiota.rs: the hourly check hands
the entry over; a bundle for this engine is downloaded, checked (size, sha256, signature), unpacked next to the current
one and swapped by an atomic pointer; the server serves the bundle's fixed file names in place of the embedded ones;
the first page load starts a 10 s wait for the page's health ping, and silence, a first-paint error, a missing
index.html or a renamed bundle rolls back to the embedded interface and marks the version bad for good. No "ui"
object retires the bundle (the kill switch). settings.ui_builtin and state.ui carry Settings > Interface.
igneum-ota-sign gains sign-ui and verify-ui. Tests: a good bundle applies, a bad signature is refused, a too-new
min_engine is ignored, a broken bundle rolls back, every bad manifest field fails.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
d7b0470210
commit
3417f1e1fa
10 changed files with 756 additions and 3 deletions
|
|
@ -7,6 +7,9 @@
|
|||
//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint
|
||||
//! igneum-ota-sign fingerprint <public-key-file|hex>
|
||||
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
|
||||
//! igneum-ota-sign sign-ui <private-key-file> <version> <sha256> <min-engine> the interface entry's own signature
|
||||
//! (src/manifest.rs ui_sign_bytes; tools/ui-ota/publish.mjs calls this), same key
|
||||
//! igneum-ota-sign verify-ui <public-key-file|hex|embedded> <version> <sha256> <min-engine> <signature-hex>
|
||||
//! igneum-ota-sign sign-jobs <private-key-file> <igneum-jobs.json> the remote-jobs file (src/jobs.rs), same key
|
||||
//! igneum-ota-sign verify-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file>
|
||||
//! igneum-ota-sign envelope-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file> prints igneum-jobs.signed.json:
|
||||
|
|
@ -101,6 +104,26 @@ fn main() {
|
|||
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
|
||||
println!("{sum} {size}");
|
||||
}
|
||||
Some("sign-ui") if args.len() == 5 => {
|
||||
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
|
||||
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
|
||||
let sk = SigningKey::from_bytes(&seed);
|
||||
if manifest::parse_version(&args[2]).is_none() || manifest::parse_version(&args[4]).is_none() {
|
||||
die("the version and the engine floor are versions like 0.3.19 or 0.3.19.1");
|
||||
}
|
||||
if args[3].len() != 64 || !args[3].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
die("the sha256 is 64 hex characters");
|
||||
}
|
||||
println!("{}", manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(&args[2], &args[3], &args[4])).to_bytes()));
|
||||
}
|
||||
Some("verify-ui") if args.len() == 6 => {
|
||||
let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) };
|
||||
let e = manifest::UiEntry { version: args[2].clone(), sha256: args[3].to_ascii_lowercase(), size: 1, url: "https://x".into(), min_engine: args[4].clone(), signature: args[5].to_ascii_lowercase() };
|
||||
match manifest::verify_ui_entry(&e, &pk) {
|
||||
Ok(()) => println!("verifies"),
|
||||
Err(e) => die(&e),
|
||||
}
|
||||
}
|
||||
Some("sign-jobs") if args.len() == 3 => {
|
||||
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
|
||||
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
|
||||
|
|
|
|||
|
|
@ -133,6 +133,10 @@ pub struct Settings {
|
|||
/// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model).
|
||||
#[serde(default)]
|
||||
pub profile_public: bool,
|
||||
/// ui-ota (7 October 2026, src/uiota.rs): "Use the built-in interface": the embedded dashboard serves even when an
|
||||
/// over-the-air interface bundle is active. Default off.
|
||||
#[serde(default)]
|
||||
pub ui_builtin: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
|
@ -147,7 +151,7 @@ fn yes() -> bool {
|
|||
|
||||
impl Default for Settings {
|
||||
fn default() -> Settings {
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false }
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,12 @@ pub enum Cmd {
|
|||
RestartNode(String),
|
||||
/// quit, with its source (the log names it: C35, 5 October 2026, two unexplained quits)
|
||||
Quit(&'static str),
|
||||
/// ui-ota (src/uiota.rs): the download thread's result; the server served a bundle's index.html (its version);
|
||||
/// the page's health ping (None) or its first-paint error; Settings > Use the built-in interface
|
||||
UiOta(crate::uiota::Event),
|
||||
UiPageLoaded(String),
|
||||
UiHealth(Option<String>),
|
||||
UiBuiltin(bool),
|
||||
}
|
||||
|
||||
pub struct Shared {
|
||||
|
|
@ -106,6 +112,8 @@ pub struct Shared {
|
|||
pub runtime: Runtime,
|
||||
pub packaged: Packaged,
|
||||
cmd_tx: Mutex<Sender<Cmd>>,
|
||||
/// ui-ota: the folder the server serves the dashboard from (None = the files embedded in the binary)
|
||||
ui_dir: Mutex<Option<PathBuf>>,
|
||||
pub started: Instant,
|
||||
engine_log: Mutex<Option<std::fs::File>>,
|
||||
/// the app log's path: the one the uploader sends (main.rs names it; the engine must not guess the stamp)
|
||||
|
|
@ -141,7 +149,7 @@ impl Shared {
|
|||
st.mining.accepted_total = settings.accepted_total;
|
||||
st.mining.fee_total = settings.fee_total;
|
||||
st.address = address_state(&settings, &wallet_path);
|
||||
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public };
|
||||
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin };
|
||||
st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() };
|
||||
st.live_page = packaged.live_page.clone();
|
||||
st.finality.message = "waiting for the miner".into();
|
||||
|
|
@ -158,6 +166,7 @@ impl Shared {
|
|||
runtime,
|
||||
packaged,
|
||||
cmd_tx: Mutex::new(cmd_tx),
|
||||
ui_dir: Mutex::new(None),
|
||||
started: Instant::now(),
|
||||
engine_log: Mutex::new(engine_log),
|
||||
log_path,
|
||||
|
|
@ -193,6 +202,22 @@ impl Shared {
|
|||
self.port.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A Shared over a scratch folder for the unit tests that need one (src/uiota.rs): no node, no window, a channel
|
||||
/// nobody reads (send drops the command), the defaults everywhere else.
|
||||
#[cfg(test)]
|
||||
pub fn for_tests(dir: PathBuf) -> Arc<Shared> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let (tx, _rx) = channel();
|
||||
let runtime = crate::config::Runtime { network: "devnet".into(), rpc_port: 0, p2p_port: 0, peers: vec![], unsynced_mining: false, devnet_suffix: None, node_dir: dir.join("node"), app_dir: dir.clone(), log_dir: dir.join("logs"), status_secs: 30, sweep_only: true, host: "test".into(), machine_id: "0123456789abcdef".into() };
|
||||
Arc::new(Shared::new("t".into(), runtime, crate::config::Packaged::default(), Settings::default(), tx, None, dir.join("app.log")))
|
||||
}
|
||||
/// ui-ota: what the server serves the dashboard from; None = the embedded files
|
||||
pub fn ui_dir(&self) -> Option<PathBuf> {
|
||||
self.ui_dir.lock().unwrap().clone()
|
||||
}
|
||||
pub fn set_ui_dir(&self, d: Option<PathBuf>) {
|
||||
*self.ui_dir.lock().unwrap() = d;
|
||||
}
|
||||
pub fn send(&self, c: Cmd) {
|
||||
let _ = self.cmd_tx.lock().unwrap().send(c);
|
||||
}
|
||||
|
|
@ -607,6 +632,8 @@ pub struct Engine {
|
|||
last_upload: Instant,
|
||||
upload_thread: Option<std::thread::JoinHandle<()>>,
|
||||
ota: crate::ota::Updater,
|
||||
/// the interface over the air (src/uiota.rs)
|
||||
uiota: crate::uiota::UiOta,
|
||||
jobs: crate::jobrun::Jobs,
|
||||
/// a remote job has the miners stopped; they restart when the runner lets go
|
||||
job_hold: bool,
|
||||
|
|
@ -692,6 +719,7 @@ impl Engine {
|
|||
// labels never carry the hostname: two cloned PCs with the same COMPUTERNAME would sign with the same keys
|
||||
let label_base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.id8());
|
||||
let ota = crate::ota::Updater::new(&shared);
|
||||
let uiota = crate::uiota::UiOta::new(&shared);
|
||||
let jobs = crate::jobrun::Jobs::new(&shared);
|
||||
Engine {
|
||||
shared,
|
||||
|
|
@ -732,6 +760,7 @@ impl Engine {
|
|||
last_upload: now,
|
||||
upload_thread: None,
|
||||
ota,
|
||||
uiota,
|
||||
jobs,
|
||||
job_hold: false,
|
||||
stamp,
|
||||
|
|
@ -962,6 +991,10 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
Cmd::Ota(ev) => self.ota.event(&self.shared, ev),
|
||||
Cmd::UiOta(ev) => self.uiota.event(&self.shared, ev),
|
||||
Cmd::UiPageLoaded(v) => self.uiota.page_loaded(&v, Instant::now()),
|
||||
Cmd::UiHealth(err) => self.uiota.health(&self.shared, err.as_deref()),
|
||||
Cmd::UiBuiltin(on) => self.uiota.set_builtin(&self.shared, on),
|
||||
Cmd::Job(ev) => {
|
||||
if let Some(a) = self.jobs.event(&self.shared, ev) {
|
||||
self.job_action(a);
|
||||
|
|
@ -3254,6 +3287,10 @@ impl Engine {
|
|||
self.apply_update();
|
||||
}
|
||||
}
|
||||
if let Some(ui) = self.ota.take_ui_change() {
|
||||
self.uiota.consider(&self.shared, ui.as_ref(), VERSION);
|
||||
}
|
||||
self.uiota.tick(&self.shared, Instant::now());
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ mod server;
|
|||
mod state;
|
||||
mod manifest;
|
||||
mod ota;
|
||||
mod uiota;
|
||||
mod update;
|
||||
mod jobs;
|
||||
mod jobrun;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@
|
|||
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
|
||||
//! "min_supported_version": "0.3.0", "notes": "one line",
|
||||
//! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} },
|
||||
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
|
||||
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } },
|
||||
//! "ui": { "version": "0.3.19.1", "sha256": "<64 hex>", "size": 412345, "url": "https://dl.../ui/igneum-ui-0.3.19.1.tar.gz",
|
||||
//! "min_engine": "0.3.19", "signature": "<128 hex>" }
|
||||
//! }
|
||||
//! `ui` (7 October 2026, docs/plans/ui-ota.md) is the interface channel: a tar.gz of app/igneum-app/ui the engine serves
|
||||
//! in place of its embedded copy once the hash and the entry's own Ed25519 signature (over `ui_sign_bytes`, the same
|
||||
//! release key) check; the whole manifest's signature covers it too. Removing the object is the kill switch.
|
||||
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
|
||||
//! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it
|
||||
//! to <app data>/tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE).
|
||||
|
|
@ -59,6 +64,34 @@ pub struct Manifest {
|
|||
/// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries
|
||||
/// it), written as is to <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
|
||||
pub tuning: Option<serde_json::Value>,
|
||||
/// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published
|
||||
pub ui: Option<UiEntry>,
|
||||
}
|
||||
|
||||
/// One published interface bundle (the manifest's `ui` object).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct UiEntry {
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub url: String,
|
||||
/// the lowest engine version that may serve this bundle; a newer bundle for an older engine is ignored
|
||||
pub min_engine: String,
|
||||
/// Ed25519 over `ui_sign_bytes(version, sha256, min_engine)` by the release key, 128 hex
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// The bytes the interface entry's own signature covers: a fixed tag, then the version, the hash and the engine
|
||||
/// floor, one per line. The url and the size are not covered: the hash is what the engine trusts after the download.
|
||||
pub fn ui_sign_bytes(version: &str, sha256: &str, min_engine: &str) -> Vec<u8> {
|
||||
format!("igneum-ui\n{version}\n{}\n{min_engine}\n", sha256.to_ascii_lowercase()).into_bytes()
|
||||
}
|
||||
|
||||
/// The entry's own signature against the release key (the whole manifest's signature is checked before this).
|
||||
pub fn verify_ui_entry(e: &UiEntry, pub_hex: &str) -> Result<(), String> {
|
||||
let key = public_key(pub_hex)?;
|
||||
let sig = hex_decode(&e.signature).and_then(|b| Signature::from_slice(&b).ok()).ok_or("interface signature is not 128 hex characters")?;
|
||||
key.verify(&ui_sign_bytes(&e.version, &e.sha256, &e.min_engine), &sig).map_err(|_| "interface signature does not verify".to_string())
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
|
|
@ -168,6 +201,32 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()),
|
||||
_ => None,
|
||||
},
|
||||
ui: match v.get("ui") {
|
||||
Some(u) if u.is_object() => {
|
||||
let e = UiEntry { version: s(u, "version"), sha256: s(u, "sha256").to_ascii_lowercase(), size: u.get("size").and_then(|x| x.as_u64()).unwrap_or(0), url: s(u, "url"), min_engine: s(u, "min_engine"), signature: s(u, "signature").to_ascii_lowercase() };
|
||||
if parse_version(&e.version).is_none() {
|
||||
return Err(format!("ui: version '{}' is not a version", e.version));
|
||||
}
|
||||
if parse_version(&e.min_engine).is_none() {
|
||||
return Err(format!("ui: min_engine '{}' is not a version", e.min_engine));
|
||||
}
|
||||
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
|
||||
return Err("ui: the url is not https".into());
|
||||
}
|
||||
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("ui: sha256 is not 64 hex characters".into());
|
||||
}
|
||||
if e.size == 0 {
|
||||
return Err("ui: size is missing".into());
|
||||
}
|
||||
if e.signature.len() != 128 || !e.signature.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("ui: signature is not 128 hex characters".into());
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
Some(u) if !u.is_null() => return Err("ui must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -389,6 +448,36 @@ mod tests {
|
|||
assert_eq!(ours, theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ui_entry_parses_and_every_bad_field_fails() {
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
let sk = SigningKey::from_bytes(&[3u8; 32]);
|
||||
let pk = hex_encode(sk.verifying_key().as_bytes());
|
||||
let sha = "ab".repeat(32);
|
||||
let sig = hex_encode(&sk.sign(&ui_sign_bytes("1.0.1", &sha, "0.3.19")).to_bytes());
|
||||
let base = serde_json::json!({ "version": "0.3.19", "platforms": {}, "ui": { "version": "1.0.1", "sha256": sha, "size": 400000, "url": "https://dl.igneum.network/dl/t/ui/igneum-ui-1.0.1.tar.gz", "min_engine": "0.3.19", "signature": sig } });
|
||||
let m = parse(&base.to_string()).unwrap();
|
||||
let u = m.ui.clone().unwrap();
|
||||
assert_eq!(u.version, "1.0.1");
|
||||
assert_eq!(u.min_engine, "0.3.19");
|
||||
assert!(verify_ui_entry(&u, &pk).is_ok());
|
||||
let mut t = u.clone();
|
||||
t.sha256 = "00".repeat(32);
|
||||
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed hash breaks the entry's signature");
|
||||
let mut t = u.clone();
|
||||
t.min_engine = "0.3.0".into();
|
||||
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed engine floor breaks it too");
|
||||
assert!(verify_ui_entry(&u, &hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes())).is_err());
|
||||
assert_eq!(parse(r#"{"version":"0.3.19","platforms":{}}"#).unwrap().ui, None, "no ui object: the kill switch");
|
||||
for (k, v) in [("version", serde_json::json!("x")), ("min_engine", serde_json::json!("")), ("url", serde_json::json!("http://evil/x.tar.gz")), ("sha256", serde_json::json!("abc")), ("size", serde_json::json!(0)), ("signature", serde_json::json!("zz"))] {
|
||||
let mut b = base.clone();
|
||||
b["ui"][k] = v;
|
||||
assert!(parse(&b.to_string()).is_err(), "ui.{k} bad must fail");
|
||||
}
|
||||
assert!(parse(r#"{"version":"0.3.19","platforms":{},"ui":"x"}"#).is_err());
|
||||
assert_eq!(std::str::from_utf8(&ui_sign_bytes("1.0.1", "AB", "0.3.19")).unwrap(), "igneum-ui\n1.0.1\nab\n0.3.19\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuning_parses() {
|
||||
let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap();
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ pub struct Updater {
|
|||
live_next: Instant,
|
||||
live_busy: bool,
|
||||
live_api: String,
|
||||
/// ui-ota: the interface entry of the last verified manifest, handed to src/uiota.rs once per check
|
||||
/// (Some(None) = the channel was withdrawn: the kill switch)
|
||||
ui_change: Option<Option<manifest::UiEntry>>,
|
||||
}
|
||||
|
||||
impl Updater {
|
||||
|
|
@ -178,6 +181,7 @@ impl Updater {
|
|||
live_next: now,
|
||||
live_busy: false,
|
||||
live_api: env("IGNEUM_APP_LIVE_API").unwrap_or_else(|| live_api_from(&shared.packaged.live_page)),
|
||||
ui_change: None,
|
||||
};
|
||||
shared.log(&format!("update slot: minute {} of every hour (machine id {})", u.slot, shared.runtime.id8()));
|
||||
#[cfg(windows)]
|
||||
|
|
@ -273,6 +277,10 @@ impl Updater {
|
|||
}
|
||||
|
||||
/// The tuning file, once per change (the engine only logs it: the workers read the file at their next prepare).
|
||||
/// ui-ota: the interface entry of the last check, once (None until a check has run)
|
||||
pub fn take_ui_change(&mut self) -> Option<Option<manifest::UiEntry>> {
|
||||
self.ui_change.take()
|
||||
}
|
||||
pub fn take_tuning_change(&mut self) -> Option<PathBuf> {
|
||||
self.tuning_changed.take()
|
||||
}
|
||||
|
|
@ -686,6 +694,7 @@ impl Updater {
|
|||
self.min_supported = m.min_supported_version.clone();
|
||||
self.write_override(shared, &m);
|
||||
self.write_tuning(shared, &m);
|
||||
self.ui_change = Some(m.ui.clone());
|
||||
if let Some(e) = entry {
|
||||
if changed {
|
||||
self.file = None;
|
||||
|
|
|
|||
|
|
@ -172,8 +172,26 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
return;
|
||||
};
|
||||
let rest = if rest.is_empty() { "/" } else { rest };
|
||||
// ui-ota (src/uiota.rs): an active interface bundle serves its files in place of the embedded ones; the names are
|
||||
// fixed (uiota::SERVED), nothing else is read from the folder; a file the bundle lacks falls back to the embedded one
|
||||
if req.method == "GET" {
|
||||
let rel = if rest == "/" { "index.html" } else { rest.trim_start_matches('/') };
|
||||
if crate::uiota::SERVED.contains(&rel) {
|
||||
if let Some(dir) = shared.ui_dir() {
|
||||
if let Ok(bytes) = std::fs::read(dir.join(rel)) {
|
||||
if rel == "index.html" {
|
||||
let v = std::fs::read_to_string(dir.join("VERSION")).unwrap_or_default().trim().to_string();
|
||||
shared.send(Cmd::UiPageLoaded(v));
|
||||
}
|
||||
respond(&mut stream, 200, crate::uiota::content_type(rel), &bytes, rel.starts_with("fonts/"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match (req.method.as_str(), rest) {
|
||||
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
|
||||
("GET", "/VERSION") => respond(&mut stream, 200, "text/plain; charset=utf-8", crate::uiota::EMBEDDED_VERSION.as_bytes(), false),
|
||||
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
|
||||
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
|
||||
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
|
||||
|
|
@ -316,6 +334,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::InstallUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/ui/health" => {
|
||||
// ui-ota: the page's first-paint ping (no error) or the error it caught before it (src/uiota.rs)
|
||||
let err = body.get("error").and_then(|v| v.as_str()).filter(|e| !e.is_empty()).map(|e| e.to_string());
|
||||
shared.send(Cmd::UiHealth(err));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/ui/builtin" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::UiBuiltin(on));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/auto" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::AutoUpdate(on));
|
||||
|
|
|
|||
|
|
@ -332,6 +332,32 @@ pub struct SettingsState {
|
|||
pub proof_verify_trust: bool,
|
||||
/// miner-ui-5: the public address profile opt-in (settings.profile_public)
|
||||
pub profile_public: bool,
|
||||
/// ui-ota: "Use the built-in interface" (settings.ui_builtin)
|
||||
pub ui_builtin: bool,
|
||||
}
|
||||
|
||||
/// The interface over the air (src/uiota.rs): what serves, from where, since when; Settings > Interface.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct UiState {
|
||||
/// the version compiled into this engine (ui/VERSION)
|
||||
pub embedded_version: String,
|
||||
/// the version the server serves now (the bundle's, or the embedded one)
|
||||
pub active_version: String,
|
||||
/// embedded | ota
|
||||
pub source: String,
|
||||
/// unix s the active bundle was swapped in (0 for the embedded interface)
|
||||
pub installed_at: f64,
|
||||
/// the page confirmed the active bundle with its health ping (always true for the embedded interface)
|
||||
pub confirmed: bool,
|
||||
/// the version the manifest publishes now ("" when the channel is withdrawn)
|
||||
pub published_version: String,
|
||||
pub busy: bool,
|
||||
pub error: String,
|
||||
/// versions refused here (never applied again)
|
||||
pub bad: Vec<String>,
|
||||
pub builtin: bool,
|
||||
/// why the published version is not applied, when it is not
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
|
||||
|
|
@ -443,6 +469,7 @@ pub struct State {
|
|||
pub settings: SettingsState,
|
||||
pub dev_fee: DevFeeState,
|
||||
pub update: UpdateState,
|
||||
pub ui: UiState,
|
||||
pub jobs: JobsState,
|
||||
pub events: Vec<Event>,
|
||||
pub uptime_s: u64,
|
||||
|
|
|
|||
533
app/igneum-app/src/uiota.rs
Normal file
533
app/igneum-app/src/uiota.rs
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
//! Interface over the air (docs/plans/ui-ota.md, 7 October 2026): the dashboard (app/igneum-app/ui, embedded in the
|
||||
//! engine binary) can be replaced by a signed bundle from the manifest's `ui` channel without a new app version.
|
||||
//!
|
||||
//! The flow, driven from the updater's hourly manifest (src/ota.rs hands the parsed `ui` entry over):
|
||||
//! decide: the entry is ignored when its min_engine is newer than this engine, when its version is the active
|
||||
//! one or the embedded one, when it was marked bad before, or when the miner chose the built-in interface
|
||||
//! -> download (curl to <app data>/ui/<version>.tar.gz, size and sha256 against the manifest, then the entry's own
|
||||
//! Ed25519 signature with the release key compiled into src/manifest.rs; the manifest's signature covered it too)
|
||||
//! -> unpack with the system tar into <app data>/ui/<version>.new, index.html, app.js and app.css must be there,
|
||||
//! rename to <app data>/ui/<version>
|
||||
//! -> swap: <app data>/ui/current.json names the version (written to .tmp and renamed: atomic); the server reads
|
||||
//! the pointer through Shared and serves the bundle's files in place of the embedded ones at the next page load;
|
||||
//! the open page sees state.ui.active_version change and reloads itself when idle
|
||||
//! -> confirm: the first page load from a new bundle starts a 10 s wait for the page's health ping
|
||||
//! (POST /api/ui/health after its first paint; a JS error on first paint posts the error instead); no ping, an
|
||||
//! error, or a bundle that fails to unpack rolls back to the embedded interface and marks the version bad
|
||||
//! (<app data>/ui/bad.json): it is never applied again
|
||||
//! The kill switch is the manifest without a `ui` object: the engine falls back to the embedded interface on the
|
||||
//! next check. Same origin, no remote script: the bundle is served from 127.0.0.1 by this engine like the embedded
|
||||
//! files, and the signature is the only trust.
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use crate::manifest::{self, UiEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The page's health ping must arrive this long after the first load of a new bundle.
|
||||
pub const HEALTH_WAIT_S: u64 = 10;
|
||||
/// The version of the interface compiled into this engine (app/igneum-app/ui/VERSION).
|
||||
pub const EMBEDDED_VERSION: &str = include_str!("../ui/VERSION");
|
||||
/// The files a bundle may serve: fixed names, nothing else is read from the bundle folder.
|
||||
pub const SERVED: [&str; 15] = ["index.html", "app.css", "app.js", "mark.svg", "live-dag.js", "proof-core.js", "VERSION", "fonts/IBMPlexMono-400.woff2", "fonts/IBMPlexMono-500.woff2", "fonts/IBMPlexSans-400.woff2", "fonts/IBMPlexSans-500.woff2", "fonts/IBMPlexSans-600.woff2", "fonts/Unbounded-500.woff2", "fonts/Unbounded-700.woff2", "fonts/Unbounded-900.woff2"];
|
||||
/// What a bundle must carry to be swapped in.
|
||||
const REQUIRED: [&str; 3] = ["index.html", "app.js", "app.css"];
|
||||
|
||||
pub fn embedded_version() -> &'static str {
|
||||
EMBEDDED_VERSION.trim()
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
/// the download, the checks and the unpack finished: the bundle folder, or why not (with the version)
|
||||
Installed(String, Result<PathBuf, String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
Apply,
|
||||
Skip(String),
|
||||
}
|
||||
|
||||
/// Whether a published entry is for this engine now (pure; the tests cover every branch).
|
||||
pub fn decide(e: &UiEntry, engine: &str, embedded: &str, active: Option<&str>, bad: &[String], builtin: bool) -> Decision {
|
||||
if builtin {
|
||||
return Decision::Skip("the built-in interface is chosen in Settings".into());
|
||||
}
|
||||
if manifest::newer(&e.min_engine, engine) {
|
||||
return Decision::Skip(format!("interface {} needs engine {} or newer (this is {engine})", e.version, e.min_engine));
|
||||
}
|
||||
if bad.iter().any(|b| b == &e.version) {
|
||||
return Decision::Skip(format!("interface {} failed here before and is not tried again", e.version));
|
||||
}
|
||||
if active == Some(e.version.as_str()) {
|
||||
return Decision::Skip(format!("interface {} is active", e.version));
|
||||
}
|
||||
if e.version == embedded {
|
||||
return Decision::Skip(format!("interface {} is the built-in one", e.version));
|
||||
}
|
||||
Decision::Apply
|
||||
}
|
||||
|
||||
/// The pointer file: which bundle the server serves ("embedded" or a version) and when it was swapped in.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Current {
|
||||
pub version: String,
|
||||
pub installed_at: u64,
|
||||
/// the page confirmed this bundle with a health ping
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
fn read_current(dir: &Path) -> Current {
|
||||
let v: serde_json::Value = std::fs::read_to_string(dir.join("current.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(serde_json::Value::Null);
|
||||
Current { version: v.get("version").and_then(|x| x.as_str()).unwrap_or("embedded").to_string(), installed_at: v.get("installed_at").and_then(|x| x.as_u64()).unwrap_or(0), confirmed: v.get("confirmed").and_then(|x| x.as_bool()).unwrap_or(false) }
|
||||
}
|
||||
|
||||
/// Writes the pointer atomically (the .tmp then the rename).
|
||||
pub fn write_current(dir: &Path, c: &Current) -> Result<(), String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let tmp = dir.join("current.json.tmp");
|
||||
std::fs::write(&tmp, serde_json::json!({ "version": c.version, "installed_at": c.installed_at, "confirmed": c.confirmed }).to_string()).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, dir.join("current.json")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn read_bad(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_to_string(dir.join("bad.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_bad(dir: &Path, bad: &[String]) {
|
||||
let _ = std::fs::write(dir.join("bad.json"), serde_json::to_string(bad).unwrap_or_default());
|
||||
}
|
||||
|
||||
/// The bundle folder for a version, if it holds what the server needs.
|
||||
pub fn bundle_dir(dir: &Path, version: &str) -> Option<PathBuf> {
|
||||
if version == "embedded" || version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let d = dir.join(version);
|
||||
if REQUIRED.iter().all(|f| d.join(f).is_file()) { Some(d) } else { None }
|
||||
}
|
||||
|
||||
/// Size, sha256 and the entry's own signature, then the unpack into <dir>/<version>: the folder on success.
|
||||
/// `pub_hex` is the release key (manifest::OTA_PUBLIC_KEY_HEX in the engine; the tests pass their own).
|
||||
pub fn install_bundle(e: &UiEntry, file: &Path, dir: &Path, pub_hex: &str) -> Result<PathBuf, String> {
|
||||
let size = std::fs::metadata(file).map(|m| m.len()).map_err(|er| format!("{}: {er}", file.display()))?;
|
||||
if size != e.size {
|
||||
return Err(format!("interface {}: the download is {size} bytes, the manifest says {}", e.version, e.size));
|
||||
}
|
||||
let sum = manifest::sha256_file(file).map_err(|er| er.to_string())?;
|
||||
if sum != e.sha256 {
|
||||
return Err(format!("interface {}: sha256 mismatch", e.version));
|
||||
}
|
||||
manifest::verify_ui_entry(e, pub_hex).map_err(|er| format!("interface {}: {er}", e.version))?;
|
||||
let fresh = dir.join(format!("{}.new", e.version));
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
std::fs::create_dir_all(&fresh).map_err(|er| er.to_string())?;
|
||||
let mut c = std::process::Command::new(crate::platform::tool("tar"));
|
||||
c.args(["-xzf", &file.display().to_string(), "-C", &fresh.display().to_string()]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("tar is not available")?;
|
||||
// a bundle packed with a top-level folder: take it
|
||||
let root = if REQUIRED.iter().all(|f| fresh.join(f).is_file()) {
|
||||
fresh.clone()
|
||||
} else {
|
||||
let inner = std::fs::read_dir(&fresh).ok().into_iter().flatten().filter_map(|d| d.ok()).map(|d| d.path()).find(|p| p.is_dir() && REQUIRED.iter().all(|f| p.join(f).is_file()));
|
||||
match inner {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
return Err(format!("interface {}: the bundle has no index.html, app.js and app.css ({})", e.version, out.lines().last().unwrap_or("tar said nothing")));
|
||||
}
|
||||
}
|
||||
};
|
||||
let dest = dir.join(&e.version);
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
std::fs::rename(&root, &dest).map_err(|er| format!("interface {}: {er}", e.version))?;
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
// the bundle's own VERSION must agree with the manifest (a renamed bundle is refused)
|
||||
let v = std::fs::read_to_string(dest.join("VERSION")).unwrap_or_default();
|
||||
if v.trim() != e.version {
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
return Err(format!("interface {}: the bundle's VERSION file says '{}'", e.version, v.trim()));
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// The engine's side: what is active, what is pending, the health wait, the rollback.
|
||||
pub struct UiOta {
|
||||
dir: PathBuf,
|
||||
current: Current,
|
||||
bad: Vec<String>,
|
||||
builtin: bool,
|
||||
busy: bool,
|
||||
/// the manifest entry seen last (for the Settings line and the retry after an error)
|
||||
entry: Option<UiEntry>,
|
||||
/// a bundle served to a page and not yet confirmed: (version, when the page loaded)
|
||||
waiting: Option<(String, Instant)>,
|
||||
error: String,
|
||||
/// the whole download thread's last reason, kept for the state
|
||||
last_skip: String,
|
||||
health_wait: Duration,
|
||||
}
|
||||
|
||||
impl UiOta {
|
||||
pub fn new(shared: &Arc<Shared>) -> UiOta {
|
||||
let dir = shared.runtime.app_dir.join("ui");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let builtin = shared.settings.lock().unwrap().ui_builtin;
|
||||
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: read_bad(&dir), builtin, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(HEALTH_WAIT_S) };
|
||||
// a pointer to a folder that is not there (a cleaned app data folder) falls back without a word
|
||||
if bundle_dir(&dir, &u.current.version).is_none() && u.current.version != "embedded" {
|
||||
shared.log(&format!("ui: the pointer names interface {} but its folder is gone; the built-in interface serves", u.current.version));
|
||||
u.current = Current { version: "embedded".into(), installed_at: 0, confirmed: true };
|
||||
let _ = write_current(&dir, &u.current);
|
||||
}
|
||||
u.apply_pointer(shared);
|
||||
if u.current.version != "embedded" {
|
||||
shared.log(&format!("ui bundle {} active (installed {}){}", u.current.version, u.current.installed_at, if u.builtin { ", but the built-in interface is chosen" } else { "" }));
|
||||
}
|
||||
u.publish(shared);
|
||||
u
|
||||
}
|
||||
|
||||
/// What the server serves: the bundle folder, or None for the embedded files.
|
||||
fn apply_pointer(&self, shared: &Arc<Shared>) {
|
||||
let d = if self.builtin { None } else { bundle_dir(&self.dir, &self.current.version) };
|
||||
shared.set_ui_dir(d);
|
||||
}
|
||||
|
||||
pub fn active_version(&self) -> &str {
|
||||
&self.current.version
|
||||
}
|
||||
|
||||
/// Called with every verified manifest: the `ui` entry or None (the kill switch).
|
||||
pub fn consider(&mut self, shared: &Arc<Shared>, entry: Option<&UiEntry>, engine: &str) {
|
||||
let Some(e) = entry else {
|
||||
self.entry = None;
|
||||
if self.current.version != "embedded" {
|
||||
shared.event("info", &format!("the interface channel was withdrawn; the built-in interface {} serves again", embedded_version()));
|
||||
shared.log(&format!("ui: no ui object in the manifest; interface {} retired", self.current.version));
|
||||
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
|
||||
}
|
||||
self.publish(shared);
|
||||
return;
|
||||
};
|
||||
self.entry = Some(e.clone());
|
||||
if self.busy {
|
||||
return;
|
||||
}
|
||||
match decide(e, engine, embedded_version(), Some(&self.current.version), &self.bad, self.builtin) {
|
||||
Decision::Skip(why) => {
|
||||
if why != self.last_skip {
|
||||
shared.log(&format!("ui: {why}"));
|
||||
self.last_skip = why;
|
||||
}
|
||||
}
|
||||
Decision::Apply => {
|
||||
self.last_skip = String::new();
|
||||
self.busy = true;
|
||||
self.error = String::new();
|
||||
shared.event("info", &format!("interface {} is published: downloading ({} KB)", e.version, e.size / 1000));
|
||||
let e2 = e.clone();
|
||||
let dir = self.dir.clone();
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = download_and_install(&e2, &dir);
|
||||
shared2.send(Cmd::UiOta(Event::Installed(e2.version.clone(), r)));
|
||||
});
|
||||
}
|
||||
}
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
pub fn event(&mut self, shared: &Arc<Shared>, ev: Event) {
|
||||
self.busy = false;
|
||||
match ev {
|
||||
Event::Installed(version, Ok(_dir)) => {
|
||||
shared.event("ok", &format!("interface {version} installed; the window takes it at its next load"));
|
||||
self.set_current(shared, Current { version: version.clone(), installed_at: crate::platform::unix_now(), confirmed: false });
|
||||
shared.log(&format!("ui bundle {version} active (installed {}), awaiting the page's health ping", self.current.installed_at));
|
||||
}
|
||||
Event::Installed(version, Err(e)) => {
|
||||
self.mark_bad(shared, &version, &e);
|
||||
}
|
||||
}
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
fn set_current(&mut self, shared: &Arc<Shared>, c: Current) {
|
||||
self.current = c;
|
||||
if let Err(e) = write_current(&self.dir, &self.current) {
|
||||
shared.log(&format!("ui: could not write the pointer: {e}"));
|
||||
}
|
||||
self.waiting = None;
|
||||
self.apply_pointer(shared);
|
||||
}
|
||||
|
||||
fn mark_bad(&mut self, shared: &Arc<Shared>, version: &str, why: &str) {
|
||||
if !self.bad.iter().any(|b| b == version) {
|
||||
self.bad.push(version.to_string());
|
||||
write_bad(&self.dir, &self.bad);
|
||||
}
|
||||
self.error = why.to_string();
|
||||
shared.event("error", &format!("interface {version} was refused: {why}. The built-in interface serves"));
|
||||
shared.log(&format!("ui: {version} marked bad: {why}"));
|
||||
if self.current.version == version {
|
||||
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// The server served index.html from a bundle: an unconfirmed one starts the health wait.
|
||||
pub fn page_loaded(&mut self, version: &str, now: Instant) {
|
||||
if version == self.current.version && !self.current.confirmed && self.waiting.is_none() {
|
||||
self.waiting = Some((version.to_string(), now));
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's ping after its first paint, or the error it caught before it.
|
||||
pub fn health(&mut self, shared: &Arc<Shared>, error: Option<&str>) {
|
||||
let Some((version, _)) = self.waiting.clone() else { return };
|
||||
match error {
|
||||
None => {
|
||||
self.waiting = None;
|
||||
self.current.confirmed = true;
|
||||
let _ = write_current(&self.dir, &self.current);
|
||||
shared.log(&format!("ui bundle {version} confirmed by the page"));
|
||||
self.publish(shared);
|
||||
}
|
||||
Some(e) => {
|
||||
self.mark_bad(shared, &version, &format!("a script error on first paint: {}", e.chars().take(200).collect::<String>()));
|
||||
self.publish(shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wait ran out: the page never confirmed the bundle.
|
||||
pub fn tick(&mut self, shared: &Arc<Shared>, now: Instant) {
|
||||
if let Some((version, since)) = self.waiting.clone() {
|
||||
if now.duration_since(since) >= self.health_wait {
|
||||
self.mark_bad(shared, &version, &format!("the page did not answer within {} s of loading it", self.health_wait.as_secs()));
|
||||
self.publish(shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_builtin(&mut self, shared: &Arc<Shared>, on: bool) {
|
||||
self.builtin = on;
|
||||
{
|
||||
let mut s = shared.settings.lock().unwrap();
|
||||
s.ui_builtin = on;
|
||||
s.save(&shared.settings_path);
|
||||
}
|
||||
shared.state.lock().unwrap().settings.ui_builtin = on;
|
||||
self.waiting = None;
|
||||
self.apply_pointer(shared);
|
||||
shared.event("info", if on { "the built-in interface serves from the next page load" } else { "the over-the-air interface serves again when one is active" });
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
/// Settings > Interface: what serves, from where, since when.
|
||||
pub fn publish(&self, shared: &Arc<Shared>) {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
let u = &mut st.ui;
|
||||
let ota_active = !self.builtin && bundle_dir(&self.dir, &self.current.version).is_some();
|
||||
u.embedded_version = embedded_version().into();
|
||||
u.active_version = if ota_active { self.current.version.clone() } else { embedded_version().into() };
|
||||
u.source = if ota_active { "ota".into() } else { "embedded".into() };
|
||||
u.installed_at = if ota_active { self.current.installed_at as f64 } else { 0.0 };
|
||||
u.confirmed = !ota_active || self.current.confirmed;
|
||||
u.published_version = self.entry.as_ref().map(|e| e.version.clone()).unwrap_or_default();
|
||||
u.busy = self.busy;
|
||||
u.error = self.error.clone();
|
||||
u.bad = self.bad.clone();
|
||||
u.builtin = self.builtin;
|
||||
u.note = self.last_skip.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// The download thread: curl with resume into <dir>/<version>.tar.gz, then install_bundle.
|
||||
fn download_and_install(e: &UiEntry, dir: &Path) -> Result<PathBuf, String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let file = dir.join(format!("{}.tar.gz", e.version));
|
||||
let part = dir.join(format!("{}.tar.gz.part", e.version));
|
||||
let mut c = std::process::Command::new(crate::platform::tool("curl"));
|
||||
c.args(["-fsSL", "--max-time", "300", "-C", "-", "-o", &part.display().to_string(), &e.url]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(320)).ok_or("curl is not available")?;
|
||||
let t = out.trim();
|
||||
if !t.is_empty() && !part.is_file() {
|
||||
return Err(format!("interface {}: {}", e.version, t.lines().last().unwrap_or("curl failed")));
|
||||
}
|
||||
std::fs::rename(&part, &file).map_err(|er| er.to_string())?;
|
||||
let r = install_bundle(e, &file, dir, manifest::OTA_PUBLIC_KEY_HEX);
|
||||
if r.is_err() {
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The content type a served bundle file gets (the same list the embedded files use).
|
||||
pub fn content_type(rel: &str) -> &'static str {
|
||||
if rel.ends_with(".html") { "text/html; charset=utf-8" } else if rel.ends_with(".css") { "text/css; charset=utf-8" } else if rel.ends_with(".js") { "application/javascript; charset=utf-8" } else if rel.ends_with(".svg") { "image/svg+xml" } else if rel.ends_with(".woff2") { "font/woff2" } else { "text/plain; charset=utf-8" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn entry(version: &str, min_engine: &str) -> UiEntry {
|
||||
UiEntry { version: version.into(), sha256: "ab".repeat(32), size: 1, url: "https://dl.igneum.network/dl/x/ui/igneum-ui-1.0.1.tar.gz".into(), min_engine: min_engine.into(), signature: "cd".repeat(64) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decision_covers_every_reason_to_skip() {
|
||||
let e = entry("1.0.1", "0.3.19");
|
||||
assert_eq!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], false), Decision::Apply);
|
||||
assert_eq!(decide(&e, "0.3.20", "1.0.0", Some("1.0.0"), &[], false), Decision::Apply);
|
||||
assert!(matches!(decide(&e, "0.3.18", "1.0.0", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("needs engine 0.3.19")), "a too-new min_engine is ignored");
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("1.0.1"), &[], false), Decision::Skip(w) if w.contains("is active")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.1", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("built-in one")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &["1.0.1".to_string()], false), Decision::Skip(w) if w.contains("failed here before")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], true), Decision::Skip(w) if w.contains("chosen in Settings")));
|
||||
}
|
||||
|
||||
/// A bundle folder, packed with the system tar, hashed and signed with a throwaway key.
|
||||
fn make_bundle(root: &Path, version: &str, with_index: bool, sk: &SigningKey) -> (UiEntry, PathBuf) {
|
||||
let src = root.join("src");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
std::fs::create_dir_all(src.join("fonts")).unwrap();
|
||||
if with_index {
|
||||
std::fs::write(src.join("index.html"), "<!doctype html><title>x</title>").unwrap();
|
||||
}
|
||||
std::fs::write(src.join("app.js"), "var x = 1;").unwrap();
|
||||
std::fs::write(src.join("app.css"), "body{}").unwrap();
|
||||
std::fs::write(src.join("VERSION"), format!("{version}\n")).unwrap();
|
||||
std::fs::write(src.join("fonts/IBMPlexMono-400.woff2"), b"wOF2").unwrap();
|
||||
let tar = root.join(format!("igneum-ui-{version}.tar.gz"));
|
||||
let mut c = std::process::Command::new("tar");
|
||||
c.args(["-czf", &tar.display().to_string(), "-C", &src.display().to_string(), "."]);
|
||||
let ok = crate::platform::quiet(&mut c).status().unwrap().success();
|
||||
assert!(ok, "tar packs the fixture");
|
||||
let sha = manifest::sha256_file(&tar).unwrap();
|
||||
let size = std::fs::metadata(&tar).unwrap().len();
|
||||
let sig = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(version, &sha, "0.3.19")).to_bytes());
|
||||
(UiEntry { version: version.into(), sha256: sha, size, url: "https://dl.igneum.network/dl/x/ui/x.tar.gz".into(), min_engine: "0.3.19".into(), signature: sig }, tar)
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("igneum-uiota-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_bundle_installs_and_the_pointer_swaps_atomically() {
|
||||
let root = tmp("good");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.1", true, &sk);
|
||||
let dir = root.join("ui");
|
||||
let dest = install_bundle(&e, &tar, &dir, &pk).expect("installs");
|
||||
assert_eq!(dest, dir.join("1.0.1"));
|
||||
assert!(bundle_dir(&dir, "1.0.1").is_some());
|
||||
assert!(!dir.join("1.0.1.new").exists(), "the staging folder is gone");
|
||||
write_current(&dir, &Current { version: "1.0.1".into(), installed_at: 5, confirmed: false }).unwrap();
|
||||
assert_eq!(read_current(&dir).version, "1.0.1");
|
||||
assert!(!dir.join("current.json.tmp").exists());
|
||||
assert_eq!(content_type("app.js"), "application/javascript; charset=utf-8");
|
||||
assert_eq!(bundle_dir(&dir, "embedded"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_signature_is_refused_before_anything_is_unpacked() {
|
||||
let root = tmp("badsig");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let other = manifest::hex_encode(SigningKey::from_bytes(&[8u8; 32]).verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.2", true, &sk);
|
||||
let dir = root.join("ui");
|
||||
let err = install_bundle(&e, &tar, &dir, &other).unwrap_err();
|
||||
assert!(err.contains("signature does not verify"), "{err}");
|
||||
assert!(!dir.join("1.0.2").exists());
|
||||
// a tampered hash is caught first
|
||||
let mut t = e.clone();
|
||||
t.sha256 = "00".repeat(32);
|
||||
let err = install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err();
|
||||
assert!(err.contains("sha256 mismatch"), "{err}");
|
||||
// a wrong size too
|
||||
let mut t = e.clone();
|
||||
t.size += 1;
|
||||
assert!(install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err().contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_bundle_without_index_html_is_refused_and_leaves_nothing_behind() {
|
||||
let root = tmp("broken");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.3", false, &sk);
|
||||
let dir = root.join("ui");
|
||||
let err = install_bundle(&e, &tar, &dir, &pk).unwrap_err();
|
||||
assert!(err.contains("no index.html"), "{err}");
|
||||
assert!(!dir.join("1.0.3").exists() && !dir.join("1.0.3.new").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_bundle_is_refused_by_its_version_file() {
|
||||
let root = tmp("renamed");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (mut e, tar) = make_bundle(&root, "1.0.4", true, &sk);
|
||||
e.version = "1.0.5".into();
|
||||
e.signature = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes("1.0.5", &e.sha256, "0.3.19")).to_bytes());
|
||||
let err = install_bundle(&e, &tar, &root.join("ui"), &pk).unwrap_err();
|
||||
assert!(err.contains("VERSION file says '1.0.4'"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_health_wait_rolls_back_to_the_embedded_interface_and_marks_the_version_bad() {
|
||||
// the state machine without threads: a UiOta over a temp dir, driven by hand
|
||||
let root = tmp("health");
|
||||
let dir = root.join("ui");
|
||||
std::fs::create_dir_all(dir.join("1.0.6")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.6").join(f), "x").unwrap();
|
||||
}
|
||||
write_current(&dir, &Current { version: "1.0.6".into(), installed_at: 1, confirmed: false }).unwrap();
|
||||
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: vec![], builtin: false, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(10) };
|
||||
let t0 = Instant::now();
|
||||
u.page_loaded("1.0.6", t0);
|
||||
assert!(u.waiting.is_some());
|
||||
// a ping in time confirms
|
||||
let shared = crate::engine::Shared::for_tests(root.join("data"));
|
||||
u.health(&shared, None);
|
||||
assert!(u.current.confirmed && u.waiting.is_none());
|
||||
assert_eq!(read_current(&dir).confirmed, true);
|
||||
// a second bundle that never answers
|
||||
std::fs::create_dir_all(dir.join("1.0.7")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.7").join(f), "x").unwrap();
|
||||
}
|
||||
u.set_current(&shared, Current { version: "1.0.7".into(), installed_at: 2, confirmed: false });
|
||||
u.page_loaded("1.0.7", t0);
|
||||
u.tick(&shared, t0 + Duration::from_secs(9));
|
||||
assert_eq!(u.current.version, "1.0.7", "still waiting inside the window");
|
||||
u.tick(&shared, t0 + Duration::from_secs(10));
|
||||
assert_eq!(u.current.version, "embedded", "rolled back");
|
||||
assert_eq!(read_current(&dir).version, "embedded");
|
||||
assert_eq!(read_bad(&dir), vec!["1.0.7".to_string()]);
|
||||
assert!(shared.ui_dir().is_none(), "the server serves the embedded files again");
|
||||
// a script error on first paint rolls back the same way
|
||||
std::fs::create_dir_all(dir.join("1.0.8")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.8").join(f), "x").unwrap();
|
||||
}
|
||||
u.set_current(&shared, Current { version: "1.0.8".into(), installed_at: 3, confirmed: false });
|
||||
u.page_loaded("1.0.8", t0);
|
||||
u.health(&shared, Some("TypeError: x is not a function"));
|
||||
assert_eq!(u.current.version, "embedded");
|
||||
assert!(u.bad.contains(&"1.0.8".to_string()));
|
||||
// and the decision never applies it again
|
||||
let e = entry("1.0.8", "0.3.19");
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &u.bad, false), Decision::Skip(_)));
|
||||
}
|
||||
}
|
||||
1
app/igneum-app/ui/VERSION
Normal file
1
app/igneum-app/ui/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.0
|
||||
Loading…
Reference in a new issue