USB-AIO für automatische Geräteprüfung
Some checks failed
Tests / test (push) Has been cancelled

This commit is contained in:
2026-10-14 20:14:36 +02:00
commit 81f06cf6b4
40 changed files with 9956 additions and 0 deletions

22
scripts/build-iso.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/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."

13
scripts/build-release.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
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
cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml"
cargo build --locked --release --manifest-path "$ROOT/$component/Cargo.toml"
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"

62
scripts/create-profile.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/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"

17
scripts/db-import.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import argparse, datetime, json, pathlib, sqlite3
parser=argparse.ArgumentParser()
parser.add_argument('database', type=pathlib.Path)
parser.add_argument('report', type=pathlib.Path)
args=parser.parse_args()
report=json.loads(args.report.read_text(encoding='utf-8'))
asset=report['asset_id']
if not isinstance(asset,str) or not asset or len(asset)>64: raise ValueError('Invalid asset ID')
args.database.parent.mkdir(parents=True,exist_ok=True)
with sqlite3.connect(args.database,timeout=30) as db:
db.execute('PRAGMA journal_mode=DELETE')
db.execute('PRAGMA synchronous=FULL')
db.execute('CREATE TABLE IF NOT EXISTS scans (scan_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, scanned_at_unix INTEGER, imported_at_utc TEXT NOT NULL, tool_version TEXT, model TEXT, cpu TEXT, health_gate TEXT, json TEXT NOT NULL)')
db.execute('CREATE INDEX IF NOT EXISTS scans_asset ON scans(asset_id,scan_id DESC)')
db.execute('INSERT INTO scans(asset_id,scanned_at_unix,imported_at_utc,tool_version,model,cpu,health_gate,json) VALUES(?,?,?,?,?,?,?,?)', (asset,report.get('timestamp_unix'),datetime.datetime.now(datetime.timezone.utc).isoformat(),report.get('tool_version'),report.get('model'),report.get('cpu'),report.get('health_gate'),json.dumps(report,separators=(',',':'))))
print('Recorded',asset,'in',args.database)

10
scripts/db-view.py Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
import pathlib, sqlite3, sys
p=pathlib.Path(sys.argv[1])
if not p.is_file():
print('No database scans yet.'); sys.exit(0)
with sqlite3.connect(f'file:{p}?mode=ro', uri=True) as db:
print('Total scans:', db.execute('SELECT count(*) FROM scans').fetchone()[0])
print('Unique assets:', db.execute('SELECT count(DISTINCT asset_id) FROM scans').fetchone()[0])
for asset, model, gate, timestamp in db.execute('SELECT asset_id, model, health_gate, scanned_at_unix FROM scans ORDER BY scan_id DESC LIMIT 15'):
print(f'{asset:14} | {(model or "unknown")[:28]:28} | {gate or "UNKNOWN"} | {timestamp}')

80
scripts/install-usb.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic.
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
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 blkid sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs dd sync awk 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 >= 16*1024*1024*1024)) || { echo 'USB must be at least 16 GiB.' >&2; exit 2; }
iso_bytes=$(stat -c %s "$ISO")
((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&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"
partprobe "$DEVICE" || true
udevadm settle
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 [[ -b "$p3" ]] && break; sleep 1; done
[[ -b "$p3" ]] || { echo 'Partitions not visible after repartition.' >&2; exit 1; }
mkfs.vfat -F32 -n INTAKE_BOOT "$p2"
mkfs.ext4 -F -L INTAKE_DATA "$p3"
TMP=$(mktemp -d)
cleanup(){ umount "$TMP/boot" 2>/dev/null || true; umount "$TMP/data" 2>/dev/null || true; rm -rf "$TMP"; }
trap cleanup EXIT
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"
cp "$ISO" "$TMP/boot/pc-intake.iso"
# Copy video reference files separately under INTAKE_DATA/reference (optional).
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
# Ensure expected kernel layout exists *in the built ISO* before proceeding.
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/vmlinuz-linux'; then
echo 'ISO layout does not match GRUB config (kernel missing). Aborting bootloader setup.' >&2; exit 1
fi
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/initramfs-linux.img'; then
echo 'ISO layout does not match GRUB config (initramfs missing).' >&2; exit 1
fi
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.'

28
scripts/intake-session.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/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"

424
scripts/inventory-sync.py Normal file
View File

@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""Offline-first bridge with atomic server-side hardware registration.
Stdlib only; server assigns numbers and immutable reports stay unchanged.
"""
import argparse
import datetime as dt
import hashlib
import time
import fcntl
import json
import os
from pathlib import Path
import re
import sqlite3
import ssl
import socket
import uuid
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_DATA = Path('/mnt/intake-data')
def identity_value(value):
value = str(value or '').strip().casefold()
if value in ('', 'unknown', 'none', 'default string', 'to be filled by o.e.m.',
'not specified', 'system serial number', 'not applicable', 'n/a'):
return ''
if not value.strip('0f- '):
return ''
return value
def hardware_id(report):
raw_uuid = identity_value(report.get('system_uuid'))
try:
token = uuid.UUID(raw_uuid)
if token.int not in (0, (1 << 128) - 1):
return 'dmi-uuid:' + str(token)
except (ValueError, AttributeError):
pass
serial = identity_value(report.get('serial_number'))
vendor = identity_value(report.get('manufacturer'))
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.')
def hardware_aliases(report):
primary = hardware_id(report)
aliases = [primary]
try:
serial = hardware_id({**report, 'system_uuid': ''})
if serial not in aliases:
aliases.append(serial)
except ValueError:
pass
return aliases
def device_kind(report):
chassis = str(report.get('chassis_type', ''))
if chassis == '13':
return 'AiO'
if chassis in ('8', '9', '10', '11', '14', '30', '31', '32') or report.get('batteries'):
return 'Laptop'
return 'Desktop'
def map_fields(report):
values = {}
for name in ('cpu', 'model'):
value = report.get(name)
if isinstance(value, str) and value.strip() and value.upper() != 'UNKNOWN':
values[name] = value.strip()[:200]
# Physically installed RAM, not Linux's usable memory (affected by iGPU reservations).
ram = report.get('installed_ram_gib')
if isinstance(ram, (int, float)) and not isinstance(ram, bool) and 1 <= ram <= 2048:
values['ram_gb'] = round(ram)
disks = report.get('disks') or []
ssd, hdd = [], []
for disk in disks:
if disk.get('removable') is not False:
continue
gib = disk.get('size_gib')
if not isinstance(gib, (int, float)) or isinstance(gib, bool) or not 1 <= gib <= 100000:
continue
# The kernel rotating flag is not reliable on USB drives; USB disks are excluded above.
(hdd if disk.get('rotational') is True else ssd if disk.get('rotational') is False else []).append(gib)
if ssd:
values['ssd_gb'] = round(sum(ssd) * (1024 ** 3) / 1e9)
if hdd:
values['hdd_gb'] = round(sum(hdd) * (1024 ** 3) / 1e9)
return values
def init_db(db):
db.execute('PRAGMA busy_timeout=10000')
db.execute('CREATE TABLE IF NOT EXISTS device_links (hwid TEXT PRIMARY KEY, inventory_number INTEGER NOT NULL)')
db.execute('CREATE TABLE IF NOT EXISTS inventory_queue (report_sha256 TEXT PRIMARY KEY, report_path TEXT NOT NULL, asset_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT \'PENDING\', attempts INTEGER NOT NULL DEFAULT 0, last_attempt INTEGER, next_attempt INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT \'\')')
db.execute('CREATE TABLE IF NOT EXISTS inventory_sync (report_sha256 TEXT PRIMARY KEY, asset_id TEXT NOT NULL, inventory_number INTEGER NOT NULL, status TEXT NOT NULL, last_attempt_utc TEXT, response TEXT)')
def api_request(base, key, method, suffix, payload=None, timeout=12, ca_file=None):
body = None if payload is None else json.dumps(payload).encode('utf-8')
url = base.rstrip('/') + '/api/v1/' + suffix.lstrip('/')
request = urllib.request.Request(url, data=body, method=method, headers={
'Authorization': 'Api-Key ' + key,
'Accept': 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'pc-intake-usb/1.4',
})
context = ssl.create_default_context()
if ca_file:
context.load_verify_locations(cafile=str(ca_file))
with urllib.request.urlopen(request, timeout=timeout, context=context) as res:
return json.loads(res.read(1024 * 1024).decode('utf-8'))
def credentials(config):
base = config.get('url', '')
if not base: raise ValueError('Inventory URL is not configured; reports remain offline')
parsed = urllib.parse.urlparse(base)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
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'))
key = secret.read_text(encoding='utf-8').strip()
if not key:
raise ValueError('No API key configured')
return base, key
def connection_error(ex):
# Never include server responses, request headers, or raw URLs in errors.
if isinstance(ex, urllib.error.HTTPError):
hints = {401: 'API key missing, expired or invalid', 403: 'API key lacks permission',
404: 'Inventory API endpoint not found. Server update required for hardware registration', 409: 'Hardware identity or revision conflict. Check existing or archived records on the server'}
return f'HTTP {ex.code}: {hints.get(ex.code, "Inventory server rejected the request")}'
reason = ex.reason if isinstance(ex, urllib.error.URLError) else ex
if isinstance(reason, ssl.SSLCertVerificationError):
return 'TLS certificate verification failed. Check config/inventory-server.crt, server address and system clock.'
if isinstance(reason, ssl.SSLError):
return 'TLS connection failed. Check the server HTTPS configuration.'
if isinstance(reason, socket.gaierror):
return 'DNS lookup failed. Check Wi-Fi, DNS and inventory server address.'
if isinstance(reason, (TimeoutError, socket.timeout)):
return 'Connection timed out. Connect Wi-Fi and check the inventory server is reachable.'
if isinstance(reason, ConnectionRefusedError):
return 'Connection refused. Check the inventory server address, port and service.'
if isinstance(ex, urllib.error.URLError):
return 'Inventory server unreachable. Check Wi-Fi, network routing and server address.'
if isinstance(ex, FileNotFoundError):
return 'Inventory API key or certificate file is missing. Check the files in config/.'
if isinstance(ex, ValueError):
return str(ex)[:200]
return type(ex).__name__
def check_connection(config):
base, key = credentials(config)
tls = {'ca_file': config['ca_file']} if config.get('ca_file') else {}
api_request(base, key, 'GET', 'choices/', **tls)
return 'Inventory connection OK: HTTPS certificate and API key verified. No records uploaded.'
def sync_one(report_path, config, db, dry_run=False):
raw = report_path.read_bytes()
sha = hashlib.sha256(raw).hexdigest()
report = json.loads(raw)
asset = report['asset_id']
hwid = hardware_id(report)
values = map_fields(report)
done = db.execute('SELECT status FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
if done and done[0] == 'SYNCED':
row = db.execute('SELECT inventory_number FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
return 'ALREADY_SYNCED', row[0]
if not values:
return 'NO_VERIFIED_FIELDS', None
if dry_run:
print(json.dumps({'hwid': hwid, 'number': 'assigned by server', 'changes': values}, indent=2, ensure_ascii=False))
return 'DRY_RUN', None
base, key = credentials(config)
tls = {'ca_file': config['ca_file']} if config.get('ca_file') else {}
# Resolve atomically on the server; local asset labels never select a server record.
current = api_request(base, key, 'POST', 'devices/resolve/',
{'hwid': hwid, 'aliases': hardware_aliases(report), 'device_kind': device_kind(report), 'model': values.get('model', '')}, **tls)
number = current.get('number')
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ValueError('Server did not return a valid inventory number')
with db:
db.execute('INSERT INTO device_links(hwid,inventory_number) VALUES(?,?) ON CONFLICT(hwid) DO UPDATE SET inventory_number=excluded.inventory_number', (hwid, number))
revision = current.get('revision')
if not isinstance(revision, int) or revision < 1:
raise ValueError('Server did not return a valid revision')
# Reject suspicious identity collisions instead of overwriting another model.
existing_model = (current.get('model') or '').strip()
if existing_model and values.get('model') and existing_model.casefold() != values['model'].casefold():
raise ValueError(f'MODEL_MISMATCH: inventory #{number} is {existing_model!r}; scan is {values["model"]!r}')
# Avoid unnecessary revisions when all mapped values already match.
actual = {k: v for k, v in values.items() if current.get(k) != v}
if current.get('stage') not in (None, 'INTAKE'): actual = {}
if not actual:
status = 'SYNCED'
else:
updated = api_request(base, key, 'PATCH', f'devices/{number}/', {**actual, 'expected_revision': revision}, **tls)
if not isinstance(updated.get('revision'), int) or updated['revision'] <= revision:
raise ValueError('Server did not acknowledge the new revision; retry will recheck current values')
status = 'SYNCED'
now = dt.datetime.now(dt.timezone.utc).isoformat()
with db:
db.execute('INSERT INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,?,?,?) ON CONFLICT(report_sha256) DO UPDATE SET status=excluded.status,last_attempt_utc=excluded.last_attempt_utc,response=excluded.response', (sha, asset, number, status, now, 'OK'))
return status, number
def discover_queue(root, db):
"""Discover immutable reports even when no inventory connection is configured."""
for path in sorted((root / 'reports').glob('*/*/report.json')):
if not path.resolve().is_relative_to((root / 'reports').resolve()):
continue
try:
raw = path.read_bytes()
report = json.loads(raw)
asset = report['asset_id']
sha = hashlib.sha256(raw).hexdigest()
done = db.execute('SELECT status FROM inventory_sync WHERE report_sha256=?', (sha,)).fetchone()
with db:
db.execute('INSERT OR IGNORE INTO inventory_queue(report_sha256,report_path,asset_id,status) VALUES(?,?,?,?)', (sha, str(path.resolve()), asset, 'SYNCED' if done and done[0] == 'SYNCED' else 'PENDING'))
except (OSError, ValueError, KeyError):
continue
def process_queue(root, db, config, retry=False, selected=None, retry_seconds=60):
discover_queue(root, db)
failures = 0
rows = db.execute("SELECT report_sha256, report_path, attempts, next_attempt FROM inventory_queue WHERE status != 'SYNCED' ORDER BY report_path").fetchall()
for sha, raw_path, attempts, next_attempt in rows:
path = Path(raw_path)
if selected and path.resolve() != selected.resolve():
continue
if not retry and next_attempt > int(time.time()):
continue
stamp = int(time.time())
# Persist before the request; interrupted uploads can be retried with the same report hash.
with db:
db.execute("UPDATE inventory_queue SET status='UPLOADING', attempts=attempts+1, last_attempt=? WHERE report_sha256=?", (stamp, sha))
try:
if not path.resolve().is_relative_to((root / 'reports').resolve()):
raise ValueError('Report path outside reports directory')
if hashlib.sha256(path.read_bytes()).hexdigest() != sha:
raise ValueError('Queued report changed; original upload cancelled')
result, number = sync_one(path, config, db)
if result not in ('SYNCED', 'ALREADY_SYNCED'):
raise ValueError(result)
with db:
db.execute("UPDATE inventory_queue SET status='SYNCED', error='', next_attempt=0 WHERE report_sha256=?", (sha,))
if db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='scans'").fetchone():
db.execute("UPDATE scans SET sync_status='SYNCED', synced_inventory_id=? WHERE report_path=?", (str(number), str(path)))
print(f'{path.parent.parent.name}: SYNCED -> #{number}')
except (OSError, ValueError, KeyError, urllib.error.URLError) as ex:
failures += 1
# Error strings deliberately exclude URLs, API keys and raw server responses.
error = connection_error(ex)
delay = min(3600, retry_seconds * (2 ** min(attempts, 6)))
with db:
db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha))
print(f'{path.parent.parent.name}: {error}', file=sys.stderr)
return failures
def station_identity():
def read(name):
for folder in ['/sys/class/dmi/id', '/run/csr-intake']:
try:
value = (Path(folder)/name).read_text().strip()
if value: return value
except OSError: pass
return ''
return {'system_uuid':read('product_uuid'),'serial_number':read('product_serial'),
'manufacturer':read('sys_vendor'),'model':read('product_name'),'chassis_type':read('chassis_type')}
def station_config(root):
config=json.loads((root/'config/inventory.json').read_text())
config.setdefault('key_file',str(root/'config/inventory-api-key'))
if (root/'config/inventory-server.crt').exists():config.setdefault('ca_file',str(root/'config/inventory-server.crt'))
return config
def station_api(config,method,suffix,payload=None):
base,key=credentials(config)
return api_request(base,key,method,suffix,payload,**({'ca_file':config['ca_file']} if config.get('ca_file') else {}))
def station_resolve(config,report):
return station_api(config,'POST','devices/resolve/',{'hwid':hardware_id(report),'aliases':hardware_aliases(report),
'device_kind':device_kind(report),'model':report.get('model','')})
def station_save(path,value):
path.parent.mkdir(parents=True,exist_ok=True)
tmp=path.with_suffix('.new')
with tmp.open('w') as f:
json.dump(value,f,ensure_ascii=False);f.flush();os.fsync(f.fileno())
os.replace(tmp,path)
def queue_inspection(root,path,phase):
inspection=json.loads(path.read_text())
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.
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',''),
'notes':inspection.get('notes',''),'results':results}
digest=hashlib.sha256(json.dumps(body,sort_keys=True).encode()).hexdigest()
body['request_id']=str(uuid.uuid5(uuid.NAMESPACE_URL,'csr-inspection:'+inspection['asset']+':'+str(inspection['revision'])+':'+str(inspection['updated'])+':'+digest))
file=root/'outbox/inspections'/f"{inspection['updated']:020}-{inspection['revision']:020}-{body['request_id']}.json"
receipt=root/'outbox/sent'/file.name
if not file.exists() and not receipt.exists():station_save(file,{'identity':identity,'body':body})
return file
def flush_inspections(root,config):
latest=None
for path in sorted((root/'outbox/inspections').glob('*.json')):
event=json.loads(path.read_text());device=station_resolve(config,event['identity'])
body={**event['body'],'expected_revision':device['revision']}
latest=station_api(config,'POST',f"devices/{device['number']}/inspection/",body)
done=root/'outbox/sent'/path.name;done.parent.mkdir(parents=True,exist_ok=True)
os.replace(path,done)
return latest
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.
if action in ['boot','flush']:
for saved in sorted((root/'inspections').glob('*.json')):
inspection=json.loads(saved.read_text())
if inspection.get('sync_phase'):
queue_inspection(root,saved,inspection['sync_phase'])
# Enqueue before reading config/network so interruption and offline use preserve results.
if action in ['start','progress','complete','queue']:
if not report_path:raise ValueError('Inspection file is required')
phase={'start':'STARTED','progress':'IN_PROGRESS','complete':'COMPLETE'}.get(action)
if phase is None: phase=json.loads(report_path.read_text()).get('sync_phase') or 'IN_PROGRESS'
queue_inspection(root,report_path,phase)
if action=='queue':
print(json.dumps({'queued':True}))
return 0
(root/'database').mkdir(parents=True,exist_ok=True)
config=station_config(root)
if action in ['start','progress','complete','flush']:
flush_inspections(root,config)
device=station_resolve(config,station_identity())
else:
report=json.loads(report_path.read_text()) if report_path else station_identity()
device=station_resolve(config,report)
if action=='intake' and device['stage']=='INTAKE':
device=station_api(config,'POST',f"devices/{device['number']}/intake/",{
'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)})
if action == 'intake' and report_path:
raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest()
with sqlite3.connect(root/'database/intake.sqlite3') as db:
init_db(db);discover_queue(root,db)
db.execute("INSERT OR REPLACE INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,'SYNCED',?,'OK')",(sha,report.get('asset_id',''),device['number'],dt.datetime.now(dt.timezone.utc).isoformat()))
db.execute("UPDATE inventory_queue SET status='SYNCED',error='',next_attempt=0 WHERE report_sha256=?",(sha,))
if db.execute("SELECT name FROM sqlite_master WHERE name='scans'").fetchone():
db.execute("UPDATE scans SET sync_status='SYNCED',synced_inventory_id=? WHERE report_path=?",(str(device['number']),str(report_path)))
result={'device':device,'pending':len(list((root/'outbox/inspections').glob('*.json')))}
station_save(root/'database/station-device.json',result)
print(json.dumps(result))
return 0
def main():
parser = argparse.ArgumentParser(description='Offline inventory queue with server-assigned device numbers')
parser.add_argument('--data', type=Path, default=DEFAULT_DATA)
parser.add_argument('--station', choices=['boot','intake','start','progress','complete','flush','queue'])
parser.add_argument('--identity-json', help=argparse.SUPPRESS)
parser.add_argument('--report', type=Path)
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--list', action='store_true', help='Discover reports and print queue as JSON; no network requests')
parser.add_argument('--check', action='store_true', help='Verify TLS and API authentication without uploading')
parser.add_argument('--retry', action='store_true', help='Retry errors immediately, ignoring backoff')
args = parser.parse_args()
if args.identity_json:
try:
hwid = hardware_id(json.loads(args.identity_json))
print(json.dumps({'hwid': hwid, 'local_id': 'HW-' + hashlib.sha256(hwid.encode()).hexdigest()[:24]}))
return 0
except ValueError as ex:
print(str(ex), file=sys.stderr)
return 2
root = args.data.resolve()
(root / 'database').mkdir(parents=True, exist_ok=True)
# Cross-process lock prevents GUI/CLI concurrent PATCH requests.
with (root / 'database' / 'inventory-sync.lock').open('a') as lock:
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print('Another upload worker is running', file=sys.stderr)
return 2
if args.station:
try: return run_station(root,args.station,args.report)
except (OSError,ValueError,KeyError) as ex:
print(connection_error(ex),file=sys.stderr)
return 2
with sqlite3.connect(root / 'database' / 'intake.sqlite3', timeout=20) as db:
init_db(db)
discover_queue(root, db)
if args.list:
db.row_factory = sqlite3.Row
print(json.dumps([dict(row) for row in db.execute('SELECT q.*, s.inventory_number FROM inventory_queue q LEFT JOIN inventory_sync s USING(report_sha256) ORDER BY q.last_attempt DESC, q.report_path')]))
return 0
if args.dry_run:
paths = [args.report] if args.report else sorted((root / 'reports').glob('*/*/report.json'))
for path in paths: sync_one(path, {}, db, True)
return 0
config_path = root / 'config' / 'inventory.json'
try:
config = json.loads(config_path.read_text()) if config_path.exists() else {}
config.setdefault('key_file', str(root / 'config' / 'inventory-api-key'))
cert = root / 'config' / 'inventory-server.crt'
if cert.exists():
config.setdefault('ca_file', str(cert))
if args.check:
print(check_connection(config))
return 0
settings_path = root / 'config' / 'workshop.json'
settings = json.loads(settings_path.read_text()) if settings_path.exists() else {}
delay = max(10, min(3600, int(settings.get('retry_seconds', 60))))
return 1 if process_queue(root, db, config, args.retry, args.report, delay) else 0
except (OSError, ValueError, KeyError) as ex:
print(connection_error(ex), file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Source fingerprint and artifact verification; never touches USB devices."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import tomllib
ROOT = Path(__file__).resolve().parents[1]
COMPONENTS = {'gui': 'intake-gui', 'scanner': 'pc-intake', 'controller': 'intake-controller'}
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def source_id():
paths = []
for component in COMPONENTS:
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())
h = hashlib.sha256()
for path in sorted(paths):
h.update(str(path.relative_to(ROOT)).encode() + b'\0')
h.update(path.read_bytes())
return 'csr-' + h.hexdigest()[:20]
def create(path):
binaries = {}
for component, name in COMPONENTS.items():
package = tomllib.loads((ROOT / component / 'Cargo.toml').read_text())['package']
binary = ROOT / component / 'target/release' / name
binaries[name] = {'version': package['version'], 'sha256': digest(binary)}
build_id = source_id()
version = subprocess.check_output([ROOT / 'gui/target/release/intake-gui', '--version'], text=True).strip()
if build_id not in version:
raise ValueError('GUI source fingerprint does not match current sources; rebuild release')
manifest = {'schema_version': 1, 'build_id': build_id, 'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(), 'binaries': binaries}
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())
for name, spec in manifest['binaries'].items():
binary = root / 'usr/local/bin' / 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)
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']}")
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)
args = parser.parse_args()
if args.source_id: print(source_id())
elif args.create: create(args.create)
else: verify(args.verify_profile)
if __name__ == '__main__': main()

9
scripts/report-view.py Normal file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python3
import json,sys
r=json.load(open(sys.argv[1],encoding='utf8'))
for k in ('asset_id','tool_version','model','cpu','ram_gib','health_gate'):
print(f'{k}: {r.get(k,"UNKNOWN")}')
print('Disks:')
for d in r.get('disks',[]):print(' ',d.get('name'),d.get('model'),d.get('smart_health'))
print('Warnings:')
for c in r.get('concerns',[]):print(' -',c)