From e2870c2fda17e3685e0c874d5b10136f581475ae Mon Sep 17 00:00:00 2001 From: "denis.stoecklmeier" Date: Sun, 11 Oct 2026 22:19:57 +0200 Subject: [PATCH] Add authenticated device registration for USB intake --- README.md | 17 +++ docs/usb-appliance.md | 18 +++ docs/usb-intake.md | 13 ++ inventory/api.py | 39 +++++- .../migrations/0013_deviceregistration.py | 28 ++++ inventory/models.py | 8 ++ inventory/test_usb_intake.py | 120 ++++++++++++++++++ 7 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 docs/usb-appliance.md create mode 100644 docs/usb-intake.md create mode 100644 inventory/migrations/0013_deviceregistration.py create mode 100644 inventory/test_usb_intake.py diff --git a/README.md b/README.md index 77a6ee3..24023fa 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,23 @@ Passwort-Hashes und Kontoverwaltung sind absichtlich nicht Teil der API. Jede Feldänderung enthält in der Änderungshistorie sowohl das zugeordnete Konto als auch den Namen des verwendeten API-Clients. +## Geräte per Live-USB erfassen + +Für den Werkstattbetrieb gibt es einen [eigenständigen USB-Stick](docs/usb-appliance.md) +im separaten Ordner `../USB-Intake`: Nach Eingabe von Server-IP und API-Schlüssel +startet die Prüfung automatisch. Ergebnisse werden direkt hochgeladen oder +dauerhaft auf dem Stick gespeichert. Gespeicherte +Berichte lassen sich später gesammelt senden, auch per WLAN. Der Archiso-Build +enthält alle benötigten Programme und benötigt keine interne Festplatte. + +Laptops und Mini-PCs können ohne interne SSD/HDD von einem Ubuntu-Live-USB +gestartet, kurz auf CPU/RAM geprüft und über die API neu erfasst werden. +`../USB-Intake/intake.py` sammelt Hardwaredaten und speichert einen offline +nutzbaren Bericht; der Upload erstellt das Gerät mit automatisch vergebener +Nummer und Prüfbericht im Kommentar. Wiederholte Uploads desselben Berichts +erzeugen keine weiteren Geräte. Einrichtung und Ablauf stehen in der +[Live-USB-Anleitung](docs/usb-intake.md). + ## Backup und Wiederherstellung Backup-Verzeichnis anlegen und Skripte ausführbar machen: diff --git a/docs/usb-appliance.md b/docs/usb-appliance.md new file mode 100644 index 0000000..849c9a5 --- /dev/null +++ b/docs/usb-appliance.md @@ -0,0 +1,18 @@ +# Eigenständiger USB-Stick + +Das USB-Projekt liegt separat in **`../USB-Intake`** neben diesem Repository. +Die vollständige Anleitung steht in [USB-Intake/README.md](https://git.computerspende-regensburg.de/denis.stoecklmeier/USB-Intake). + +Auf dem Arch-Linux-Rechner im USB-Projektordner: + +```bash +sudo bash make-usb.sh /dev/sdb --erase +``` + +Der Befehl baut das Image und überschreibt den geprüften USB-Datenträger. +Beim ersten Boot werden Server-IP/HTTPS-Adresse und API-Schlüssel abgefragt. +Danach laufen Hardwareprüfung und Upload automatisch; ohne Verbindung bleiben +Berichte auf dem Stick für späteres Senden gespeichert. + +Dieses Inventory-Repository enthält weiterhin die API-Erweiterung einschließlich +Migration `0013_deviceregistration`, die auf dem Server bereitgestellt werden muss. diff --git a/docs/usb-intake.md b/docs/usb-intake.md new file mode 100644 index 0000000..356f4e9 --- /dev/null +++ b/docs/usb-intake.md @@ -0,0 +1,13 @@ +# USB-Erfassung + +Die eigenständige USB-Anwendung und alle Build-/Installationswerkzeuge wurden +nach [USB-Intake](https://git.computerspende-regensburg.de/denis.stoecklmeier/USB-Intake) neben diesem Repository verschoben. +Sie startet direkt vom Stick, fragt Server und API-Schlüssel ab und führt +Prüfung plus Upload/Offline-Speicherung automatisch aus. + +Für manuelle Diagnose enthält das separate Projekt weiterhin `intake.py` mit den +Unterbefehlen `collect`, `choices` und `upload` (`python3 intake.py --help`). + +Die zugehörige Server-API bleibt in diesem Repository. Migration `0013` speichert +Upload-Belege, sodass wiederholte Uploads desselben Berichts keine weiteren Geräte +erstellen. Benötigt wird ein aktiver API-Schlüssel mit Lese- und Schreibrecht. diff --git a/inventory/api.py b/inventory/api.py index e3e66e0..9bd12f8 100644 --- a/inventory/api.py +++ b/inventory/api.py @@ -1,6 +1,8 @@ from __future__ import annotations from collections.abc import Mapping +import hashlib +import json from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction @@ -9,9 +11,9 @@ from rest_framework import generics, serializers, status from rest_framework.response import Response from rest_framework.views import APIView -from .models import ChoiceValue, Device, Employee +from .models import AuditLog, ChoiceValue, Device, DeviceRegistration, Employee, NumberSequence from .query import SPECIFICATION_RELATIONS -from .services import update_device_field +from .services import add_audit, update_device_field class SecureAPIResponseMixin: @@ -121,6 +123,16 @@ class DeviceChangesSerializer(StrictSerializer): return attrs +class DeviceCreateSerializer(DeviceChangesSerializer): + request_id = serializers.UUIDField() + status = serializers.PrimaryKeyRelatedField( + queryset=ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True), + ) + device_type = serializers.PrimaryKeyRelatedField( + queryset=ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True), + ) + + class DevicePatchSerializer(DeviceChangesSerializer): expected_updated_at = serializers.DateTimeField(required=False, write_only=True) expected_revision = serializers.IntegerField(required=False, min_value=1, write_only=True) @@ -169,6 +181,29 @@ def apply_changes(device, changes, *, user, api_client, expected_updated_at=None class DeviceListView(SecureAPIResponseMixin, generics.ListAPIView): serializer_class = DeviceSerializer + @transaction.atomic + def post(self, request): + serializer = DeviceCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + values = dict(serializer.validated_data) + request_id = values.pop("request_id") + digest = hashlib.sha256(json.dumps(request.data, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + # Share the existing allocation lock, including across simultaneous retries. + NumberSequence.objects.select_for_update().get_or_create(name="device") + previous = DeviceRegistration.objects.select_related("device").filter(request_id=request_id).first() + if previous: + if previous.payload_hash != digest: + return Response({"detail": "request_id wurde bereits für andere Daten verwendet."}, status=409) + return Response(DeviceSerializer(previous.device).data, status=200) + try: + device = Device.objects.create(**values) + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.messages) + DeviceRegistration.objects.create(request_id=request_id, payload_hash=digest, device=device) + add_audit(request.user, device, AuditLog.Action.CREATE, + new=f"Gerät #{device.number}", api_client=request.auth.name) + return Response(DeviceSerializer(device).data, status=201) + def get_queryset(self): queryset = Device.objects.select_related( "status", "recipient", "location", "device_type", "employee", diff --git a/inventory/migrations/0013_deviceregistration.py b/inventory/migrations/0013_deviceregistration.py new file mode 100644 index 0000000..e902944 --- /dev/null +++ b/inventory/migrations/0013_deviceregistration.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.18 on 2026-10-11 19:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0012_allinonespecification_desktopspecification_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DeviceRegistration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('request_id', models.UUIDField(unique=True)), + ('payload_hash', models.CharField(max_length=64)), + ('device', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='registration', to='inventory.device')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/inventory/models.py b/inventory/models.py index 847c53f..3cb466c 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -102,6 +102,14 @@ class FieldDefinition(TimeStampedModel): return self.name +class DeviceRegistration(TimeStampedModel): + """Durable receipt so retrying a USB report cannot allocate another device.""" + + request_id = models.UUIDField(unique=True) + payload_hash = models.CharField(max_length=64) + device = models.OneToOneField("Device", on_delete=models.PROTECT, related_name="registration") + + class FieldOption(TimeStampedModel): field = models.ForeignKey(FieldDefinition, on_delete=models.CASCADE, related_name="options") label = models.CharField(max_length=120) diff --git a/inventory/test_usb_intake.py b/inventory/test_usb_intake.py new file mode 100644 index 0000000..3b05ff9 --- /dev/null +++ b/inventory/test_usb_intake.py @@ -0,0 +1,120 @@ +import json +import uuid +from datetime import timedelta +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.db import close_old_connections +from django.test import Client, TestCase, TransactionTestCase, skipUnlessDBFeature +from django.urls import reverse +from django.utils import timezone + +from .models import AuditLog, ChoiceValue, Device, DeviceRegistration, InventoryAPIKey, NumberSequence + + +class RegistrationTests(TestCase): + def setUp(self): + self.user = User.objects.create_user('usb-operator', is_superuser=True) + self.status = ChoiceValue.objects.create(kind='status', label='Neu') + self.kind = ChoiceValue.objects.create(kind='device_type', label='Laptop') + _, self.key = InventoryAPIKey.objects.create_key( + name='usb-intake', service_user=self.user, can_read=True, can_write=True, + expiry_date=timezone.now() + timedelta(days=1)) + self.payload = {'request_id': str(uuid.uuid4()), 'status': self.status.pk, + 'device_type': self.kind.pk, 'cpu': 'Test CPU', 'ram_gb': 16, + 'hwid': str(uuid.uuid4()), 'comment': 'CPU/RAM: PASS'} + + def post(self, payload=None, key=None): + return self.client.post(reverse('inventory-api:device-list'), + json.dumps(self.payload if payload is None else payload), + content_type='application/json', + HTTP_AUTHORIZATION=f'Api-Key {key or self.key}') + + def test_create_specs_audit_and_retry(self): + NumberSequence.objects.update_or_create(name='device', defaults={'last_value': 42}) + response = self.post() + self.assertEqual(response.status_code, 201, response.content) + device = Device.objects.get() + self.assertEqual(device.number, 43) + self.assertEqual(device.ram_gb, 16) + self.assertEqual(device.stage, Device.Stage.INTAKE) + self.assertIsNone(device.ssd_gb) + self.assertEqual(device.wifi, 'UNKNOWN') + audit = AuditLog.objects.get(device=device) + self.assertEqual(audit.api_client, 'usb-intake') + self.assertEqual(audit.user, self.user) + self.assertEqual(audit.action, AuditLog.Action.CREATE) + retry = self.post() + self.assertEqual(retry.status_code, 200) + self.assertEqual(retry.json()['number'], device.number) + self.assertEqual(Device.objects.count(), 1) + self.assertEqual(AuditLog.objects.count(), 1) + + def test_changed_retry_conflicts(self): + self.post() + self.assertEqual(self.post({**self.payload, 'ram_gb': 32}).status_code, 409) + self.assertEqual(Device.objects.get().ram_gb, 16) + + def test_rejects_missing_auth_readonly_and_viewer(self): + self.assertEqual(self.client.post(reverse('inventory-api:device-list'), self.payload).status_code, 401) + _, key = InventoryAPIKey.objects.create_key(name='read', service_user=self.user, can_read=True, + expiry_date=timezone.now() + timedelta(days=1)) + self.assertEqual(self.post(key=key).status_code, 403) + self.user.is_superuser = False + self.user.save() + self.user.profile.role = 'VIEWER' + self.user.profile.save() + self.assertEqual(self.post().status_code, 403) + self.assertFalse(Device.objects.exists()) + + def test_strict_validation_and_active_choices(self): + for changes in ({'status': self.kind.pk}, {'status': None}, {'device_type': None}, + {'ram_gb': -1}, {'number': 99}, {'stage': 'STORAGE'}, {'request_id': 'bad'}): + with self.subTest(changes=changes): + self.assertEqual(self.post({**self.payload, **changes}).status_code, 400) + for missing in ('status', 'device_type', 'request_id'): + self.assertEqual(self.post({k: v for k, v in self.payload.items() if k != missing}).status_code, 400) + self.kind.active = False + self.kind.save() + self.assertEqual(self.post().status_code, 400) + self.assertFalse(Device.objects.exists()) + + def test_failure_rolls_back_device_receipt_and_number(self): + NumberSequence.objects.update_or_create(name='device', defaults={'last_value': 42}) + with patch('inventory.api.add_audit', side_effect=RuntimeError('audit unavailable')): + with self.assertRaises(RuntimeError): + self.post() + self.assertFalse(Device.objects.exists()) + self.assertFalse(DeviceRegistration.objects.exists()) + self.assertEqual(NumberSequence.objects.get(name='device').last_value, 42) + + +class ConcurrentRegistrationTests(TransactionTestCase): + @skipUnlessDBFeature('has_select_for_update') + def test_simultaneous_retry_allocates_once(self): + user = User.objects.create_user('concurrent-usb', is_superuser=True) + _, key = InventoryAPIKey.objects.create_key(name='usb', service_user=user, can_read=True, can_write=True, + expiry_date=timezone.now() + timedelta(days=1)) + status = ChoiceValue.objects.create(kind='status', label='Neu') + kind = ChoiceValue.objects.create(kind='device_type', label='Desktop') + NumberSequence.objects.update_or_create(name='device', defaults={'last_value': 0}) + payload = {'request_id': str(uuid.uuid4()), 'status': status.pk, 'device_type': kind.pk} + barrier = Barrier(2) + + def submit(_): + close_old_connections() + try: + barrier.wait(timeout=10) + response = Client().post(reverse('inventory-api:device-list'), json.dumps(payload), + content_type='application/json', HTTP_AUTHORIZATION=f'Api-Key {key}') + return response.status_code, response.json()['number'] + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(submit, range(2))) + self.assertEqual(sorted(code for code, _ in results), [200, 201]) + self.assertEqual(len({number for _, number in results}), 1) + self.assertEqual(Device.objects.count(), 1)