Live-USB durch eigenständige Anwendung ersetzen
Some checks failed
Tests / test (push) Has been cancelled
Some checks failed
Tests / test (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
150
README.md
150
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
7
controller/Cargo.lock
generated
7
controller/Cargo.lock
generated
@@ -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"
|
||||
@@ -1,5 +0,0 @@
|
||||
[package]
|
||||
name = "intake-controller"
|
||||
version = "1.7.0"
|
||||
edition = "2021"
|
||||
[dependencies]
|
||||
@@ -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\"");
|
||||
}
|
||||
}
|
||||
@@ -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'");
|
||||
}
|
||||
}
|
||||
@@ -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::<u64>().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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
[Unit]
|
||||
Requires=intake-prepare.service
|
||||
After=intake-prepare.service
|
||||
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=-/usr/bin/agetty --autologin intake --noclear %I $TERM
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
|
||||
@@ -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" "\$@"
|
||||
|
||||
@@ -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"
|
||||
@@ -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.'
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
root = Path(tmp)
|
||||
self.make_release(root)
|
||||
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)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user