diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index ba3173d..7d43dc2 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: - name: Systempakete installieren run: | sudo apt-get update - sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev + sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev network-manager curl - name: Rust installieren uses: dtolnay/rust-toolchain@stable with: @@ -21,10 +21,12 @@ jobs: run: | bash -n scripts/*.sh tests/*.sh bash scripts/build-release.sh - ./dist/csr-intake-x86_64.run --check-bundle + ./dist/csr-intake-x86_64 --check-bundle + file dist/csr-intake-x86_64 | grep -q 'static-pie linked' + env -i HOME="$RUNNER_TEMP" XDG_CACHE_HOME="$RUNNER_TEMP/cache" PATH=/nonexistent ./dist/csr-intake-x86_64 --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 + path: dist/csr-intake-x86_64 if-no-files-found: error diff --git a/README.md b/README.md index b85e0c6..6cf5191 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ auch ohne Netzwerk lokal. ## Ablauf in der Werkstatt -1. `csr-intake-x86_64.run` auf dem zu prüfenden Linux-Rechner starten. +1. `csr-intake-x86_64` 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. @@ -39,27 +39,32 @@ Reparaturen, Berichte und die Synchronisationswarteschlange sind über das Menü ## 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`. +Die Release-Datei ist ein statisch gelinktes Linux-ELF. Sie enthält Oberfläche, +Hardware-Scanner, Python-Laufzeit, TLS, SQLite und alle unmittelbar benötigten +Laufzeitbibliotheken. Sie benötigt weder eine installierte Python-Version noch +Shell-, Archiv- oder glibc-Werkzeuge. Geprüfte Programmdateien werden in den +Benutzer-Cache entpackt. Einstellungen, Berichte und die Offline-Warteschlange +liegen standardmäßig unter `~/.local/share/csr-intake`. ```bash -chmod +x csr-intake-x86_64.run -./csr-intake-x86_64.run +chmod +x csr-intake-x86_64 +./csr-intake-x86_64 ``` 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 +./csr-intake-x86_64 --data "$HOME/csr-intake-daten" +./csr-intake-x86_64 --version +./csr-intake-x86_64 --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. +Die Anwendung ist für x86-64 Linux ab Kernel 4.4 gebaut. Clientprogramme für WLAN, +HTTP, Audio, Video, SMART und USB werden in den Build übernommen, sofern sie auf dem +Build-Rechner verfügbar sind. Benötigt werden nur eine grafische Linux-Sitzung, der +Kernel und die Systemdienste für die tatsächlich verwendete Hardware, insbesondere +der NetworkManager-Dienst für WLAN. Ein im Build nicht vorhandenes Diagnosewerkzeug +macht nur den betreffenden Test **nicht verfügbar**. ## Konfiguration @@ -85,13 +90,13 @@ aus und erzeugt anschließend genau ein verteilbares Artefakt: ```bash bash scripts/build-release.sh -./dist/csr-intake-x86_64.run --check-bundle +./dist/csr-intake-x86_64 --check-bundle ``` -Das Ergebnis liegt in `dist/csr-intake-x86_64.run`. `build/release.json` enthält +Das Ergebnis liegt in `dist/csr-intake-x86_64`. `build/release.json` enthält Build-ID, Versionen und SHA-256-Prüfsummen der eingebetteten Programme. Die CI stellt -bei jedem erfolgreichen Build eine neutrale `.run`-Datei ohne Zugangsdaten als -Artefakt bereit. +bei jedem erfolgreichen Build eine neutrale ELF-Datei ohne Zugangsdaten als Artefakt +bereit. Für eine werkstattgebundene Datei können `inventory-api-key` und `inventory-server.crt` vor dem Build in `.bundle-secrets/` abgelegt werden. Der Build @@ -106,6 +111,7 @@ einen lesbaren Zugangsschlüssel und darf nur wie ein Passwort weitergegeben wer | --- | --- | | `gui/` | Bedienoberfläche und geführte Tests | | `scanner/` | Hardwareerkennung und Messwerte | +| `launcher/` | Statischer ELF-Starter und eingebettete Laufzeit | | `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge | | `tests/` | Python- und Rauchtests | | `config.example/` | Beispiel für die Inventarverbindung | diff --git a/gui/src/inventory.rs b/gui/src/inventory.rs index 11eb26d..06486ab 100644 --- a/gui/src/inventory.rs +++ b/gui/src/inventory.rs @@ -1,6 +1,16 @@ use serde::Deserialize; use std::{path::Path, process::Command}; const BRIDGE: &str = include_str!("../../scripts/inventory-sync.py"); + +fn python() -> Command { + if let Some(launcher) = std::env::var_os("CSR_RUNTIME_LAUNCHER") { + let mut command = Command::new(launcher); + command.arg("--internal-python"); + command + } else { + Command::new("python3") + } +} #[derive(Clone, Debug, Deserialize)] pub struct QueueEntry { pub asset_id: String, @@ -12,7 +22,7 @@ pub struct QueueEntry { pub report_path: String, } fn call(data: &Path, args: &[&str]) -> Result { - let out = Command::new("python3") + let out = python() .args(["-c", BRIDGE, "--data"]) .arg(data) .args(args) @@ -48,7 +58,7 @@ pub struct HardwareIdentity { pub fn identify(identity: &crate::workflow::Identity) -> Result { let report = serde_json::json!({"system_uuid": identity.uuid, "serial_number": identity.serial, "manufacturer": identity.manufacturer}); - let output = Command::new("python3") + let output = python() .args(["-c", BRIDGE, "--identity-json", &report.to_string()]) .output() .map_err(|e| e.to_string())?; diff --git a/gui/src/main.rs b/gui/src/main.rs index 96af127..db07265 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -86,24 +86,29 @@ struct App { station_ready: bool, next_station_attempt: Instant, } -fn scanner_path() -> PathBuf { +fn scanner_command() -> Command { + if let Some(launcher) = std::env::var_os("CSR_RUNTIME_LAUNCHER") { + let mut command = Command::new(launcher); + command.arg("--internal-scanner"); + return command; + } if let Some(p) = std::env::var_os("CSR_SCANNER") { - return PathBuf::from(p); + return Command::new(p); } // 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"); if sibling.exists() { - return sibling; + return Command::new(sibling); } } } let local = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scanner/target/release/pc-intake"); if local.exists() { - local + Command::new(local) } else { - PathBuf::from("/usr/local/bin/pc-intake") + Command::new("/usr/local/bin/pc-intake") } } fn setup(data: &Path) -> Result<(), String> { @@ -337,20 +342,17 @@ impl App { } let data = self.data.clone(); let asset = self.inspection.as_ref().unwrap().asset.clone(); - let scanner = scanner_path(); + let mut scanner = scanner_command(); let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.status = "Hardware scan running / Hardware-Scan läuft …".into(); thread::spawn(move || { let result = (|| { - let mut command = Command::new("timeout"); - command - .args(["--kill-after=5s", "600s"]) - .arg(scanner) + scanner .args(["--asset-id", &asset, "--output"]) .arg(data.join("reports")); - command.arg(if full { "--compile" } else { "--quick" }); - let out = command.output().map_err(|e| format!("Scanner: {e}"))?; + scanner.arg(if full { "--compile" } else { "--quick" }); + let out = scanner.output().map_err(|e| format!("Scanner: {e}"))?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).into_owned()); } diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock new file mode 100644 index 0000000..52d0118 --- /dev/null +++ b/launcher/Cargo.lock @@ -0,0 +1,64 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "csr-intake-launcher" +version = "2.6.0" +dependencies = [ + "flate2", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml new file mode 100644 index 0000000..a54b809 --- /dev/null +++ b/launcher/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "csr-intake-launcher" +version = "2.6.0" +edition = "2021" + +[dependencies] +flate2 = "1" + +[profile.release] +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/launcher/src/main.rs b/launcher/src/main.rs new file mode 100644 index 0000000..fbc4c26 --- /dev/null +++ b/launcher/src/main.rs @@ -0,0 +1,330 @@ +use flate2::read::GzDecoder; +use std::env; +use std::fs::{self, File}; +use std::io::{self, BufReader, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Component, Path, PathBuf}; +use std::process::{self, Command}; + +const BUILD_ID: &str = env!("CSR_BUILD_ID"); +const VERSION: &str = env!("CSR_VERSION"); +const PAYLOAD: &[u8] = include_bytes!(env!("CSR_PAYLOAD_FILE")); +const MANIFEST: &str = include_str!(env!("CSR_PAYLOAD_MANIFEST")); +const MAGIC: &[u8; 8] = b"CSRPKG3\0"; + +fn cache_dir() -> PathBuf { + env::var_os("XDG_CACHE_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .unwrap_or_else(|| env::temp_dir().join(format!("csr-intake-{}", process::id()))) + .join("csr-intake") + .join(BUILD_ID) +} + +fn default_data_dir() -> PathBuf { + env::var_os("XDG_DATA_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .unwrap_or_else(|| PathBuf::from(".")) + .join("csr-intake") +} + +fn fnv(path: &Path) -> io::Result { + let mut input = BufReader::new(File::open(path)?); + let mut hash = 0xcbf29ce484222325u64; + let mut buf = [0u8; 65536]; + loop { + let count = input.read(&mut buf)?; + if count == 0 { + break; + } + for byte in &buf[..count] { + hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3); + } + } + Ok(hash) +} + +fn valid(root: &Path) -> bool { + MANIFEST.lines().all(|line| { + let mut fields = line.splitn(4, '\t'); + let expected_hash = fields.next().and_then(|v| u64::from_str_radix(v, 16).ok()); + let expected_mode = fields.next().and_then(|v| u32::from_str_radix(v, 8).ok()); + let expected_size = fields.next().and_then(|v| v.parse::().ok()); + let name = fields.next(); + let (Some(hash), Some(mode), Some(size), Some(name)) = + (expected_hash, expected_mode, expected_size, name) + else { + return false; + }; + let path = root.join(name); + let Ok(metadata) = fs::metadata(&path) else { + return false; + }; + metadata.is_file() + && metadata.len() == size + && metadata.permissions().mode() & 0o777 == mode + && fnv(&path).ok() == Some(hash) + }) +} + +fn read_u32(reader: &mut impl Read) -> io::Result { + let mut bytes = [0u8; 4]; + reader.read_exact(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) +} + +fn read_u64(reader: &mut impl Read) -> io::Result { + let mut bytes = [0u8; 8]; + reader.read_exact(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) +} + +fn safe_relative(value: &str) -> io::Result<&Path> { + let path = Path::new(value); + if value.is_empty() + || path + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsafe bundle path", + )); + } + Ok(path) +} + +fn unpack(destination: &Path) -> io::Result<()> { + let mut reader = GzDecoder::new(PAYLOAD); + let mut magic = [0u8; 8]; + reader.read_exact(&mut magic)?; + if &magic != MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid bundle header", + )); + } + loop { + let name_len = read_u32(&mut reader)?; + if name_len == 0 { + break; + } + if name_len > 4096 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bundle path too long", + )); + } + let mode = read_u32(&mut reader)?; + let size = read_u64(&mut reader)?; + let mut name = vec![0u8; name_len as usize]; + reader.read_exact(&mut name)?; + let name = std::str::from_utf8(&name) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-UTF-8 bundle path"))?; + let path = destination.join(safe_relative(name)?); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut output = File::create(&path)?; + io::copy(&mut reader.by_ref().take(size), &mut output)?; + output.flush()?; + fs::set_permissions(&path, fs::Permissions::from_mode(mode & 0o777))?; + } + Ok(()) +} + +fn ensure_bundle() -> io::Result { + let app = cache_dir(); + if !valid(&app) { + if app.exists() { + fs::remove_dir_all(&app)?; + } + let parent = app.parent().unwrap(); + fs::create_dir_all(parent)?; + let temp = parent.join(format!(".extract-{BUILD_ID}-{}", process::id())); + if temp.exists() { + fs::remove_dir_all(&temp)?; + } + fs::create_dir(&temp)?; + if let Err(error) = unpack(&temp).and_then(|_| { + if valid(&temp) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "bundle checksum mismatch", + )) + } + }) { + let _ = fs::remove_dir_all(&temp); + return Err(error); + } + if let Err(error) = fs::rename(&temp, &app) { + if !valid(&app) { + return Err(error); + } + let _ = fs::remove_dir_all(&temp); + } + } + let runtime = app.join("runtime-launcher"); + let current = env::current_exe()?; + let replace = fs::metadata(&runtime).map(|m| m.len()).ok() + != fs::metadata(¤t).map(|m| m.len()).ok(); + if replace { + let _ = fs::remove_file(&runtime); + if fs::hard_link(¤t, &runtime).is_err() { + fs::copy(¤t, &runtime)?; + } + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?; + } + Ok(app) +} + +fn loader_command(app: &Path, program: &Path) -> Command { + let libraries = app.join("lib"); + let mut command = Command::new(app.join("lib/ld-linux-x86-64.so.2")); + command.arg("--library-path").arg(&libraries).arg(program); + command.env("LD_LIBRARY_PATH", &libraries); + command.env( + "PATH", + format!( + "{}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + app.join("bin").display() + ), + ); + let certificates = app.join("etc/ca-certificates.crt"); + if certificates.exists() { + command.env("SSL_CERT_FILE", &certificates); + command.env("CURL_CA_BUNDLE", &certificates); + } + command +} + +fn internal(app: &Path, kind: &str, args: impl Iterator) -> ! { + let mut command = if kind == "python" { + let mut command = loader_command(app, &app.join("python/bin/python3")); + command.env("PYTHONHOME", app.join("python")); + command.env( + "PYTHONPATH", + format!( + "{}:{}", + app.join("python/lib/python.zip").display(), + app.join("python/lib/python/lib-dynload").display() + ), + ); + command + } else { + loader_command(app, &app.join("pc-intake")) + }; + command.args(args); + let error = command.exec(); + eprintln!("Could not start bundled {kind}: {error}"); + process::exit(126) +} + +fn seed_config(app: &Path, data: &Path) -> io::Result<()> { + let source = app.join("default-config"); + if !source.is_dir() { + return Ok(()); + } + let target = data.join("config"); + fs::create_dir_all(&target)?; + fs::set_permissions(&target, fs::Permissions::from_mode(0o700))?; + for name in [ + "inventory.json", + "inventory-api-key", + "inventory-server.crt", + ] { + let destination = target.join(name); + if !destination.exists() { + fs::copy(source.join(name), &destination)?; + fs::set_permissions(destination, fs::Permissions::from_mode(0o600))?; + } + } + Ok(()) +} + +fn keyboard_layout(data: &Path) -> Option { + let text = fs::read_to_string(data.join("config/workshop.json")).ok()?; + let rest = text + .split_once("\"keyboard_layout\"")? + .1 + .split_once(':')? + .1 + .trim_start(); + let value = rest.strip_prefix('"')?.split_once('"')?.0; + matches!(value, "de" | "us").then(|| value.to_owned()) +} + +fn fail(context: &str, error: impl std::fmt::Display) -> ! { + eprintln!("CS-R Intake: {context}: {error}"); + process::exit(2) +} + +fn main() { + let mut args = env::args().skip(1); + let first = args.next(); + if first.as_deref() == Some("--version") { + println!("CS-R Intake {VERSION} · {BUILD_ID}"); + return; + } + if matches!( + first.as_deref(), + Some("--internal-python" | "--internal-scanner") + ) { + let app = env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_owned)) + .unwrap_or_else(|| fail("runtime location", "unavailable")); + internal( + &app, + if first.as_deref() == Some("--internal-python") { + "python" + } else { + "scanner" + }, + args, + ); + } + let app = ensure_bundle().unwrap_or_else(|e| fail("bundle extraction failed", e)); + if first.as_deref() == Some("--check-bundle") { + println!("Standalone ELF verified: {BUILD_ID}"); + return; + } + let mut gui_args = Vec::new(); + if let Some(value) = first { + gui_args.push(value); + } + gui_args.extend(args); + let mut data = default_data_dir(); + let mut has_data = false; + let mut index = 0; + while index < gui_args.len() { + if gui_args[index] == "--data" { + has_data = true; + data = gui_args + .get(index + 1) + .map(PathBuf::from) + .unwrap_or_else(|| fail("argument error", "missing directory after --data")); + index += 1; + } + index += 1; + } + seed_config(&app, &data).unwrap_or_else(|e| fail("could not install configuration", e)); + if !has_data { + gui_args.extend(["--data".into(), data.to_string_lossy().into_owned()]); + } + if let Some(layout) = keyboard_layout(&data) { + env::set_var("XKB_DEFAULT_LAYOUT", layout); + } + env::set_var("CSR_RUNTIME_LAUNCHER", app.join("runtime-launcher")); + env::set_var("CSR_SCANNER", app.join("pc-intake")); + let mut command = loader_command(&app, &app.join("intake-gui")); + command.args(gui_args); + let error = command.exec(); + fail("could not start GUI", error) +} diff --git a/scripts/build-release.sh b/scripts/build-release.sh index c51c732..cf86b1a 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -3,6 +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) +cargo fmt --check --manifest-path "$ROOT/launcher/Cargo.toml" 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 diff --git a/scripts/build-standalone.sh b/scripts/build-standalone.sh index 48df551..655e474 100755 --- a/scripts/build-standalone.sh +++ b/scripts/build-standalone.sh @@ -2,7 +2,7 @@ set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -OUT=${1:-$ROOT/dist/csr-intake-x86_64.run} +OUT=${1:-$ROOT/dist/csr-intake-x86_64} GUI="$ROOT/gui/target/release/intake-gui" SCANNER="$ROOT/scanner/target/release/pc-intake" MANIFEST="$ROOT/build/release.json" @@ -16,7 +16,7 @@ for file in "$GUI" "$SCANNER" "$MANIFEST"; do done BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id) -GUI_VERSION=$("$GUI" --version) +GUI_VERSION=$($GUI --version) grep -qF "$BUILD_ID" <<< "$GUI_VERSION" || { echo 'Release binaries are stale. Run bash scripts/build-release.sh first.' >&2 exit 2 @@ -24,152 +24,136 @@ grep -qF "$BUILD_ID" <<< "$GUI_VERSION" || { VERSION=$(sed -E 's/^CS-R Intake ([^ ]+).*/\1/' <<< "$GUI_VERSION") TMP=$(mktemp -d) -cleanup() { - find "$TMP" -depth -delete 2>/dev/null || true -} +cleanup() { find "$TMP" -depth -delete 2>/dev/null || true; } trap cleanup EXIT +APP="$TMP/app" +mkdir -p "$APP/bin" "$APP/etc" "$APP/lib" "$APP/python/bin" "$APP/python/lib/python/lib-dynload" "$(dirname "$OUT")" +install -m755 "$GUI" "$APP/intake-gui" +install -m755 "$SCANNER" "$APP/pc-intake" +install -m644 "$MANIFEST" "$APP/release.json" +python3 "$ROOT/scripts/release-manifest.py" --verify-release "$APP" -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" +PYTHON=$(readlink -f "$(command -v python3)") +PYTHON_STDLIB=$(python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))') +install -m755 "$PYTHON" "$APP/python/bin/python3" +cp -a "$PYTHON_STDLIB/lib-dynload/." "$APP/python/lib/python/lib-dynload/" + +for tool in nmcli id timeout curl sh; do + source=$(command -v "$tool" 2>/dev/null || true) + [[ -n $source ]] || { echo "Required portable runtime tool is missing: $tool" >&2; exit 2; } + install -m755 "$(readlink -f "$source")" "$APP/bin/$tool" +done +for tool in smartctl stress-ng ffmpeg ffplay arecord aplay speaker-test lsusb xdg-open mpv lspci glmark2 memtester; do + source=$(command -v "$tool" 2>/dev/null || true) + [[ -z $source ]] || install -m755 "$(readlink -f "$source")" "$APP/bin/$tool" +done +for certificates in /etc/ssl/certs/ca-certificates.crt /etc/ca-certificates/extracted/tls-ca-bundle.pem; do + if [[ -f $certificates ]]; then install -m644 "$certificates" "$APP/etc/ca-certificates.crt"; break; fi +done + +python3 - "$PYTHON_STDLIB" "$APP/python/lib/python.zip" <<'PY' +from pathlib import Path +import sys, zipfile +source = Path(sys.argv[1]) +output = Path(sys.argv[2]) +excluded = {'site-packages', '__pycache__', 'test', 'tests', 'idlelib', 'tkinter', 'turtledemo', 'ensurepip', 'venv'} +with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for path in sorted(source.rglob('*.py')): + relative = path.relative_to(source) + if any(part in excluded for part in relative.parts): + continue + archive.write(path, relative) +PY + +python3 - "$APP" <<'PY' +from pathlib import Path +import re, shutil, subprocess, sys +app = Path(sys.argv[1]) +lib = app / 'lib' +seeds = [app/'intake-gui', app/'pc-intake', app/'python/bin/python3', *sorted((app/'bin').iterdir()), *sorted((app/'python/lib/python/lib-dynload').glob('*.so'))] +optional = ['libX11.so.6','libXcursor.so.1','libXi.so.6','libXrandr.so.2','libxcb.so.1','libXau.so.6','libXdmcp.so.6','libwayland-client.so.0','libxkbcommon.so.0','libEGL.so.1','libGL.so.1','libGLX.so.0','libOpenGL.so.0','libnss_files.so.2','libnss_dns.so.2','libresolv.so.2'] +cache = subprocess.run(['ldconfig','-p'], text=True, check=True, capture_output=True).stdout +for name in optional: + match = re.search(r'^\s*'+re.escape(name)+r'.* => (\S+)$', cache, re.M) + if match: seeds.append(Path(match.group(1))) +queue, seen = list(seeds), set() +while queue: + binary = queue.pop() + try: key = binary.resolve() + except OSError: continue + if key in seen or not key.is_file(): continue + seen.add(key) + result = subprocess.run(['ldd', str(key)], text=True, capture_output=True) + for line in result.stdout.splitlines(): + paths = re.findall(r'(/[^\s()]+)', line) + for value in paths: + dependency = Path(value) + if dependency.exists(): queue.append(dependency) +for source in sorted(seen): + if source.is_relative_to(app.resolve()): continue + destination = lib / source.name + if not destination.exists(): shutil.copy2(source, destination) +loader = Path('/usr/lib/ld-linux-x86-64.so.2') +if not loader.exists(): loader = Path('/lib64/ld-linux-x86-64.so.2').resolve() +shutil.copy2(loader, lib/'ld-linux-x86-64.so.2') +PY contains_credentials=0 if [[ -f $BUNDLE_CONFIG/inventory-api-key && -f $BUNDLE_CONFIG/inventory-server.crt ]]; then key=$(tr -d '\r\n' < "$BUNDLE_CONFIG/inventory-api-key") - [[ -n $key && $key != *[[:space:]]* ]] || { - echo 'Bundled inventory API key is empty or malformed.' >&2 - exit 2 - } + [[ -n $key && $key != *[[:space:]]* ]] || { echo 'Bundled API key is malformed.' >&2; exit 2; } openssl x509 -in "$BUNDLE_CONFIG/inventory-server.crt" -noout -checkend 0 >/dev/null || { - echo 'Bundled inventory certificate is invalid or expired.' >&2 - exit 2 + echo 'Bundled inventory certificate is invalid or expired.' >&2; exit 2; } - mkdir -m700 "$TMP/app/default-config" - install -m600 "$BUNDLE_CONFIG/inventory-api-key" "$TMP/app/default-config/inventory-api-key" - install -m600 "$BUNDLE_CONFIG/inventory-server.crt" "$TMP/app/default-config/inventory-server.crt" - printf '%s\n' '{"url":"https://192.168.178.17"}' > "$TMP/app/default-config/inventory.json" - chmod 600 "$TMP/app/default-config/inventory.json" + mkdir -m700 "$APP/default-config" + install -m600 "$BUNDLE_CONFIG/inventory-api-key" "$APP/default-config/inventory-api-key" + install -m600 "$BUNDLE_CONFIG/inventory-server.crt" "$APP/default-config/inventory-server.crt" + printf '%s\n' '{"url":"https://192.168.178.17"}' > "$APP/default-config/inventory.json" + chmod 600 "$APP/default-config/inventory.json" contains_credentials=1 elif [[ -e $BUNDLE_CONFIG/inventory-api-key || -e $BUNDLE_CONFIG/inventory-server.crt ]]; then - echo 'Both inventory-api-key and inventory-server.crt are required for bundled configuration.' >&2 + echo 'Both inventory-api-key and inventory-server.crt are required.' >&2 exit 2 fi -( - cd "$TMP/app" - find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS -) -tar -C "$TMP/app" -czf "$TMP/payload.tar.gz" . -cat > "$TMP/launcher" </dev/null 2>&1) - } - if ! bundle_valid; then - if [ -e "\$app_dir" ]; then - find "\$app_dir" -depth -delete 2>/dev/null || true - fi - temp_dir=\$cache_root/.extract-\$BUILD_ID-\$\$ - mkdir -p "\$temp_dir" - trap 'find "\$temp_dir" -depth -delete 2>/dev/null || true' EXIT HUP INT TERM - tail -n +"\$PAYLOAD_LINE" "\$0" | gzip -dc | tar -xf - -C "\$temp_dir" - (cd "\$temp_dir" && sha256sum -c SHA256SUMS >/dev/null) - mkdir -p "\$cache_root" - if ! mv -T "\$temp_dir" "\$app_dir" 2>/dev/null; then - find "\$temp_dir" -depth -delete 2>/dev/null || true - fi - trap - EXIT HUP INT TERM - fi - bundle_valid -} - -case \${1:-} in - --version) - printf 'CS-R Intake %s · %s\n' "\$VERSION" "\$BUILD_ID" - exit 0 - ;; - --check-bundle) - extract_bundle - printf 'Standalone bundle verified: %s\n' "\$BUILD_ID" - exit 0 - ;; -esac - -extract_bundle -data_dir=\${XDG_DATA_HOME:-\${HOME:-\$PWD}/.local/share}/csr-intake -expect_data=0 -has_data=0 -for argument in "\$@"; do - 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 -} - -if [ -d "\$app_dir/default-config" ]; then - config_dir=\$data_dir/config - mkdir -p "\$config_dir" - chmod 700 "\$config_dir" - for name in inventory.json inventory-api-key inventory-server.crt; do - target=\$config_dir/\$name - if [ ! -e "\$target" ]; then - install -m600 "\$app_dir/default-config/\$name" "\$target" - fi - done -fi - -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) +python3 - "$APP" "$TMP/payload.gz" "$TMP/payload.manifest" <<'PY' +from pathlib import Path +import gzip, os, struct, sys +root, output, manifest = map(Path, sys.argv[1:]) +files = sorted(path for path in root.rglob('*') if path.is_file()) +lines=[] +with output.open('wb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', compresslevel=9, mtime=0) as archive: + archive.write(b'CSRPKG3\0') + for path in files: + name = path.relative_to(root).as_posix().encode() + data = path.read_bytes() + mode = path.stat().st_mode & 0o777 + archive.write(struct.pack(' "$OUT" -if [[ $contains_credentials -eq 1 ]]; then - chmod 700 "$OUT" -else - chmod 755 "$OUT" +file "$OUT" | grep -q 'static-pie linked' || { echo 'Launcher is not statically linked.' >&2; exit 2; } +if ldd "$OUT" 2>&1 | grep -qv 'statically linked\|not a dynamic executable'; then + echo 'Launcher unexpectedly depends on host libraries.' >&2; exit 2 fi "$OUT" --check-bundle -if [[ $contains_credentials -eq 1 ]]; then - echo "Standalone executable created with inventory configuration: $OUT" -else - echo "Standalone executable created: $OUT" -fi +echo "Standalone ELF created: $OUT" diff --git a/scripts/release-manifest.py b/scripts/release-manifest.py index c064d81..f8d4d22 100644 --- a/scripts/release-manifest.py +++ b/scripts/release-manifest.py @@ -19,6 +19,9 @@ 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()) + launcher = ROOT / 'launcher' + paths.extend((launcher / 'src').rglob('*.rs')) + paths.extend(p for p in [launcher / 'Cargo.toml', launcher / 'Cargo.lock'] if p.exists()) paths.extend(p for p in (ROOT / 'scripts').iterdir() if p.is_file()) h = hashlib.sha256() for path in sorted(paths):