Live-USB durch eigenständige Anwendung ersetzen
Some checks failed
Tests / test (push) Has been cancelled

This commit is contained in:
2026-10-15 10:26:33 +02:00
parent 692bbbb815
commit 1f91db25b5
23 changed files with 205 additions and 1228 deletions

View File

@@ -17,19 +17,14 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- name: Rust testen - name: Anwendung bauen und testen
run: | run: |
cargo fmt --check --manifest-path scanner/Cargo.toml bash -n scripts/*.sh tests/*.sh
cargo fmt --check --manifest-path controller/Cargo.toml bash scripts/build-release.sh
cargo fmt --check --manifest-path gui/Cargo.toml ./dist/csr-intake-x86_64.run --check-bundle
cargo clippy --locked --manifest-path scanner/Cargo.toml --all-targets -- -D warnings - name: Eigenständige Anwendung bereitstellen
cargo clippy --locked --manifest-path controller/Cargo.toml --all-targets -- -D warnings uses: actions/upload-artifact@v4
cargo clippy --locked --manifest-path gui/Cargo.toml --all-targets -- -D warnings with:
cargo test --locked --manifest-path scanner/Cargo.toml name: csr-intake-x86_64
cargo test --locked --manifest-path controller/Cargo.toml path: dist/csr-intake-x86_64.run
cargo test --locked --manifest-path gui/Cargo.toml if-no-files-found: error
- 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

150
README.md
View File

@@ -1,21 +1,22 @@
# CS-R USB-AIO # CS-R Intake
Der USB-Stick unterstützt die Annahme und technische Prüfung gespendeter Computer. CS-R Intake ist eine eigenständige Linux-Anwendung für die Annahme und technische
Er startet als eigenständiges Arch-Linux-Livesystem, erkennt das aktuelle Gerät über Prüfung gespendeter Computer. Sie erkennt das aktuelle Gerät über eine stabile
seine Hardware-ID und verbindet es mit der zentralen Inventarverwaltung. Hardware-ID, verbindet es mit der Inventarverwaltung und speichert alle Arbeiten
auch ohne Netzwerk lokal.
## Ablauf in der Werkstatt ## 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 2. Beim ersten Einsatz unter **Einstellungen** das Werkstatt-WLAN verbinden und
**WLAN speichern** aktiviert lassen. **WLAN speichern** aktiviert lassen.
3. Das Gerät wird über DMI-UUID oder Hersteller und Seriennummer erkannt. 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. 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, 5. Neue Geräte und Geräte im Status **Eingang** erhalten automatisch einen Kurzscan
RAM und Datenträgern. mit Modell, CPU, RAM und Datenträgern.
6. Danach kann der Techniker das Gerät ausschalten oder die Inspektion starten. 6. Danach kann der Techniker die Anwendung schließen oder die Inspektion starten.
7. Prüfstände und Ergebnisse werden als **Inspektion** synchronisiert. Offline 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**. Die Oberfläche konzentriert sich auf **Gerät**, **Inspektion** und **Einstellungen**.
Reparaturen, Berichte und die Synchronisationswarteschlange sind über das Menü erreichbar. 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 - zentrale Sprache, Testdauer und Grenzwerte
- verifizierbarer Build mit Versions- und Prüfsummenmanifest - 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 ## Projektaufbau
| Pfad | Inhalt | | Pfad | Inhalt |
| --- | --- | | --- | --- |
| `gui/` | Bedienoberfläche und geführte Tests | | `gui/` | Bedienoberfläche und geführte Tests |
| `scanner/` | Hardwareerkennung und Messwerte | | `scanner/` | Hardwareerkennung und Messwerte |
| `controller/` | Geräteklassifizierung und Auswertung |
| `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge | | `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge |
| `live/` | Start und Vorbereitung des Livesystems |
| `tests/` | Python- und Rauchtests | | `tests/` | Python- und Rauchtests |
| `config.example/` | Beispiel für die Inventarverbindung | | `config.example/` | Beispiel für die Inventarverbindung |
Der Inventory-Server wird in einem eigenen Repository gepflegt. Dieses Repository Der Inventory-Server wird in einem eigenen Repository gepflegt. Weitere Einzelheiten
enthält ausschließlich den USB-Client. zum Arbeitsablauf stehen in [WERKSTATT-ABLAUF.md](WERKSTATT-ABLAUF.md).
## 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).

View File

@@ -2,10 +2,11 @@
## Geräteerkennung ## Geräteerkennung
Beim Start liest das Livesystem geschützte DMI-Felder einmal als `root` und stellt Beim Start liest die Anwendung die verfügbaren DMI-Felder. Bevorzugt wird eine
sie der unprivilegierten Anwendung nur lesbar bereit. Bevorzugt wird eine gültige gültige System-UUID. Fehlt sie, wird eine gehashte Kombination aus Hersteller und
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. 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 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 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 ## Unterbrechung und Offlinebetrieb
Der lokale Prüfstand wird atomar gespeichert. Nach Neustart öffnet die Anwendung Der lokale Prüfstand wird atomar gespeichert. Nach einem Neustart öffnet die
die letzte unvollständige Prüfung mit Messwerten und Notizen. Noch nicht bestätigte Anwendung die letzte unvollständige Prüfung mit Messwerten und Notizen. Noch nicht
Uploads liegen in `outbox/inspections/`; bestätigte Uploads werden nach bestätigte Uploads liegen in `outbox/inspections/`; bestätigte Uploads werden nach
`outbox/sent/` verschoben. Beim nächsten Start werden auch gespeicherte Prüfungen `outbox/sent/` verschoben. Beim nächsten Start werden gespeicherte Prüfungen erneut
des zuvor bearbeiteten Computers wieder in die Warteschlange aufgenommen. in die Warteschlange aufgenommen.
## Reparaturen und Bericht ## 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: Vor dem Werkstatteinsatz sind mindestens diese Punkte auf echter Hardware zu prüfen:
1. UEFI- und BIOS-Start 1. Start der eigenständigen Datei in der vorgesehenen Linux-Umgebung
2. persistente Daten nach Neustart 2. persistente Daten nach Neustart der Anwendung und des Rechners
3. automatische Wiederverbindung mit dem WLAN 3. automatische Wiederverbindung mit dem WLAN
4. richtige Gerätezuordnung und Dublettenwarnung 4. richtige Gerätezuordnung und Dublettenwarnung
5. Intake-Synchronisation ohne Überschreiben späterer Zustände 5. Intake-Synchronisation ohne Überschreiben späterer Zustände
6. Ein- und Ausgabegeräte sowie jedes Laufwerk 6. Ein- und Ausgabegeräte sowie jedes Laufwerk
7. Offline-Warteschlange und Wiederholung nach Verbindungsabbruch 7. Offline-Warteschlange und Wiederholung nach Verbindungsabbruch
8. druckbarer Bericht 8. druckbarer Bericht
9. kontrolliertes Herunterfahren

7
controller/Cargo.lock generated
View File

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

View File

@@ -1,5 +0,0 @@
[package]
name = "intake-controller"
version = "1.7.0"
edition = "2021"
[dependencies]

View File

@@ -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<String> {
fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn output(exe: &str, args: &[&str]) -> Option<String> {
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::<String>()
.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::<Vec<_>>()
);
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::<u64>().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<std::process::ExitStatus>| 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\"");
}
}

View File

@@ -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<String, String> {
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<String, String> {
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<PathBuf> {
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<String> = 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'");
}
}

View File

@@ -99,7 +99,7 @@ fn temps() -> Vec<(String, f64)> {
} }
fn thermal(settings: &crate::workflow::Settings) -> (bool, String) { fn thermal(settings: &crate::workflow::Settings) -> (bool, String) {
if !available("stress-ng") { 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 baseline = temps();
let start = Instant::now(); let start = Instant::now();
@@ -186,7 +186,7 @@ fn stability(settings: &crate::workflow::Settings) -> (bool, String) {
.and_then(|v| v.parse::<u64>().ok()) .and_then(|v| v.parse::<u64>().ok())
}) })
.unwrap_or(0); .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 { if mem_kib < 256 * 1024 {
return ( return (
false, false,

View File

@@ -90,7 +90,7 @@ fn scanner_path() -> PathBuf {
if let Some(p) = std::env::var_os("CSR_SCANNER") { if let Some(p) = std::env::var_os("CSR_SCANNER") {
return PathBuf::from(p); 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 Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() { if let Some(parent) = exe.parent() {
let sibling = parent.join("pc-intake"); let sibling = parent.join("pc-intake");
@@ -782,7 +782,7 @@ impl App {
} }
ui.add_space(16.0); ui.add_space(16.0);
if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending { 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() { 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 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))); 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, ORANGE,
tr( tr(
en, en,
"Keine gültige Hardware-Kennung. Aktuellen USB neu starten.", "Keine gültige Hardware-Kennung. Anwendung neu starten und erneut erfassen.",
"No valid hardware identity. Reboot the updated USB.", "No valid hardware identity. Restart the application and scan again.",
), ),
); );
} }
@@ -1488,8 +1488,8 @@ impl App {
}); });
ui.small(tr( ui.small(tr(
en, en,
"Die Tastaturbelegung wird beim nächsten Start des USB-Systems angewendet.", "Die Tastaturbelegung wird beim nächsten Start der Anwendung angewendet.",
"The keyboard layout is applied on the next USB-system boot.", "The keyboard layout is applied on the next application start.",
)); ));
ui.label(tr(en, "WLAN-Testserver", "Wi-Fi test server")); ui.label(tr(en, "WLAN-Testserver", "Wi-Fi test server"));
ui.text_edit_singleline(&mut self.settings_draft.test_url); ui.text_edit_singleline(&mut self.settings_draft.test_url);

View File

@@ -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.")); 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.")); } } else { ui.label(crate::tr(en, "Offenes Netzwerk – kein Passwort nötig.", "Open network — no password needed.")); }
if self.saved_path.is_some() { 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() { if ui.button(crate::tr(en, "Mit WLAN verbinden", "Connect to Wi-Fi")).clicked() {
let password = self.password.clone(); self.show_password = false; let password = self.password.clone(); self.show_password = false;
@@ -558,7 +558,7 @@ impl Wifi {
thread::spawn(move || { thread::spawn(move || {
let result = connect(&n, &password).and_then(|_| { let result = connect(&n, &password).and_then(|_| {
if let Some(path) = save { 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)?; save_network(&path, &n.ssid, &password)?;
} }
Ok(()) Ok(())

View File

@@ -1,7 +0,0 @@
[Unit]
Requires=intake-prepare.service
After=intake-prepare.service
[Service]
ExecStart=
ExecStart=-/usr/bin/agetty --autologin intake --noclear %I $TERM

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export CSR_BUILD_ID export CSR_BUILD_ID
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-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 fmt --check --manifest-path "$ROOT/$component/Cargo.toml"
cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings
cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml" cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml"
@@ -12,4 +12,5 @@ done
python3 -m unittest discover -s "$ROOT/tests" -p 'test_*.py' python3 -m unittest discover -s "$ROOT/tests" -p 'test_*.py'
bash "$ROOT/tests/smoke.sh" bash "$ROOT/tests/smoke.sh"
python3 "$ROOT/scripts/release-manifest.py" --create "$ROOT/build/release.json" 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"

View File

@@ -32,6 +32,7 @@ mkdir -p "$TMP/app" "$(dirname "$OUT")"
install -m755 "$GUI" "$TMP/app/intake-gui" install -m755 "$GUI" "$TMP/app/intake-gui"
install -m755 "$SCANNER" "$TMP/app/pc-intake" install -m755 "$SCANNER" "$TMP/app/pc-intake"
install -m644 "$MANIFEST" "$TMP/app/release.json" install -m644 "$MANIFEST" "$TMP/app/release.json"
python3 "$ROOT/scripts/release-manifest.py" --verify-release "$TMP/app"
( (
cd "$TMP/app" cd "$TMP/app"
sha256sum intake-gui pc-intake release.json > SHA256SUMS sha256sum intake-gui pc-intake release.json > SHA256SUMS
@@ -85,10 +86,37 @@ esac
extract_bundle extract_bundle
data_dir=\${XDG_DATA_HOME:-\${HOME:-\$PWD}/.local/share}/csr-intake data_dir=\${XDG_DATA_HOME:-\${HOME:-\$PWD}/.local/share}/csr-intake
expect_data=0
has_data=0 has_data=0
for argument in "\$@"; do 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 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" export CSR_SCANNER="\$app_dir/pc-intake"
if [ "\$has_data" -eq 1 ]; then if [ "\$has_data" -eq 1 ]; then
exec "\$app_dir/intake-gui" "\$@" exec "\$app_dir/intake-gui" "\$@"

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,13 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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): def identity_value(value):
value = str(value or '').strip().casefold() value = str(value or '').strip().casefold()
@@ -45,7 +51,7 @@ def hardware_id(report):
if serial and vendor: if serial and vendor:
payload = json.dumps([vendor, serial], ensure_ascii=True, separators=(',', ':')) payload = json.dumps([vendor, serial], ensure_ascii=True, separators=(',', ':'))
return 'dmi-serial-sha256:' + hashlib.sha256(payload.encode()).hexdigest() 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): def hardware_aliases(report):
primary = hardware_id(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, 'Authorization': 'Api-Key ' + key,
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'pc-intake-usb/1.4', 'User-Agent': 'csr-intake/2.6',
}) })
context = ssl.create_default_context() context = ssl.create_default_context()
if ca_file: 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)') raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)')
key = os.environ.get('CSR_API_KEY', '') key = os.environ.get('CSR_API_KEY', '')
if not 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() key = secret.read_text(encoding='utf-8').strip()
if not key: if not key:
raise ValueError('No API key configured') raise ValueError('No API key configured')
@@ -304,7 +310,7 @@ def queue_inspection(root,path,phase):
ident=inspection['identity'] ident=inspection['identity']
identity={'system_uuid':ident.get('uuid'),'serial_number':ident.get('serial'), identity={'system_uuid':ident.get('uuid'),'serial_number':ident.get('serial'),
'manufacturer':ident.get('manufacturer'),'model':ident.get('model')} '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']}) 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()} 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',''), 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): def run_station(root,action,report_path):
# Recover the last saved snapshot even if power was lost before a worker queued it. # 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']: if action in ['boot','flush']:
for saved in sorted((root/'inspections').glob('*.json')): for saved in sorted((root/'inspections').glob('*.json')):
inspection=json.loads(saved.read_text()) inspection=json.loads(saved.read_text())

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Source fingerprint and artifact verification; never touches USB devices.""" """Create and verify the reproducible standalone application manifest."""
import argparse import argparse
import hashlib import hashlib
import json import json
@@ -8,7 +8,7 @@ import subprocess
import tomllib import tomllib
ROOT = Path(__file__).resolve().parents[1] 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): def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest() return hashlib.sha256(path.read_bytes()).hexdigest()
@@ -19,8 +19,7 @@ def source_id():
base = ROOT / component base = ROOT / component
paths.extend((base / 'src').rglob('*.rs')) paths.extend((base / 'src').rglob('*.rs'))
paths.extend(p for p in [base / 'Cargo.toml', base / 'Cargo.lock', base / 'build.rs'] if p.exists()) 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 / 'scripts').iterdir() if p.is_file())
paths.extend(p for p in (ROOT / folder).iterdir() if p.is_file())
h = hashlib.sha256() h = hashlib.sha256()
for path in sorted(paths): for path in sorted(paths):
h.update(str(path.relative_to(ROOT)).encode() + b'\0') 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.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(manifest, indent=2) + '\n') path.write_text(json.dumps(manifest, indent=2) + '\n')
def verify(profile): def verify(release_dir):
root = profile / 'airootfs' manifest = json.loads((release_dir / 'release.json').read_text())
manifest = json.loads((root / 'etc/csr-intake/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(): for name, spec in manifest['binaries'].items():
binary = root / 'usr/local/bin' / name binary = release_dir / name
if digest(binary) != spec['sha256']: if digest(binary) != spec['sha256']:
raise ValueError(f'Binary hash mismatch: {name}') raise ValueError(f'Binary hash mismatch: {name}')
if not binary.stat().st_mode & 0o111: if not binary.stat().st_mode & 0o111:
raise ValueError(f'Binary is not executable: {name}') 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: if manifest['build_id'] not in version:
raise ValueError('GUI build ID differs from release manifest') 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'] print(f"Release verified: {manifest['build_id']}")
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']}")
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True) group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--source-id', action='store_true') group.add_argument('--source-id', action='store_true')
group.add_argument('--create', type=Path) group.add_argument('--create', type=Path)
group.add_argument('--verify-profile', type=Path) group.add_argument('--verify-release', type=Path)
args = parser.parse_args() args = parser.parse_args()
if args.source_id: print(source_id()) if args.source_id: print(source_id())
elif args.create: create(args.create) elif args.create: create(args.create)
else: verify(args.verify_profile) else: verify(args.verify_release)
if __name__ == '__main__': main() if __name__ == '__main__': main()

View File

@@ -4,41 +4,64 @@ from pathlib import Path
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1] 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) release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release) spec.loader.exec_module(release)
class ReleaseTests(unittest.TestCase): class ReleaseTests(unittest.TestCase):
def test_source_id_is_stable(self): def test_source_id_is_stable(self):
self.assertEqual(release.source_id(), release.source_id()) self.assertEqual(release.source_id(), release.source_id())
self.assertTrue(release.source_id().startswith('csr-')) 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: with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) root = Path(tmp)
base = profile / 'airootfs' self.make_release(root)
(base / 'etc/csr-intake').mkdir(parents=True) (root / 'intake-gui').write_bytes(b'stale binary')
(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')
with self.assertRaisesRegex(ValueError, 'hash mismatch'): 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: with tempfile.TemporaryDirectory() as tmp:
profile=Path(tmp); base=profile/'airootfs' root = Path(tmp)
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']: self.make_release(root)
path=base/file;path.parent.mkdir(parents=True,exist_ok=True);path.write_text('fixture') with patch.object(release.subprocess, 'check_output', return_value='GUI csr-test'):
binary=base/'usr/local/bin/intake-gui';binary.chmod(0o755) release.verify(root)
(base/'etc/csr-intake/release.json').write_text(json.dumps({'build_id':'csr-test','binaries':{'intake-gui':{'sha256':release.digest(binary)}}})) (root / 'pc-intake').chmod(0o644)
packages=['networkmanager','curl','ca-certificates','python','usbutils','smartmontools','alsa-utils','ffmpeg','stress-ng','xdg-utils','firefox'] with self.assertRaisesRegex(ValueError, 'not executable'):
(profile/'packages.x86_64').write_text('\n'.join(packages)) release.verify(root)
with patch.object(release.subprocess,'check_output',return_value='GUI csr-test'):
release.verify(profile) def test_release_requires_exact_component_set(self):
(profile/'packages.x86_64').write_text('networkmanager') with tempfile.TemporaryDirectory() as tmp:
with self.assertRaisesRegex(ValueError,'Missing live package'): root = Path(tmp)
release.verify(profile) 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()