diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 28dc0c7..ba3173d 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -17,19 +17,14 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - name: Rust testen + - name: Anwendung bauen und testen run: | - cargo fmt --check --manifest-path scanner/Cargo.toml - cargo fmt --check --manifest-path controller/Cargo.toml - cargo fmt --check --manifest-path gui/Cargo.toml - cargo clippy --locked --manifest-path scanner/Cargo.toml --all-targets -- -D warnings - cargo clippy --locked --manifest-path controller/Cargo.toml --all-targets -- -D warnings - cargo clippy --locked --manifest-path gui/Cargo.toml --all-targets -- -D warnings - cargo test --locked --manifest-path scanner/Cargo.toml - cargo test --locked --manifest-path controller/Cargo.toml - cargo test --locked --manifest-path gui/Cargo.toml - - name: Python und Rauchtests - run: | - python3 -W error -m unittest discover -s tests -p 'test_*.py' - bash -n scripts/*.sh live/*.sh tests/*.sh - bash tests/smoke.sh + bash -n scripts/*.sh tests/*.sh + bash scripts/build-release.sh + ./dist/csr-intake-x86_64.run --check-bundle + - name: Eigenständige Anwendung bereitstellen + uses: actions/upload-artifact@v4 + with: + name: csr-intake-x86_64 + path: dist/csr-intake-x86_64.run + if-no-files-found: error diff --git a/README.md b/README.md index 2b6f9a5..661405c 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ -# CS-R USB-AIO +# CS-R Intake -Der USB-Stick unterstützt die Annahme und technische Prüfung gespendeter Computer. -Er startet als eigenständiges Arch-Linux-Livesystem, erkennt das aktuelle Gerät über -seine Hardware-ID und verbindet es mit der zentralen Inventarverwaltung. +CS-R Intake ist eine eigenständige Linux-Anwendung für die Annahme und technische +Prüfung gespendeter Computer. Sie erkennt das aktuelle Gerät über eine stabile +Hardware-ID, verbindet es mit der Inventarverwaltung und speichert alle Arbeiten +auch ohne Netzwerk lokal. ## Ablauf in der Werkstatt -1. Vom USB-Stick starten. +1. `csr-intake-x86_64.run` auf dem zu prüfenden Linux-Rechner starten. 2. Beim ersten Einsatz unter **Einstellungen** das Werkstatt-WLAN verbinden und **WLAN speichern** aktiviert lassen. 3. Das Gerät wird über DMI-UUID oder Hersteller und Seriennummer erkannt. 4. Die Inventarverwaltung liefert die vorhandene Gerätenummer oder vergibt eine neue. -5. Geräte im Status **Eingang** erhalten automatisch einen Kurzscan mit Modell, CPU, - RAM und Datenträgern. -6. Danach kann der Techniker das Gerät ausschalten oder die Inspektion starten. +5. Neue Geräte und Geräte im Status **Eingang** erhalten automatisch einen Kurzscan + mit Modell, CPU, RAM und Datenträgern. +6. Danach kann der Techniker die Anwendung schließen oder die Inspektion starten. 7. Prüfstände und Ergebnisse werden als **Inspektion** synchronisiert. Offline - gespeicherte Ergebnisse bleiben in der Warteschlange und werden später erneut gesendet. + gespeicherte Ergebnisse bleiben in der Warteschlange und werden später gesendet. Die Oberfläche konzentriert sich auf **Gerät**, **Inspektion** und **Einstellungen**. Reparaturen, Berichte und die Synchronisationswarteschlange sind über das Menü erreichbar. @@ -36,87 +37,70 @@ Reparaturen, Berichte und die Synchronisationswarteschlange sind über das Menü - zentrale Sprache, Testdauer und Grenzwerte - verifizierbarer Build mit Versions- und Prüfsummenmanifest +## Anwendung starten + +Die Release-Datei enthält Oberfläche und Hardware-Scanner. Sie entpackt geprüfte +Programmdateien in den Benutzer-Cache und speichert Einstellungen, Berichte und die +Offline-Warteschlange standardmäßig unter `~/.local/share/csr-intake`. + +```bash +chmod +x csr-intake-x86_64.run +./csr-intake-x86_64.run +``` + +Ein anderer Datenordner kann mit `--data /pfad/zum/ordner` gewählt werden: + +```bash +./csr-intake-x86_64.run --data "$HOME/csr-intake-daten" +./csr-intake-x86_64.run --version +./csr-intake-x86_64.run --check-bundle +``` + +Die Anwendung ist für x86-64 Linux gebaut. Sie benötigt eine grafische Sitzung, +Python 3, SQLite und NetworkManager. Einzelne Diagnosen verwenden Systemwerkzeuge +wie `smartctl`, `stress-ng`, `ffmpeg`, `arecord`, `lsusb` und `xdg-open`. Fehlt ein +Werkzeug, wird der Test als **nicht verfügbar** und nicht als Hardwarefehler bewertet. + +## Konfiguration + +Diese Dateien entstehen im Datenordner unter `config/`: + +```text +inventory.json +inventory-api-key +inventory-server.crt +workshop-wifi.json +workshop.json +``` + +API-Schlüssel und WLAN-Passwort gehören nie in Git. Ein privates CA-Zertifikat darf +nur als öffentliches Zertifikat gespeichert werden. Sensible Konfigurationsdateien +sind nur für den Besitzer lesbar. + +## Projekt bauen und prüfen + +Voraussetzungen sind Rust, Cargo, Python 3 sowie die Entwicklungsbibliotheken für +X11, Wayland und OpenGL. Der vollständige Build führt Rust-, Python- und Rauchtests +aus und erzeugt anschließend genau ein verteilbares Artefakt: + +```bash +bash scripts/build-release.sh +./dist/csr-intake-x86_64.run --check-bundle +``` + +Das Ergebnis liegt in `dist/csr-intake-x86_64.run`. `build/release.json` enthält +Build-ID, Versionen und SHA-256-Prüfsummen der eingebetteten Programme. Die CI stellt +dieselbe `.run`-Datei bei jedem erfolgreichen Build als Artefakt bereit. + ## Projektaufbau | Pfad | Inhalt | | --- | --- | | `gui/` | Bedienoberfläche und geführte Tests | | `scanner/` | Hardwareerkennung und Messwerte | -| `controller/` | Geräteklassifizierung und Auswertung | | `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge | -| `live/` | Start und Vorbereitung des Livesystems | | `tests/` | Python- und Rauchtests | | `config.example/` | Beispiel für die Inventarverbindung | -Der Inventory-Server wird in einem eigenen Repository gepflegt. Dieses Repository -enthält ausschließlich den USB-Client. - -## Entwicklung und Prüfung - -Voraussetzungen sind Rust, Cargo und Python 3. Auf Arch Linux werden für den ISO-Bau -zusätzlich `archiso`, GRUB, Dateisystemwerkzeuge und `libarchive` benötigt. - -```bash -bash scripts/build-release.sh -``` - -Der Befehl führt die Rust-, Python- und Rauchtests aus, baut alle drei Programme im -Release-Modus und erzeugt `build/release.json` mit Build-ID und SHA-256-Prüfsummen. - -## Eigenständige Anwendung - -Für einen normalen x86-64-Linux-Rechner kann eine einzelne ausführbare Datei gebaut -werden. Sie enthält GUI und Hardware-Scanner und verwendet standardmäßig -`~/.local/share/csr-intake` für Berichte und Einstellungen: - -```bash -bash scripts/build-release.sh -bash scripts/build-standalone.sh -./dist/csr-intake-x86_64.run -``` - -Ein anderer Datenordner kann mit `--data /pfad/zum/ordner` gewählt werden. Das Paket -ist für die Linux-Version des Build-Rechners bestimmt und benötigt Python 3, SQLite, -NetworkManager sowie die jeweiligen Diagnoseprogramme. `--check-bundle` prüft die -eingebetteten Dateien, ohne die Oberfläche zu starten. - -## ISO erstellen - -Das Zielverzeichnis muss neu und leer sein: - -```bash -bash scripts/build-iso.sh "$PWD/build/usb-aio-2.6.0" -``` - -Der Build prüft Profil, Binärdateien, Kernelpfade und die FAT32-Größengrenze. Ein -erfolgreicher Softwaretest ersetzt keinen Starttest auf echter UEFI- und BIOS-Hardware. - -## USB-Stick installieren - -Dieser Befehl löscht den ausgewählten Datenträger vollständig: - -```bash -lsblk -o NAME,PATH,TYPE,SIZE,TRAN,MODEL,SERIAL,MOUNTPOINTS -sudo bash scripts/install-usb.sh /dev/sdX /absoluter/pfad/zur.iso -``` - -Das Installationsskript akzeptiert nur ein vollständiges USB-Blockgerät, lehnt den -Systemdatenträger ab und verlangt die exakte Bestätigung `ERASE /dev/sdX`. - -## Konfiguration auf `INTAKE_DATA` - -Die dauerhaften Dateien liegen auf der Datenpartition des Sticks: - -```text -config/inventory.json -config/inventory-api-key -config/inventory-server.crt -config/workshop-wifi.json -config/workshop.json -``` - -API-Schlüssel und WLAN-Passwort gehören nie in Git. Ein privates CA-Zertifikat darf -nur als öffentliches Zertifikat gespeichert werden. Die Datenpartition ist nicht -verschlüsselt und muss wie ein Werkstatt-Zugang behandelt werden. - -Weitere Einzelheiten stehen in [WERKSTATT-ABLAUF.md](WERKSTATT-ABLAUF.md). +Der Inventory-Server wird in einem eigenen Repository gepflegt. Weitere Einzelheiten +zum Arbeitsablauf stehen in [WERKSTATT-ABLAUF.md](WERKSTATT-ABLAUF.md). diff --git a/WERKSTATT-ABLAUF.md b/WERKSTATT-ABLAUF.md index 4b6a9c4..49f75d9 100644 --- a/WERKSTATT-ABLAUF.md +++ b/WERKSTATT-ABLAUF.md @@ -2,10 +2,11 @@ ## Geräteerkennung -Beim Start liest das Livesystem geschützte DMI-Felder einmal als `root` und stellt -sie der unprivilegierten Anwendung nur lesbar bereit. Bevorzugt wird eine gültige -System-UUID. Fehlt sie, wird eine gehashte Kombination aus Hersteller und +Beim Start liest die Anwendung die verfügbaren DMI-Felder. Bevorzugt wird eine +gültige System-UUID. Fehlt sie, wird eine gehashte Kombination aus Hersteller und Seriennummer verwendet. Platzhalter wie `To be filled by O.E.M.` sind ungültig. +Falls der Linux-Benutzer einzelne DMI-Felder nicht lesen darf, müssen die Rechte auf +dem Werkstattrechner passend eingerichtet werden. Die Hardware-ID wird an `POST /api/v1/devices/resolve/` gesendet. Der Server ordnet sie einem vorhandenen Gerät zu oder vergibt atomar eine neue Gerätenummer. UUID und @@ -47,11 +48,11 @@ einen Technikernamen und vollständig bewertete Prüfschritte. ## Unterbrechung und Offlinebetrieb -Der lokale Prüfstand wird atomar gespeichert. Nach Neustart öffnet die Anwendung -die letzte unvollständige Prüfung mit Messwerten und Notizen. Noch nicht bestätigte -Uploads liegen in `outbox/inspections/`; bestätigte Uploads werden nach -`outbox/sent/` verschoben. Beim nächsten Start werden auch gespeicherte Prüfungen -des zuvor bearbeiteten Computers wieder in die Warteschlange aufgenommen. +Der lokale Prüfstand wird atomar gespeichert. Nach einem Neustart öffnet die +Anwendung die letzte unvollständige Prüfung mit Messwerten und Notizen. Noch nicht +bestätigte Uploads liegen in `outbox/inspections/`; bestätigte Uploads werden nach +`outbox/sent/` verschoben. Beim nächsten Start werden gespeicherte Prüfungen erneut +in die Warteschlange aufgenommen. ## Reparaturen und Bericht @@ -81,12 +82,11 @@ Zertifikats- und Hostnamenprüfung bleiben aktiv. Vor dem Werkstatteinsatz sind mindestens diese Punkte auf echter Hardware zu prüfen: -1. UEFI- und BIOS-Start -2. persistente Daten nach Neustart +1. Start der eigenständigen Datei in der vorgesehenen Linux-Umgebung +2. persistente Daten nach Neustart der Anwendung und des Rechners 3. automatische Wiederverbindung mit dem WLAN 4. richtige Gerätezuordnung und Dublettenwarnung 5. Intake-Synchronisation ohne Überschreiben späterer Zustände 6. Ein- und Ausgabegeräte sowie jedes Laufwerk 7. Offline-Warteschlange und Wiederholung nach Verbindungsabbruch 8. druckbarer Bericht -9. kontrolliertes Herunterfahren diff --git a/controller/Cargo.lock b/controller/Cargo.lock deleted file mode 100644 index d112d83..0000000 --- a/controller/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "intake-controller" -version = "1.7.0" diff --git a/controller/Cargo.toml b/controller/Cargo.toml deleted file mode 100644 index 59749a7..0000000 --- a/controller/Cargo.toml +++ /dev/null @@ -1,5 +0,0 @@ -[package] -name = "intake-controller" -version = "1.7.0" -edition = "2021" -[dependencies] diff --git a/controller/src/extended.rs b/controller/src/extended.rs deleted file mode 100644 index 2428011..0000000 --- a/controller/src/extended.rs +++ /dev/null @@ -1,405 +0,0 @@ -//! Optional technician workstations; no write access to internal devices. -use std::{ - fs, - io::{self, Write}, - path::Path, - process::Command, - time::{SystemTime, UNIX_EPOCH}, -}; - -fn ask(prompt: &str) -> String { - print!("{}", prompt); - let _ = io::stdout().flush(); - let mut answer = String::new(); - if io::stdin().read_line(&mut answer).is_err() { - return String::new(); - } - answer.trim().to_string() -} -fn read_trim(path: &str) -> Option { - fs::read_to_string(path) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} -fn output(exe: &str, args: &[&str]) -> Option { - let r = Command::new(exe).args(args).output().ok()?; - if r.status.success() { - Some(String::from_utf8_lossy(&r.stdout).trim().to_owned()) - } else { - None - } -} -fn escape_json(s: &str) -> String { - let mut result = String::from("\""); - for c in s.chars() { - match c { - '"' => result.push_str("\\\""), - '\\' => result.push_str("\\\\"), - '\n' => result.push_str("\\n"), - '\r' => result.push_str("\\r"), - '\t' => result.push_str("\\t"), - c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)), - c => result.push(c), - } - } - result.push('"'); - result -} -fn valid_asset(id: &str) -> bool { - !id.is_empty() - && id.len() <= 64 - && id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') -} -fn normalize_hostname(model: &str, asset: &str) -> String { - let source = format!("{}-{}", model, asset).to_ascii_lowercase(); - let mut result = String::new(); - let mut last_dash = false; - for c in source.chars() { - if c.is_ascii_alphanumeric() { - result.push(c); - last_dash = false; - } else if !last_dash && !result.is_empty() { - result.push('-'); - last_dash = true; - } - } - result - .trim_matches('-') - .chars() - .take(63) - .collect::() - .trim_end_matches('-') - .to_owned() -} -fn net_interfaces() -> Vec<(String, String, String, String)> { - let mut list = Vec::new(); - if let Ok(entries) = fs::read_dir("/sys/class/net") { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if name == "lo" { - continue; - } - let base = format!("/sys/class/net/{name}"); - let kind = if Path::new(&format!("{base}/wireless")).exists() { - "Wi-Fi" - } else { - "LAN/other" - }; - let carrier = read_trim(&format!("{base}/carrier")).unwrap_or_else(|| "unknown".into()); - let mac = read_trim(&format!("{base}/address")).unwrap_or_else(|| "unknown".into()); - list.push((name, kind.to_string(), carrier, mac)); - } - } - list.sort(); - list -} -fn ethernet_connected() -> bool { - net_interfaces() - .iter() - .any(|(_, kind, carrier, _)| kind == "LAN/other" && carrier == "1") -} -fn mobile_device() -> bool { - let chassis = read_trim("/sys/class/dmi/id/chassis_type") - .or_else(|| read_trim("/run/csr-intake/chassis_type")) - .unwrap_or_default(); - matches!( - chassis.as_str(), - "8" | "9" | "10" | "11" | "14" | "30" | "31" | "32" - ) || fs::read_dir("/sys/class/power_supply").is_ok_and(|entries| { - entries.flatten().any(|entry| { - read_trim(&entry.path().join("type").to_string_lossy()) - .is_some_and(|kind| kind == "Battery") - }) - }) -} -fn memory_dmi() -> (String, String) { - let text = match output("dmidecode", &["-t", "memory"]) { - Some(s) => s, - None => { - return ( - "UNKNOWN (dmidecode unavailable or permissions missing)".into(), - "UNKNOWN".into(), - ) - } - }; - let mut slots = 0usize; - let mut occupied = 0usize; - let mut max_cap = String::from("UNKNOWN"); - let mut in_device = false; - for line in text.lines() { - let t = line.trim(); - if t == "Memory Device" { - slots += 1; - in_device = true; - continue; - } - if t == "Physical Memory Array" { - in_device = false; - continue; - } - if t.starts_with("Maximum Capacity:") && max_cap == "UNKNOWN" { - max_cap = t.trim_start_matches("Maximum Capacity:").trim().into(); - } - if in_device - && t.starts_with("Size:") - && !t.contains("No Module Installed") - && !t.ends_with("Unknown") - { - occupied += 1; - in_device = false; - } - } - let summary = if slots == 0 { - "UNKNOWN".into() - } else { - format!( - "{occupied}/{slots} occupied; {} free", - slots.saturating_sub(occupied) - ) - }; - (summary, max_cap) -} -fn display_info() -> String { - let mut displays = Vec::new(); - if let Ok(entries) = fs::read_dir("/sys/class/drm") { - for entry in entries.flatten() { - let path = entry.path(); - if !path.join("status").is_file() { - continue; - } - let status = fs::read_to_string(path.join("status")).unwrap_or_default(); - if status.trim() != "connected" { - continue; - } - let name = entry.file_name().to_string_lossy().to_string(); - let modes = fs::read_to_string(path.join("modes")).unwrap_or_default(); - let mode = modes.lines().next().unwrap_or("resolution unknown"); - let size = fs::read(path.join("edid")) - .ok() - .and_then(|e| { - if e.len() >= 23 && e[21] > 0 && e[22] > 0 { - let diag = ((e[21] as f64).powi(2) + (e[22] as f64).powi(2)).sqrt() / 2.54; - Some(format!("{diag:.1} inch (EDID estimate)")) - } else { - None - } - }) - .unwrap_or_else(|| "size UNKNOWN".into()); - displays.push(format!("{name}: {mode}, {size}")); - } - } - if displays.is_empty() { - "UNKNOWN (no active DRM connector)".into() - } else { - displays.join("; ") - } -} -fn save(data: &Path, asset: &str, stem: &str, content: &str) -> Result<(), String> { - let folder = data.join("reports").join(asset).join("supplemental"); - fs::create_dir_all(&folder).map_err(|e| e.to_string())?; - let path = folder.join(stem); - fs::write(&path, content).map_err(|e| e.to_string())?; - println!("Saved to {}", path.display()); - Ok(()) -} -pub fn device_details(data: &Path) { - let asset = ask("Asset ID / Geraetenummer: "); - if !valid_asset(&asset) { - println!("Invalid asset ID"); - return; - } - let manufacturer = - read_trim("/sys/class/dmi/id/sys_vendor").unwrap_or_else(|| "UNKNOWN".into()); - let model = read_trim("/sys/class/dmi/id/product_name").unwrap_or_else(|| "UNKNOWN".into()); - let uuid = read_trim("/sys/class/dmi/id/product_uuid").unwrap_or_else(|| "UNKNOWN".into()); - let cpu = read_trim("/proc/cpuinfo") - .and_then(|s| { - s.lines().find_map(|line| { - line.strip_prefix("model name") - .and_then(|v| v.split_once(':').map(|(_, v)| v.trim().to_owned())) - }) - }) - .unwrap_or_else(|| "UNKNOWN".into()); - let (slots, max_ram) = memory_dmi(); - let display = display_info(); - let host = normalize_hostname(&model, &asset); - let connections = net_interfaces(); - println!("\nManufacturer: {manufacturer}\nModel: {model}\nCPU: {cpu}\nUUID: {uuid}\nRAM slots: {slots}\nMax RAM (BIOS estimate): {max_ram}\nDisplay: {display}\nProposed hostname: {host}"); - for (name, kind, carrier, mac) in &connections { - println!("Interface {name}: {kind}, carrier={carrier}, MAC={mac}"); - } - println!("Storage sizes and device type are collected by the main scanner; don't include the boot USB."); - let mut json = format!("{{\"asset_id\":{},\"manufacturer\":{},\"model\":{},\"cpu\":{},\"uuid\":{},\"ram_slots\":{},\"ram_max\":{},\"display\":{},\"proposed_hostname\":{},\"network\":[",escape_json(&asset),escape_json(&manufacturer),escape_json(&model),escape_json(&cpu),escape_json(&uuid),escape_json(&slots),escape_json(&max_ram),escape_json(&display),escape_json(&host)); - for (i, (name, kind, carrier, mac)) in connections.iter().enumerate() { - if i > 0 { - json.push(','); - } - json.push_str(&format!( - "{{\"interface\":{},\"type\":{},\"carrier\":{},\"mac\":{}}}", - escape_json(name), - escape_json(kind), - escape_json(carrier), - escape_json(mac) - )); - } - json.push_str("]}\n"); - if let Err(e) = save(data, &asset, "hardware-details.json", &json) { - eprintln!("Save failed: {e}"); - } -} -fn check_prompt(label: &str) -> &'static str { - println!("{label}"); - loop { - match ask("Result [p]ass / [f]ail / [u]ntested / [n]ot applicable: ") - .to_lowercase() - .as_str() - { - "p" => return "PASS", - "f" => return "FAIL", - "u" | "" => return "NOT_TESTED", - "n" => return "NOT_APPLICABLE", - _ => println!("Use p/f/u/n"), - } - } -} -pub fn guided_tests(data: &Path) { - let asset = ask("Asset ID: "); - if !valid_asset(&asset) { - println!("Invalid asset ID"); - return; - } - println!("\nNETWORK PRECHECK"); - if mobile_device() && ethernet_connected() { - println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test."); - let _ = ask("After unplugging LAN press Enter to recheck..."); - if ethernet_connected() { - println!("LAN link remains active: Wi-Fi connectivity cannot be confirmed until disconnected."); - } - } else { - println!("No active wired link detected. Wireless testing may proceed."); - } - let wifi = if mobile_device() && ethernet_connected() { - "NOT_TESTED" - } else { - println!( - "Wi-Fi interfaces: {:?}", - net_interfaces() - .iter() - .filter(|x| x.1 == "Wi-Fi") - .map(|x| &x.0) - .collect::>() - ); - println!("Connect using your network manager and verify actual traffic over Wi-Fi. Interface detection alone is not enough."); - check_prompt("Wi-Fi connection and data transfer verified?") - }; - println!("\nAUDIO: We'll attempt a stereo test sound if speaker-test exists. Confirm left/right audibly."); - if ask("Run short speaker test? [y/N] ").eq_ignore_ascii_case("y") { - let result = Command::new("speaker-test") - .args(["-c", "2", "-t", "wav", "-l", "1"]) - .status(); - println!( - "speaker-test outcome: {:?} (not proof that sound was audible)", - result - ); - } - let audio = check_prompt("Speakers: both channels audible, without distortion?"); - println!("\nMICROPHONE: make a short recording then play it back, confirming sound clarity."); - let mic = check_prompt("Microphone recorded and replayed clearly?"); - println!("\nCAMERA: look for /dev/video*, and preview live picture if a desktop is running."); - if let Ok(devices) = fs::read_dir("/dev") { - for dev in devices.flatten() { - let n = dev.file_name().to_string_lossy().to_string(); - if n.starts_with("video") { - println!("Found /dev/{n}"); - } - } - } - let webcam = check_prompt("Webcam picture inspected and working?"); - let mut json=format!("{{\"asset_id\":{},\"timestamp\":{},\"wifi\":{},\"audio\":{},\"microphone\":{},\"webcam\":{}}}\n",escape_json(&asset),SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),escape_json(wifi),escape_json(audio),escape_json(mic),escape_json(webcam)); - if let Err(e) = save(data, &asset, "guided-tests.json", &json) { - eprintln!("Save failed: {e}"); - } - json.clear(); -} -pub fn maintenance_guidance() { - println!("\nMAINTENANCE OF INSTALLED OPERATING SYSTEM"); - println!("The live USB does NOT automatically run updates on an internal disk. Only run maintenance after booting the installed system and confirming its Linux distribution."); - println!("For Debian/Ubuntu/Mint installed systems:"); - println!(" sudo apt update && sudo apt upgrade && sudo apt autoremove && sudo apt clean"); - println!("For Arch Linux installed systems:"); - println!(" sudo pacman -Syu"); - println!("Do not chroot or rename another machine's OS without selecting and validating its target disk. Proposed hostname is stored in hardware-details.json for controlled provisioning."); - let _ = ask("Press Enter to return to menu..."); -} -/// Read-only CPU stress plus allocated userspace memory checks. Not a full RAM test. -pub fn stress_test(data: &Path) { - let asset = ask("Asset ID: "); - if !valid_asset(&asset) { - println!("Invalid asset ID"); - return; - } - println!("\nThis uses stress-ng (120 seconds) and memtester on up to 50% of MemAvailable, capped at 8 GiB."); - println!("Do not use on a machine with unstable cooling. This cannot test all physical RAM or prove absence of faults."); - if !ask("Run stress tests now? Type YES: ").eq("YES") { - println!("Cancelled"); - return; - } - let cpu = Command::new("stress-ng") - .args(["--cpu", "0", "--timeout", "120s", "--metrics-brief"]) - .status(); - println!("CPU stress result: {cpu:?}"); - let mem_kib = read_trim("/proc/meminfo") - .and_then(|t| { - t.lines() - .find(|l| l.starts_with("MemAvailable:")) - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|n| n.parse::().ok()) - }) - .unwrap_or(0); - let mem_mib = std::cmp::min(mem_kib / 2048, 8192); - let ram = if mem_mib >= 128 { - Command::new("memtester") - .args([&format!("{mem_mib}M"), "1"]) - .status() - } else { - println!("Not enough free RAM for safe test"); - return; - }; - println!("RAM stress result: {ram:?}"); - let status = |r: &io::Result| match r { - Ok(s) if s.success() => "PASS", - Ok(_) => "FAIL", - Err(_) => "UNAVAILABLE", - }; - let report=format!("{{\"asset_id\":{},\"cpu_stress\":{},\"ram_stress\":{},\"ram_test_mib\":{},\"coverage\":\"PARTIAL_USERSPACE\"}}\n",escape_json(&asset),escape_json(status(&cpu)),escape_json(status(&ram)),mem_mib); - if let Err(e) = save(data, &asset, "stress-test.json", &report) { - eprintln!("Save failed: {e}"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_asset_ids() { - assert!(valid_asset("PC-0042")); - assert!(!valid_asset("../evil")); - } - - #[test] - fn hostnames() { - assert_eq!( - normalize_hostname("EliteBook 745 G6", "PC-0001"), - "elitebook-745-g6-pc-0001" - ); - } - - #[test] - fn escape() { - assert_eq!(escape_json("a\"b"), "\"a\\\"b\""); - } -} diff --git a/controller/src/main.rs b/controller/src/main.rs deleted file mode 100644 index 0e59ddb..0000000 --- a/controller/src/main.rs +++ /dev/null @@ -1,312 +0,0 @@ -mod extended; -use std::{ - env, fs, - io::{self, Write}, - path::{Path, PathBuf}, - process::Command, - time::{SystemTime, UNIX_EPOCH}, -}; -const DATA: &str = "/mnt/intake-data"; -fn prompt(q: &str) -> String { - print!("{}", q); - let _ = io::stdout().flush(); - let mut s = String::new(); - let _ = io::stdin().read_line(&mut s); - s.trim().to_string() -} -fn run(program: &str, args: &[&str]) -> Result<(), String> { - let st = Command::new(program) - .args(args) - .status() - .map_err(|e| format!("{}: {}", program, e))?; - if st.success() { - Ok(()) - } else { - Err(format!("{} exited with {}", program, st)) - } -} -fn capture(program: &str, args: &[&str]) -> Result { - let o = Command::new(program) - .args(args) - .output() - .map_err(|e| e.to_string())?; - if !o.status.success() { - return Err(String::from_utf8_lossy(&o.stderr).to_string()); - } - Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()) -} -fn sqlquote(s: &str) -> String { - format!("'{}'", s.replace('\'', "''")) -} -fn db_path(data: &Path) -> PathBuf { - data.join("database/intake.sqlite3") -} -fn sql(data: &Path, statement: &str) -> Result { - capture( - "sqlite3", - &[db_path(data).to_str().ok_or("Invalid DB path")?, statement], - ) -} -fn init(data: &Path) -> Result<(), String> { - for part in ["database", "reports", "checklists", "config"] { - fs::create_dir_all(data.join(part)).map_err(|e| e.to_string())?; - } - let probe = data.join(".write-test"); - fs::write(&probe, b"ok").map_err(|e| format!("Persistent partition not writable: {}", e))?; - let _ = fs::remove_file(probe); - sql(data,"PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id TEXT NOT NULL, timestamp INTEGER NOT NULL, report_path TEXT NOT NULL UNIQUE, inspection_path TEXT, inspection_status TEXT NOT NULL DEFAULT 'NOT_TESTED', sync_status TEXT NOT NULL DEFAULT 'PENDING', synced_inventory_id TEXT);")?; - Ok(()) -} -fn valid_id(s: &str) -> bool { - !s.is_empty() - && s.len() <= 64 - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') -} -fn latest(data: &Path, id: &str) -> Option { - let root = data.join("reports").join(id); - let mut matches = Vec::new(); - for d in fs::read_dir(root).ok()?.flatten() { - let p = d.path().join("report.json"); - if p.is_file() { - let mt = p.metadata().ok()?.modified().ok()?; - matches.push((mt, p)); - } - } - matches.sort_by_key(|(m, _)| *m); - matches.pop().map(|(_, p)| p) -} -fn get_time() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} -fn scan(data: &Path, quick: bool) { - let id = prompt("Asset ID: "); - if !valid_id(&id) { - println!("Invalid ID (letters, digits, underscore and hyphen only)"); - return; - } - let out = data.join("reports"); - let outstr = out.to_string_lossy().to_string(); - let mut cmd = Command::new("/usr/local/bin/pc-intake"); - cmd.args(["--asset-id", &id, "--output", &outstr]); - if quick { - cmd.arg("--quick"); - } else { - cmd.arg("--compile"); - } - let checklist = data.join("checklists").join(format!("{}.txt", id)); - if checklist.exists() { - cmd.args(["--checklist", &checklist.to_string_lossy()]); - } - println!("Running {} scan...", if quick { "quick" } else { "full" }); - match cmd.status() { - Ok(status) if status.success() => { - if let Some(report) = latest(data, &id) { - let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy()))); - match result { - Ok(_) => { - println!("Saved: {}", report.display()); - println!("Inventory sync: PENDING (not uploaded)"); - } - Err(e) => println!("Database write failed: {}. JSON report remains saved.", e), - } - } else { - println!("Scanner exited successfully, but no report found"); - } - } - Ok(status) => println!("Scanner failed: {}", status), - Err(e) => println!("Could not launch scanner: {}", e), - } -} -const CHECKS: &[(&str, &str)] = &[ - ( - "display", - "Show colors/brightness and inspect for dead pixels", - ), - ( - "keyboard", - "Check every key, including modifiers and function keys", - ), - ( - "touchpad", - "Verify movement, taps, buttons and scrolling; N/A for desktop", - ), - ( - "wifi", - "Connect to a network and open a local or remote test service", - ), - ("audio", "Play left/right sound and check for distortion"), - ("microphone", "Record voice and play it back"), - ( - "webcam", - "Preview image and verify focus and quality; N/A if absent", - ), - ( - "usb_ports", - "Insert a known-good test device in each physical port", - ), - ( - "charging", - "Verify AC adapter, charging status, battery runtime; N/A for desktop", - ), - ("thermals", "Verify fans, heat and sustained load"), - ( - "stability", - "Check for crashes, unexpected resets or kernel errors", - ), -]; -fn technician(data: &Path) { - let id = prompt("Asset ID for inspection: "); - if !valid_id(&id) { - println!("Invalid ID"); - return; - } - let mut rows = Vec::new(); - for (key, desc) in CHECKS { - println!("\n{}: {}", key, desc); - if *key == "audio" { - println!("Optional speaker test (if installed): speaker-test -c 2 -t wav"); - } - if *key == "webcam" { - println!( - "Optional camera detection: ls /dev/video*; preview requires graphical session" - ); - } - loop { - let ans = prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase(); - if let Some(st) = match ans.as_str() { - "p" => Some("PASS"), - "f" => Some("FAIL"), - "u" | "" => Some("NOT_TESTED"), - "n" => Some("NOT_APPLICABLE"), - _ => None, - } { - rows.push((key.to_string(), st.to_string())); - break; - } - println!("Enter p, f, u or n"); - } - } - let target = data.join("checklists").join(format!("{}.txt", id)); - let mut raw = String::new(); - for (k, v) in &rows { - raw.push_str(&format!("{}={}\n", k, v)); - } - if let Err(e) = fs::write(&target, raw) { - println!("Could not save checklist: {}", e); - return; - } - let fail = rows.iter().any(|(_, s)| s == "FAIL"); - let unknown = rows.iter().any(|(_, s)| s == "NOT_TESTED"); - let verdict = if fail { - "FAIL" - } else if unknown { - "INCOMPLETE" - } else { - "COMPLETED" - }; - let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id))); - println!( - "Inspection {}. Saved {}; re-scan to embed checklist in hardware report.", - verdict, - target.display() - ); -} -fn recent(data: &Path) { - let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;"; - match sql(data, query) { - Ok(rows) if rows.is_empty() => println!("No scans yet."), - Ok(rows) => println!("{}", rows), - Err(err) => println!("Database error: {}", err), - } -} -fn help() { - println!("PC Intake AIO v1.7.0 — offline-first technician controller"); - println!("Usage: intake-controller [--data PATH] [--self-check]"); -} -fn main() { - let args: Vec = env::args().collect(); - if args.iter().any(|x| x == "--help") { - help(); - return; - } - if args.iter().any(|x| x == "--self-check") { - for tool in [ - "sqlite3", - "smartctl", - "ffmpeg", - "mpv", - "glmark2", - "stress-ng", - "memtester", - "speaker-test", - ] { - println!( - "{}: {}", - tool, - if Command::new("sh") - .args(["-c", &format!("command -v {} >/dev/null", tool)]) - .status() - .map(|s| s.success()) - .unwrap_or(false) - { - "FOUND" - } else { - "MISSING" - } - ); - } - return; - } - let data = if let Some(p) = args.iter().position(|x| x == "--data") { - PathBuf::from(args.get(p + 1).unwrap_or(&DATA.to_owned())) - } else { - PathBuf::from(DATA) - }; - if let Err(e) = init(&data) { - eprintln!("FATAL: {} — refusing to scan without persistent storage", e); - std::process::exit(1) - } - loop { - println!("\n==== PC INTAKE AIO v1.7.0 ====\nStorage: {}\n1 Full scan and local queue\n2 Quick inventory\n3 Guided technician checklist\n4 Recent scans and sync states\n5 Check diagnostic tools\n6 Safely power off\n7 Hardware details (UUID / MAC / RAM slots / screen)\n8 Guided audio / webcam / Wi-Fi test\n9 Installed-system maintenance instructions\n10 CPU and RAM stress test (opt-in)\n",data.display()); - match prompt("Select: ").as_str() { - "1" => scan(&data, false), - "2" => scan(&data, true), - "3" => technician(&data), - "4" => recent(&data), - "5" => { - let _ = Command::new( - env::current_exe().unwrap_or_else(|_| PathBuf::from("intake-controller")), - ) - .arg("--self-check") - .status(); - } - "6" => { - let _ = run("sync", &[]); - let _ = run("systemctl", &["poweroff"]); - break; - } - "7" => extended::device_details(&data), - "8" => extended::guided_tests(&data), - "9" => extended::maintenance_guidance(), - "10" => extended::stress_test(&data), - _ => println!("Unknown selection"), - } - } -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn id_validation() { - assert!(valid_id("PC-0123")); - assert!(!valid_id("../../evil")); - } - #[test] - fn sql_escape() { - assert_eq!(sqlquote("O'Reilly"), "'O''Reilly'"); - } -} diff --git a/gui/src/diagnostics.rs b/gui/src/diagnostics.rs index a68a94c..4fce3f3 100644 --- a/gui/src/diagnostics.rs +++ b/gui/src/diagnostics.rs @@ -99,7 +99,7 @@ fn temps() -> Vec<(String, f64)> { } fn thermal(settings: &crate::workflow::Settings) -> (bool, String) { if !available("stress-ng") { - return (false, "UNAVAILABLE: stress-ng fehlt im Live-System".into()); + return (false, "UNAVAILABLE: stress-ng ist nicht installiert".into()); } let baseline = temps(); let start = Instant::now(); @@ -186,7 +186,7 @@ fn stability(settings: &crate::workflow::Settings) -> (bool, String) { .and_then(|v| v.parse::().ok()) }) .unwrap_or(0); - // Conservatively cap test allocation; never allocate all free RAM in a live USB GUI. + // Conservatively cap test allocation; never allocate all free RAM in the GUI. if mem_kib < 256 * 1024 { return ( false, diff --git a/gui/src/main.rs b/gui/src/main.rs index 23c6a5b..96af127 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -90,7 +90,7 @@ fn scanner_path() -> PathBuf { if let Some(p) = std::env::var_os("CSR_SCANNER") { return PathBuf::from(p); } - // Prefer the scanner beside this build, then the installed live-system scanner. + // Prefer the scanner bundled beside this executable, then a system installation. if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { let sibling = parent.join("pc-intake"); @@ -782,7 +782,7 @@ impl App { } ui.add_space(16.0); if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending { - ui.colored_label(ORANGE,tr(en,"Noch nicht vollständig synchronisiert. Ergebnisse bleiben auf diesem USB gespeichert.","Not fully synced yet. Results remain saved on this USB.")); + ui.colored_label(ORANGE,tr(en,"Noch nicht vollständig synchronisiert. Ergebnisse bleiben lokal gespeichert.","Not fully synced yet. Results remain saved locally.")); if ui.add_enabled(!self.busy(),egui::Button::new(tr(en,"Offline speichern und herunterfahren","Save offline and shut down"))).clicked() && self.persist() { let data=self.data.clone(); let inspection=self.inspection.as_ref().filter(|s|!s.sync_phase.is_empty()).map(|s|data.join("inspections").join(format!("{}.json",s.asset))); @@ -821,8 +821,8 @@ impl App { ORANGE, tr( en, - "Keine gültige Hardware-Kennung. Aktuellen USB neu starten.", - "No valid hardware identity. Reboot the updated USB.", + "Keine gültige Hardware-Kennung. Anwendung neu starten und erneut erfassen.", + "No valid hardware identity. Restart the application and scan again.", ), ); } @@ -1488,8 +1488,8 @@ impl App { }); ui.small(tr( en, - "Die Tastaturbelegung wird beim nächsten Start des USB-Systems angewendet.", - "The keyboard layout is applied on the next USB-system boot.", + "Die Tastaturbelegung wird beim nächsten Start der Anwendung angewendet.", + "The keyboard layout is applied on the next application start.", )); ui.label(tr(en, "WLAN-Testserver", "Wi-Fi test server")); ui.text_edit_singleline(&mut self.settings_draft.test_url); diff --git a/gui/src/wifi.rs b/gui/src/wifi.rs index 30d794b..d8a4c0a 100644 --- a/gui/src/wifi.rs +++ b/gui/src/wifi.rs @@ -549,7 +549,7 @@ impl Wifi { ui.small(crate::tr(en, "Leer lassen, wenn das Passwort bereits in NetworkManager gespeichert ist.", "Leave blank to use a password already saved in NetworkManager.")); } else { ui.label(crate::tr(en, "Offenes Netzwerk – kein Passwort nötig.", "Open network — no password needed.")); } if self.saved_path.is_some() { - ui.checkbox(&mut self.remember, crate::tr(en, "Werkstatt-WLAN auf USB merken", "Remember workshop Wi-Fi on USB")); + ui.checkbox(&mut self.remember, crate::tr(en, "Werkstatt-WLAN speichern", "Remember workshop Wi-Fi")); } if ui.button(crate::tr(en, "Mit WLAN verbinden", "Connect to Wi-Fi")).clicked() { let password = self.password.clone(); self.show_password = false; @@ -558,7 +558,7 @@ impl Wifi { thread::spawn(move || { let result = connect(&n, &password).and_then(|_| { if let Some(path) = save { - if n.secured() && !n.security.contains("OWE") && password.is_empty() { return Err("Connected, but enter password once to save it on USB / Verbunden; Passwort zum Speichern einmal eingeben.".into()); } + if n.secured() && !n.security.contains("OWE") && password.is_empty() { return Err("Connected, but enter password once to save it / Verbunden; Passwort zum Speichern einmal eingeben.".into()); } save_network(&path, &n.ssid, &password)?; } Ok(()) diff --git a/live/autologin.conf b/live/autologin.conf deleted file mode 100644 index 4e09cd6..0000000 --- a/live/autologin.conf +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -Requires=intake-prepare.service -After=intake-prepare.service - -[Service] -ExecStart= -ExecStart=-/usr/bin/agetty --autologin intake --noclear %I $TERM diff --git a/live/intake-login.sh b/live/intake-login.sh deleted file mode 100644 index 31ac085..0000000 --- a/live/intake-login.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -# Sourced on autologin on tty1; no system service owns the compositor. -[[ "$(tty)" == /dev/tty1 ]] || return 0 -[[ -z "${WAYLAND_DISPLAY:-}" ]] || return 0 -export WLR_RENDERER_ALLOW_SOFTWARE=1 -export XDG_SESSION_TYPE=wayland -keyboard_layout=$(python3 - /mnt/intake-data/config/workshop.json <<'PY' -import json -import pathlib -import sys - -try: - value = json.loads(pathlib.Path(sys.argv[1]).read_text()).get('keyboard_layout', 'de') -except (OSError, ValueError): - value = 'de' -print(value if value in ('de', 'us') else 'de') -PY -) -export XKB_DEFAULT_LAYOUT="$keyboard_layout" -if [[ ! -w /mnt/intake-data ]]; then - printf '\nCS-R Intake: data partition not writable; no scan can be saved.\n' >&2 - printf 'Press Enter for diagnostic console...' - read -r _ - return 0 -fi -if command -v cage >/dev/null && [[ -x /usr/local/bin/intake-gui ]]; then - cage -- /usr/local/bin/intake-gui --data /mnt/intake-data - printf '\nGUI exited. You are at the diagnostic terminal (tty1).\n' >&2 -else - printf '\nCage or GUI missing. Attempting controller fallback.\n' >&2 - /usr/local/bin/intake-controller --data /mnt/intake-data || true -fi diff --git a/live/intake-prepare.service b/live/intake-prepare.service deleted file mode 100644 index 2fb12d7..0000000 --- a/live/intake-prepare.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Prepare CS-R Intake data and kiosk user -After=local-fs.target systemd-udev-settle.service -Before=getty@tty1.service -Wants=systemd-udev-settle.service - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/intake-prepare -RemainAfterExit=yes -TimeoutStartSec=45 - -[Install] -WantedBy=multi-user.target diff --git a/live/intake-prepare.sh b/live/intake-prepare.sh deleted file mode 100644 index c8b63bf..0000000 --- a/live/intake-prepare.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail -# Run as root once per boot. NEVER write to internal devices. -DATA=/mnt/intake-data -if ! id intake &>/dev/null; then - useradd --create-home --home-dir /home/intake --shell /bin/bash --user-group intake -fi -install -d -o intake -g intake -m 0700 /home/intake -# Read protected DMI fields once as root; provide a boot-local, read-only snapshot. -install -d -o root -g intake -m 0750 /run/csr-intake -for field in product_uuid product_serial; do - if [[ -r /sys/class/dmi/id/$field ]]; then - install -o root -g intake -m 0440 "/sys/class/dmi/id/$field" "/run/csr-intake/$field" - fi -done -if [[ -r /sys/firmware/dmi/tables/DMI ]]; then - install -o root -g intake -m 0440 /sys/firmware/dmi/tables/DMI /run/csr-intake/DMI -fi -install -d -m 0755 "$DATA" -part=$(blkid -L INTAKE_DATA || true) -if [[ -z "$part" ]]; then - echo 'CS-R ERROR: INTAKE_DATA not found; scans must not run.' >&2 -else - if ! mountpoint -q "$DATA"; then - mount -o rw,nosuid,nodev "$part" "$DATA" - fi - for d in config database reports checklists inspections final-reports diagnostics; do - mkdir -p "$DATA/$d" - chown -R intake:intake "$DATA/$d" - done - chown intake:intake "$DATA" - [[ ! -f "$DATA/active-inspection.txt" ]] || chown intake:intake "$DATA/active-inspection.txt" - chmod 0750 "$DATA" - # Only provision when absent; the user can rotate credentials on persistent media. - if [[ -d /etc/csr-intake ]]; then - for f in inventory.json inventory-api-key workshop.json; do - if [[ -f /etc/csr-intake/$f && ! -e "$DATA/config/$f" ]]; then - install -o intake -g intake -m 0600 "/etc/csr-intake/$f" "$DATA/config/$f" - fi - done - fi - [[ ! -f "$DATA/config/inventory-api-key" ]] || chmod 0600 "$DATA/config/inventory-api-key" - # Workshop TLS certificates stay on persistent media, alongside inventory config. - # Preserve certificate and hostname verification for Python and curl. - if [[ -f "$DATA/config/inventory-server.crt" ]]; then - install -D -m 0644 "$DATA/config/inventory-server.crt" /etc/ca-certificates/trust-source/anchors/csr-inventory.crt - update-ca-trust - fi -fi -install -o intake -g intake -m 0644 /usr/local/bin/intake-login /home/intake/.bash_profile -# .bash_profile sourced by the user's real console login. diff --git a/scripts/build-iso.sh b/scripts/build-iso.sh deleted file mode 100644 index fb29ddc..0000000 --- a/scripts/build-iso.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Build in a new directory so old profiles and work directories are never reused. -set -Eeuo pipefail -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -DEST=${1:?Usage: bash scripts/build-iso.sh /absolute/path/to/new-build-directory} -[[ ! -e "$DEST" ]] || { echo 'Destination must not already exist.' >&2; exit 2; } -mkdir -p "$DEST" -DEST=$(cd "$DEST" && pwd) -bash "$ROOT/scripts/create-profile.sh" "$DEST/profile" -python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST/profile" -sudo mkarchiso -v -w "$DEST/work" -o "$DEST/out" "$DEST/profile" -python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST/profile" -shopt -s nullglob -isos=("$DEST"/out/*.iso) -[[ ${#isos[@]} == 1 ]] || { echo 'Expected exactly one ISO.' >&2; exit 1; } -bsdtar -tf "${isos[0]}" > "$DEST/iso-files.txt" -grep -qx 'arch/boot/x86_64/vmlinuz-linux' "$DEST/iso-files.txt" -grep -qx 'arch/boot/x86_64/initramfs-linux.img' "$DEST/iso-files.txt" -[[ $(stat -c %s "${isos[0]}") -lt 4294967295 ]] || { echo 'ISO exceeds FAT32 file size limit.' >&2; exit 1; } -sha256sum "${isos[0]}" > "$DEST/out/SHA256SUMS" -cp "$DEST/profile/airootfs/etc/csr-intake/release.json" "$DEST/out/release.json" -echo "ISO validated in $DEST/out. Physical UEFI/BIOS boot and hardware tests still required." diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 442916b..c51c732 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -3,7 +3,7 @@ set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) export CSR_BUILD_ID CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id) -for component in scanner controller gui; do +for component in scanner gui; do cargo fmt --check --manifest-path "$ROOT/$component/Cargo.toml" cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml" @@ -12,4 +12,5 @@ done python3 -m unittest discover -s "$ROOT/tests" -p 'test_*.py' bash "$ROOT/tests/smoke.sh" python3 "$ROOT/scripts/release-manifest.py" --create "$ROOT/build/release.json" -echo "Validated release: $CSR_BUILD_ID" +bash "$ROOT/scripts/build-standalone.sh" +echo "Validated standalone release: $CSR_BUILD_ID" diff --git a/scripts/build-standalone.sh b/scripts/build-standalone.sh index e6e461f..92d987c 100755 --- a/scripts/build-standalone.sh +++ b/scripts/build-standalone.sh @@ -32,6 +32,7 @@ mkdir -p "$TMP/app" "$(dirname "$OUT")" install -m755 "$GUI" "$TMP/app/intake-gui" install -m755 "$SCANNER" "$TMP/app/pc-intake" install -m644 "$MANIFEST" "$TMP/app/release.json" +python3 "$ROOT/scripts/release-manifest.py" --verify-release "$TMP/app" ( cd "$TMP/app" sha256sum intake-gui pc-intake release.json > SHA256SUMS @@ -85,10 +86,37 @@ esac extract_bundle data_dir=\${XDG_DATA_HOME:-\${HOME:-\$PWD}/.local/share}/csr-intake +expect_data=0 has_data=0 for argument in "\$@"; do - [ "\$argument" = '--data' ] && has_data=1 + if [ "\$expect_data" -eq 1 ]; then + data_dir=\$argument + expect_data=0 + elif [ "\$argument" = '--data' ]; then + has_data=1 + expect_data=1 + fi done +[ "\$expect_data" -eq 0 ] || { + echo 'Missing directory after --data.' >&2 + exit 2 +} + +workshop_config=\$data_dir/config/workshop.json +if [ -f "\$workshop_config" ]; then + keyboard_layout=\$(python3 - "\$workshop_config" <<'PY' 2>/dev/null || true +import json +import sys +layout = json.load(open(sys.argv[1], encoding='utf-8')).get('keyboard_layout', '') +if layout in ('de', 'us'): + print(layout) +PY + ) + if [ -n "\$keyboard_layout" ]; then + export XKB_DEFAULT_LAYOUT=\$keyboard_layout + fi +fi + export CSR_SCANNER="\$app_dir/pc-intake" if [ "\$has_data" -eq 1 ]; then exec "\$app_dir/intake-gui" "\$@" diff --git a/scripts/create-profile.sh b/scripts/create-profile.sh deleted file mode 100755 index 05f344a..0000000 --- a/scripts/create-profile.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail -ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -DEST=${1:-$ROOT/build/profile} -[[ -d /usr/share/archiso/configs/releng ]] || { echo 'Install archiso first.' >&2; exit 1; } -[[ ! -e $DEST ]] || { echo "Profile already exists: $DEST" >&2; exit 1; } -bash "$ROOT/scripts/build-release.sh" -mkdir -p "$(dirname "$DEST")" -cp -a /usr/share/archiso/configs/releng "$DEST" -for pkg in cage seatd xorg-xwayland mesa vulkan-radeon vulkan-intel libglvnd fontconfig ttf-dejavu networkmanager curl ca-certificates sqlite smartmontools ffmpeg gcc glmark2 mpv pciutils util-linux e2fsprogs stress-ng memtester alsa-utils v4l-utils dmidecode python usbutils xdg-utils firefox; do - grep -qxF "$pkg" "$DEST/packages.x86_64" || echo "$pkg" >> "$DEST/packages.x86_64" -done -mkdir -p "$DEST/airootfs/usr/local/bin" "$DEST/airootfs/usr/local/lib/pc-intake" "$DEST/airootfs/etc/systemd/system/multi-user.target.wants" -install -m755 "$ROOT/scanner/target/release/pc-intake" "$DEST/airootfs/usr/local/bin/pc-intake" -install -m755 "$ROOT/controller/target/release/intake-controller" "$DEST/airootfs/usr/local/bin/intake-controller" -install -m755 "$ROOT/gui/target/release/intake-gui" "$DEST/airootfs/usr/local/bin/intake-gui" -install -m755 "$ROOT/scripts/intake-session.sh" "$DEST/airootfs/usr/local/bin/intake-session" -# The GUI must run in a real logind session, not a root system service attached to a TTY. -install -m755 "$ROOT/live/intake-prepare.sh" "$DEST/airootfs/usr/local/bin/intake-prepare" -install -m755 "$ROOT/live/intake-login.sh" "$DEST/airootfs/usr/local/bin/intake-login" -install -m644 "$ROOT/live/intake-prepare.service" "$DEST/airootfs/etc/systemd/system/intake-prepare.service" -mkdir -p "$DEST/airootfs/etc/systemd/system/getty@tty1.service.d" -install -m644 "$ROOT/live/autologin.conf" "$DEST/airootfs/etc/systemd/system/getty@tty1.service.d/autologin.conf" -ln -s ../intake-prepare.service "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/intake-prepare.service" -rm -f "$DEST/airootfs/etc/systemd/system/getty@tty1.service" -rm -f "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/pc-intake.service" -rm -f "$DEST/airootfs/etc/systemd/system/pc-intake.service" -# Archiso can override copied file modes. Record explicit permissions too. -cat >> "$DEST/profiledef.sh" <<'EOF' -file_permissions+=( - ["/usr/local/bin/intake-session"]="0:0:755" - ["/usr/local/bin/pc-intake"]="0:0:755" - ["/usr/local/bin/intake-controller"]="0:0:755" - ["/usr/local/bin/intake-gui"]="0:0:755" - ["/usr/local/bin/intake-prepare"]="0:0:755" - ["/usr/local/bin/intake-login"]="0:0:755" -) -EOF -for path in usr/local/bin/intake-session usr/local/bin/pc-intake usr/local/bin/intake-controller usr/local/bin/intake-gui usr/local/bin/intake-prepare usr/local/bin/intake-login; do - test -x "$DEST/airootfs/$path" || { echo "FATAL: missing executable $path" >&2; exit 1; } -done -# Optional operator-approved provisioning at build time; ISO secrets are extractable. -if [[ ${CSR_EMBED_INVENTORY_CONFIG:-0} == 1 ]]; then - : "${CSR_PRIVATE_CONFIG_DIR:?Set CSR_PRIVATE_CONFIG_DIR to local private folder}" - test -f "$CSR_PRIVATE_CONFIG_DIR/inventory.json" - test -f "$CSR_PRIVATE_CONFIG_DIR/inventory-api-key" - mkdir -p "$DEST/airootfs/etc/csr-intake" - install -m600 "$CSR_PRIVATE_CONFIG_DIR/inventory.json" "$DEST/airootfs/etc/csr-intake/inventory.json" - install -m600 "$CSR_PRIVATE_CONFIG_DIR/inventory-api-key" "$DEST/airootfs/etc/csr-intake/inventory-api-key" - cat >> "$DEST/profiledef.sh" <<'PERMS' -file_permissions+=( ["/etc/csr-intake/inventory.json"]="0:0:600" ["/etc/csr-intake/inventory-api-key"]="0:0:600" ) -PERMS -fi -echo "Profile created at $DEST" - -# Keep NetworkManager running in the live image for LAN/WLAN diagnostics. -mkdir -p "$DEST/airootfs/etc/systemd/system/multi-user.target.wants" -ln -sfn /usr/lib/systemd/system/NetworkManager.service "$DEST/airootfs/etc/systemd/system/multi-user.target.wants/NetworkManager.service" - -mkdir -p "$DEST/airootfs/etc/csr-intake" -install -m644 "$ROOT/build/release.json" "$DEST/airootfs/etc/csr-intake/release.json" -python3 "$ROOT/scripts/release-manifest.py" --verify-profile "$DEST" diff --git a/scripts/install-usb.sh b/scripts/install-usb.sh deleted file mode 100755 index 5abd395..0000000 --- a/scripts/install-usb.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic. -set -Eeuo pipefail -DEVICE=${1:-} -ISO=${2:-} -if [[ ! "$DEVICE" =~ ^/dev/[a-zA-Z0-9/_-]+$ || ! -f "$ISO" ]]; then - echo "Usage: sudo bash scripts/install-usb.sh /dev/sdb /absolute/path/to/pc-intake.iso" >&2; exit 2 -fi -[[ $EUID -eq 0 ]] || { echo 'Run as root.' >&2; exit 2; } -for cmd in lsblk sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs sync bsdtar blockdev partprobe udevadm; do - command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 2; } -done -DEVICE=$(readlink -f "$DEVICE") -[[ -b $DEVICE && $(lsblk -dn -o TYPE "$DEVICE") == disk ]] || { echo 'Target must be an entire block disk.' >&2; exit 2; } -[[ $(lsblk -dn -o TRAN "$DEVICE" | tr -d ' ') == usb ]] || { echo 'Refusing a device that is not identified as USB transport.' >&2; exit 2; } -root_source=$(findmnt -n -o SOURCE / || true) -root_disk=$(lsblk -no PKNAME "$root_source" 2>/dev/null | head -1 || true) -[[ -z "$root_disk" || "/dev/$root_disk" != "$DEVICE" ]] || { echo 'Refusing root system disk.' >&2; exit 2; } -if lsblk -nr -o MOUNTPOINT "$DEVICE" | grep -q '[^[:space:]]'; then - echo 'USB contains mounted partitions; unmount them before continuing.' >&2; lsblk "$DEVICE"; exit 2 -fi -size_bytes=$(blockdev --getsize64 "$DEVICE") -((size_bytes >= 12*1024*1024*1024)) || { echo 'USB must be at least 12 GiB.' >&2; exit 2; } -iso_bytes=$(stat -c %s "$ISO") -((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&2; exit 2; } -ISO_LIST=$(mktemp) -TMP= -cleanup(){ - if [[ -n $TMP ]]; then - umount "$TMP/boot" 2>/dev/null || true - umount "$TMP/data" 2>/dev/null || true - rmdir "$TMP/boot" "$TMP/data" "$TMP" 2>/dev/null || true - fi - rm -f "$ISO_LIST" -} -trap cleanup EXIT -bsdtar -tf "$ISO" > "$ISO_LIST" 2>/dev/null || { - echo 'ISO cannot be read as an archive.' >&2 - exit 2 -} -grep -qx 'arch/boot/x86_64/vmlinuz-linux' "$ISO_LIST" || { - echo 'ISO layout does not match GRUB config (kernel missing).' >&2 - exit 2 -} -grep -qx 'arch/boot/x86_64/initramfs-linux.img' "$ISO_LIST" || { - echo 'ISO layout does not match GRUB config (initramfs missing).' >&2 - exit 2 -} -model=$(lsblk -dn -o MODEL "$DEVICE" | xargs) -serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs) -echo '===== DESTRUCTIVE USB INSTALL =====' -echo "Device: $DEVICE Model: $model Serial: $serial" -echo "Capacity: $(lsblk -dn -o SIZE "$DEVICE") ISO: $ISO" -echo 'ALL PARTITIONS AND DATA ON THIS DEVICE WILL BE ERASED.' -read -rp "Type exactly ERASE $DEVICE to continue: " answer -[[ "$answer" == "ERASE $DEVICE" ]] || { echo 'Cancelled. Nothing erased.'; exit 1; } -# GPT: p1 BIOS boot (2MiB), p2 ESP FAT32 (7GiB), p3 ext4 remainder. -sgdisk --zap-all "$DEVICE" -wipefs -a "$DEVICE" >/dev/null -sgdisk -n 1:2048:+2M -t 1:ef02 -c 1:BIOS_GRUB "$DEVICE" -sgdisk -n 2:0:+7G -t 2:ef00 -c 2:INTAKE_BOOT "$DEVICE" -sgdisk -n 3:0:0 -t 3:8300 -c 3:INTAKE_DATA "$DEVICE" -if [[ "$DEVICE" =~ [0-9]$ ]]; then p2="${DEVICE}p2"; p3="${DEVICE}p3"; else p2="${DEVICE}2"; p3="${DEVICE}3"; fi -for i in {1..10}; do - blockdev --rereadpt "$DEVICE" 2>/dev/null || true - partprobe "$DEVICE" 2>/dev/null || true - udevadm settle - p2_size=$(blockdev --getsize64 "$p2" 2>/dev/null || echo 0) - p3_size=$(blockdev --getsize64 "$p3" 2>/dev/null || echo 0) - ((p2_size >= 7*1024*1024*1024 && p3_size >= 4*1024*1024*1024)) && break - sleep 1 -done -((p2_size >= 7*1024*1024*1024 && p3_size >= 4*1024*1024*1024)) || { - echo 'Kernel still reports stale partition sizes; reconnect the USB and retry.' >&2 - exit 1 -} -mkfs.vfat -F32 -n INTAKE_BOOT "$p2" -mkfs.ext4 -F -L INTAKE_DATA "$p3" -TMP=$(mktemp -d) -mkdir -p "$TMP/boot" "$TMP/data" -mount "$p2" "$TMP/boot"; mount "$p3" "$TMP/data" -mkdir -p "$TMP/boot/boot/grub" "$TMP/data/reports" "$TMP/data/database" "$TMP/data/checklists" "$TMP/data/reference" -if ! cp "$ISO" "$TMP/boot/pc-intake.iso" || ! sync -f "$TMP/boot"; then - current_size=$(blockdev --getsize64 "$DEVICE" 2>/dev/null || echo 0) - if ((current_size == 0)); then - echo 'USB medium disappeared while writing. Replace the stick or use another USB port.' >&2 - else - echo 'USB write failed. Check the stick and the kernel log before retrying.' >&2 - fi - exit 1 -fi -cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG' -set timeout=5 -set default=0 -menuentry 'PC Intake Live USB (offline technician mode)' { - search --no-floppy --label INTAKE_BOOT --set=root - set isofile='/pc-intake.iso' - loopback loop $isofile - linux (loop)/arch/boot/x86_64/vmlinuz-linux img_dev=/dev/disk/by-label/INTAKE_BOOT img_loop=$isofile archisobasedir=arch loglevel=3 - initrd (loop)/arch/boot/x86_64/initramfs-linux.img -} -menuentry 'Reboot' { reboot } -menuentry 'Power off' { halt } -GRUBCFG -grub-install --target=x86_64-efi --efi-directory="$TMP/boot" --boot-directory="$TMP/boot/boot" --removable --no-nvram --recheck -grub-install --target=i386-pc --boot-directory="$TMP/boot/boot" --recheck "$DEVICE" -sync -echo "USB is prepared. Results will be saved to $p3 (INTAKE_DATA)." -echo 'Unmount complete; boot a test PC with UEFI or legacy BIOS support.' diff --git a/scripts/intake-session.sh b/scripts/intake-session.sh deleted file mode 100755 index b08bd48..0000000 --- a/scripts/intake-session.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail -DATA=/mnt/intake-data -mkdir -p "$DATA" -if ! mountpoint -q "$DATA"; then - disk=$(blkid -L INTAKE_DATA || true) - if [[ -z "$disk" ]]; then echo 'FATAL: INTAKE_DATA partition missing. No scan will run.'; exec bash; fi - mount -o rw,nosuid,nodev "$disk" "$DATA" || { echo 'FATAL: cannot mount persistent storage'; exec bash; } -fi -# First boot: provision Inventory config to the persistent data partition. -mkdir -p "$DATA/config" -if [[ -d /etc/csr-intake ]]; then - for f in inventory.json inventory-api-key; do - if [[ -f "/etc/csr-intake/$f" && ! -e "$DATA/config/$f" ]]; then - install -m600 "/etc/csr-intake/$f" "$DATA/config/$f" - fi - done -fi -export XDG_RUNTIME_DIR=/run/pc-intake-runtime -mkdir -p "$XDG_RUNTIME_DIR" -chmod 0700 "$XDG_RUNTIME_DIR" -export WLR_RENDERER_ALLOW_SOFTWARE=1 -if command -v cage >/dev/null && [[ -x /usr/local/bin/intake-gui ]]; then - echo "Launching graphical intake station..." - cage -s -- /usr/local/bin/intake-gui --data "$DATA" - echo "Graphical interface exited. Starting terminal fallback." >&2 -fi -exec /usr/local/bin/intake-controller --data "$DATA" diff --git a/scripts/inventory-sync.py b/scripts/inventory-sync.py index d39c63e..b7b7fb1 100644 --- a/scripts/inventory-sync.py +++ b/scripts/inventory-sync.py @@ -21,7 +21,13 @@ import urllib.error import urllib.parse import urllib.request -DEFAULT_DATA = Path('/mnt/intake-data') +def default_data_dir(): + root = os.environ.get('XDG_DATA_HOME') + if root: + return Path(root) / 'csr-intake' + return Path.home() / '.local/share/csr-intake' + +DEFAULT_DATA = default_data_dir() def identity_value(value): value = str(value or '').strip().casefold() @@ -45,7 +51,7 @@ def hardware_id(report): if serial and vendor: payload = json.dumps([vendor, serial], ensure_ascii=True, separators=(',', ':')) return 'dmi-serial-sha256:' + hashlib.sha256(payload.encode()).hexdigest() - raise ValueError('Keine zuverlässige Hardware-Kennung im Bericht. Mit der aktuellen USB-Version Hardware erneut erfassen; alte Berichte ohne Kennung werden nicht automatisch zugeordnet.') + raise ValueError('Keine zuverlässige Hardware-Kennung im Bericht. Mit der aktuellen Anwendung Hardware erneut erfassen; alte Berichte ohne Kennung werden nicht automatisch zugeordnet.') def hardware_aliases(report): primary = hardware_id(report) @@ -105,7 +111,7 @@ def api_request(base, key, method, suffix, payload=None, timeout=12, ca_file=Non 'Authorization': 'Api-Key ' + key, 'Accept': 'application/json', 'Content-Type': 'application/json', - 'User-Agent': 'pc-intake-usb/1.4', + 'User-Agent': 'csr-intake/2.6', }) context = ssl.create_default_context() if ca_file: @@ -127,7 +133,7 @@ def credentials(config): raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)') key = os.environ.get('CSR_API_KEY', '') if not key: - secret = Path(config.get('key_file', '/mnt/intake-data/config/inventory-api-key')) + secret = Path(config.get('key_file', DEFAULT_DATA / 'config/inventory-api-key')) key = secret.read_text(encoding='utf-8').strip() if not key: raise ValueError('No API key configured') @@ -304,7 +310,7 @@ def queue_inspection(root,path,phase): ident=inspection['identity'] identity={'system_uuid':ident.get('uuid'),'serial_number':ident.get('serial'), 'manufacturer':ident.get('manufacturer'),'model':ident.get('model')} - # An immutable report provides chassis information, not the USB host's hardware. + # An immutable report provides the inspected computer's chassis information. if inspection.get('report'): identity.update({k:inspection['report'].get(k,identity.get(k)) for k in ['system_uuid','serial_number','manufacturer','model','chassis_type','batteries']}) results={k:{'outcome':v['outcome'],'notes':v.get('notes',''),'history':v.get('history',[]),'evidence':v.get('evidence','')} for k,v in inspection['checks'].items()} body={'hwid':hardware_id(identity),'phase':phase,'technician':inspection.get('technician',''), @@ -328,7 +334,7 @@ def flush_inspections(root,config): def run_station(root,action,report_path): # Recover the last saved snapshot even if power was lost before a worker queued it. - # These files may belong to a different PC previously visited by this USB. + # These files may belong to a different PC inspected earlier on this workstation. if action in ['boot','flush']: for saved in sorted((root/'inspections').glob('*.json')): inspection=json.loads(saved.read_text()) diff --git a/scripts/release-manifest.py b/scripts/release-manifest.py index e9a4de6..c064d81 100644 --- a/scripts/release-manifest.py +++ b/scripts/release-manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Source fingerprint and artifact verification; never touches USB devices.""" +"""Create and verify the reproducible standalone application manifest.""" import argparse import hashlib import json @@ -8,7 +8,7 @@ import subprocess import tomllib ROOT = Path(__file__).resolve().parents[1] -COMPONENTS = {'gui': 'intake-gui', 'scanner': 'pc-intake', 'controller': 'intake-controller'} +COMPONENTS = {'gui': 'intake-gui', 'scanner': 'pc-intake'} def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() @@ -19,8 +19,7 @@ def source_id(): base = ROOT / component paths.extend((base / 'src').rglob('*.rs')) paths.extend(p for p in [base / 'Cargo.toml', base / 'Cargo.lock', base / 'build.rs'] if p.exists()) - for folder in ['scripts', 'live']: - paths.extend(p for p in (ROOT / folder).iterdir() if p.is_file()) + paths.extend(p for p in (ROOT / 'scripts').iterdir() if p.is_file()) h = hashlib.sha256() for path in sorted(paths): h.update(str(path.relative_to(ROOT)).encode() + b'\0') @@ -41,38 +40,33 @@ def create(path): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(manifest, indent=2) + '\n') -def verify(profile): - root = profile / 'airootfs' - manifest = json.loads((root / 'etc/csr-intake/release.json').read_text()) +def verify(release_dir): + manifest = json.loads((release_dir / 'release.json').read_text()) + expected = set(COMPONENTS.values()) + if manifest.get('schema_version') != 1: + raise ValueError('Unsupported release manifest schema') + if set(manifest.get('binaries', {})) != expected: + raise ValueError('Release manifest has missing or unexpected binaries') for name, spec in manifest['binaries'].items(): - binary = root / 'usr/local/bin' / name + binary = release_dir / name if digest(binary) != spec['sha256']: raise ValueError(f'Binary hash mismatch: {name}') if not binary.stat().st_mode & 0o111: raise ValueError(f'Binary is not executable: {name}') - version = subprocess.check_output([root / 'usr/local/bin/intake-gui', '--version'], text=True) + version = subprocess.check_output([release_dir / 'intake-gui', '--version'], text=True) if manifest['build_id'] not in version: raise ValueError('GUI build ID differs from release manifest') - required = ['networkmanager', 'curl', 'ca-certificates', 'python', 'usbutils', 'smartmontools', 'alsa-utils', 'ffmpeg', 'stress-ng', 'xdg-utils', 'firefox'] - packages = (profile / 'packages.x86_64').read_text().splitlines() - for package in required: - if package not in packages: - raise ValueError(f'Missing live package: {package}') - for file in ['usr/local/bin/intake-login', 'usr/local/bin/intake-prepare', 'etc/systemd/system/getty@tty1.service.d/autologin.conf', 'etc/systemd/system/multi-user.target.wants/NetworkManager.service']: - p = root / file - if not p.exists() and not p.is_symlink(): - raise ValueError(f'Missing live startup file: {file}') - print(f"Profile verified: {manifest['build_id']}") + print(f"Release verified: {manifest['build_id']}") def main(): parser = argparse.ArgumentParser() group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--source-id', action='store_true') group.add_argument('--create', type=Path) - group.add_argument('--verify-profile', type=Path) + group.add_argument('--verify-release', type=Path) args = parser.parse_args() if args.source_id: print(source_id()) elif args.create: create(args.create) - else: verify(args.verify_profile) + else: verify(args.verify_release) if __name__ == '__main__': main() diff --git a/tests/test_release_manifest.py b/tests/test_release_manifest.py index 7a2da59..3d1a3fd 100644 --- a/tests/test_release_manifest.py +++ b/tests/test_release_manifest.py @@ -4,41 +4,64 @@ from pathlib import Path import tempfile import unittest from unittest.mock import patch + ROOT = Path(__file__).resolve().parents[1] -spec = importlib.util.spec_from_file_location('release_manifest', ROOT / 'scripts/release-manifest.py') +spec = importlib.util.spec_from_file_location( + 'release_manifest', ROOT / 'scripts/release-manifest.py' +) release = importlib.util.module_from_spec(spec) spec.loader.exec_module(release) + class ReleaseTests(unittest.TestCase): def test_source_id_is_stable(self): self.assertEqual(release.source_id(), release.source_id()) self.assertTrue(release.source_id().startswith('csr-')) + self.assertEqual(set(release.COMPONENTS.values()), {'intake-gui', 'pc-intake'}) - def test_profile_rejects_stale_binary(self): + def make_release(self, root): + for name in release.COMPONENTS.values(): + binary = root / name + binary.write_bytes(name.encode()) + binary.chmod(0o755) + manifest = { + 'schema_version': 1, + 'build_id': 'csr-test', + 'binaries': { + name: {'sha256': release.digest(root / name)} + for name in release.COMPONENTS.values() + }, + } + (root / 'release.json').write_text(json.dumps(manifest)) + + def test_release_rejects_stale_binary(self): with tempfile.TemporaryDirectory() as tmp: - profile = Path(tmp) - base = profile / 'airootfs' - (base / 'etc/csr-intake').mkdir(parents=True) - (base / 'usr/local/bin').mkdir(parents=True) - binary = base / 'usr/local/bin/intake-gui' - binary.write_bytes(b'original binary'); binary.chmod(0o755) - manifest = {'build_id':'csr-test','binaries':{'intake-gui':{'sha256':release.digest(binary)}}} - (base / 'etc/csr-intake/release.json').write_text(json.dumps(manifest)) - binary.write_bytes(b'stale binary') + root = Path(tmp) + self.make_release(root) + (root / 'intake-gui').write_bytes(b'stale binary') with self.assertRaisesRegex(ValueError, 'hash mismatch'): - release.verify(profile) + release.verify(root) - def test_complete_profile_is_verified(self): + def test_complete_release_is_verified(self): with tempfile.TemporaryDirectory() as tmp: - profile=Path(tmp); base=profile/'airootfs' - for file in ['etc/csr-intake/release.json', 'usr/local/bin/intake-gui', 'usr/local/bin/intake-login', 'usr/local/bin/intake-prepare', 'etc/systemd/system/getty@tty1.service.d/autologin.conf', 'etc/systemd/system/multi-user.target.wants/NetworkManager.service']: - path=base/file;path.parent.mkdir(parents=True,exist_ok=True);path.write_text('fixture') - binary=base/'usr/local/bin/intake-gui';binary.chmod(0o755) - (base/'etc/csr-intake/release.json').write_text(json.dumps({'build_id':'csr-test','binaries':{'intake-gui':{'sha256':release.digest(binary)}}})) - packages=['networkmanager','curl','ca-certificates','python','usbutils','smartmontools','alsa-utils','ffmpeg','stress-ng','xdg-utils','firefox'] - (profile/'packages.x86_64').write_text('\n'.join(packages)) - with patch.object(release.subprocess,'check_output',return_value='GUI csr-test'): - release.verify(profile) - (profile/'packages.x86_64').write_text('networkmanager') - with self.assertRaisesRegex(ValueError,'Missing live package'): - release.verify(profile) + root = Path(tmp) + self.make_release(root) + with patch.object(release.subprocess, 'check_output', return_value='GUI csr-test'): + release.verify(root) + (root / 'pc-intake').chmod(0o644) + with self.assertRaisesRegex(ValueError, 'not executable'): + release.verify(root) + + def test_release_requires_exact_component_set(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_release(root) + manifest = json.loads((root / 'release.json').read_text()) + manifest['binaries'].pop('pc-intake') + (root / 'release.json').write_text(json.dumps(manifest)) + with self.assertRaisesRegex(ValueError, 'missing or unexpected'): + release.verify(root) + + +if __name__ == '__main__': + unittest.main()