3 · URG-Cloud¶
Danger
Die Zusage, an der sich jede Zeile dieses Teils messen lassen muss: Kein Fahrbefehl aus der Cloud. Nie. Aus dieser Richtung nimmt das Gerät genau zwei Dinge an — eine fassungsfreigabe und eine sperre. Alles andere wird verworfen und protokolliert, auch aus einer gültigen Verbindung mit gültigem Zertifikat.
Die Sperre ist die einzige erlaubte Richtung nach unten, weil sie nur wegnimmt: sie hält an, sie fährt nicht. Diese Eigenschaft hat nur, wer sie nirgends aufweicht — nicht „nur für die Fernwartung", nicht „nur im Prüfstand". Wer im Empfang einen dritten Fall hinzufügt, hebt die Zusage auf, mit der dieses Gerät verkauft wird.
Zweiter Grundsatz: die Cloud ist ein Zusatz, nie eine Voraussetzung. Ein Roboter ohne Internet fährt in vollem Funktionsumfang weiter, puffert seine Meldungen und reicht sie nach.
3.1 Verbindung¶
| Punkt | Wert |
|---|---|
| Träger | AWS IoT Core über MQTT, TLS beidseitig, X.509 je Gerät |
clientId |
= Thing-Name = Seriennummer des Fahrgestells |
| Anschluss | 8883; bei einer Firewall, die das sperrt, 443 mit der von AWS verlangten ALPN-Kennung x-amzn-mqtt-ca |
| QoS | 1 in beide Richtungen |
| Wurzelzertifikat | ohne eigene Datei die Wurzeln von Node (Amazon Root CA 1 ist darin). rejectUnauthorized bleibt in jedem Fall an |
| Wiederverbinden | selbst gesteuert: 1 s, 5 s, 15 s, 60 s, 300 s, jeweils mit ±20 % Streuung — mehrere Roboter am selben WLAN dürfen nach einer Störung nicht in derselben Sekunde anklopfen |
| Puffer | Meldungen, die nicht hinausgehen, landen in daten/wolke-puffer.jsonl und werden beim nächsten Verbinden in der ursprünglichen Reihenfolge nachgereicht. Bei Überlauf (5000) fliegt die älteste Kennzahl, nie die älteste Störung |
3.2 Themenbaum je Gerät¶
Alles, was ein Roboter sagen oder hören darf, liegt unterhalb eines einzigen Pfades:
urg/<umgebung>/geraet/<seriennummer>/<art>
<umgebung> ist pruef oder betrieb. <seriennummer> ist der Thing-Name, und den setzt AWS über die Richtlinienvariable ${iot:Connection.Thing.ThingName} durch. Was im Pfad steht, hat AWS erzwungen; was im Rumpf steht, hat der Absender selbst geschrieben und ist eine Behauptung. Alle Regeln in der Cloud holen die Seriennummer deshalb mit topic(4) aus dem Pfad und niemals aus dem Rumpf.
| Richtung | Thema | Inhalt | Was in der Cloud damit geschieht |
|---|---|---|---|
| Gerät → Cloud | …/zustand |
Zustand, Fassungen | Lambda schreibt die Zeile des Gerätes fort (nur UpdateItem, kein Anlegen) |
| Gerät → Cloud | …/kennzahlen |
viel, gleichförmig | Firehose, spätere Auswertung |
| Gerät → Cloud | …/stoerung |
Störung | SNS — ein Mensch soll es merken |
| Gerät → Cloud | …/karte |
{vorgang:"gesichert", archivId, name, bytes, pruefsumme} |
Vermerk |
| Gerät → Cloud | …/karte/anfrage |
Steckbrief einer Kartensicherung, siehe 3.7 | Lambda antwortet mit vorsignierten Ablageadressen |
| Gerät → Cloud | …/update/rueckroll |
Rückrollmeldung, siehe 3.6 | Lambda zählt Geräte und sperrt notfalls die Fassung |
| Cloud → Gerät | …/befehl |
nur fassungsfreigabe und sperre |
alles andere verwirft das Gerät |
| Cloud → Gerät | …/+/antwort |
Antwort auf eine eigene Frage, zugeordnet über anfrageId |
Antwort ohne offene Frage wird verworfen |
| beidseitig | $aws/things/<name>/jobs/… |
IoT Jobs; bestellt wird notify-next |
siehe 3.5 |
| beidseitig | $aws/things/<name>/shadow/… |
Geräteschatten | trägt u. a. die laufende Fassung — sie passt nicht in die drei durchsuchbaren Merkmale des Thing-Typs |
Umschlag jeder Meldung nach oben: {art, zeit, inhalt}. zeit ist die Uhr des Roboters; die Cloud übernimmt sie bewusst nicht als Empfangszeit — ein Gerät puffert bei Netzausfall und reicht nach, und seine Uhr kann danebenliegen. Wann etwas gehört wurde, weiss nur die Cloud; wann es gemessen wurde, steht im Inhalt.
3.3 Was das Gerät darf — aus der IoT-Richtlinie¶
| Recht | Worauf | Warum genau so |
|---|---|---|
iot:Connect |
client/${iot:Connection.Thing.ThingName} |
Die wichtigste Zeile der ganzen Richtlinie. Ohne sie dürfte jedes gültige Gerätezertifikat unter jeder clientId verbinden — alle Roboter teilen sich dieselbe Richtlinie. Ein einziges übernommenes Gerät (und ein Roboter steht in einem Krankenhausflur, nicht in einem Rechenzentrum) könnte sich dann als jeder andere ausgeben: fremde Zustandsmeldungen fälschen, fremde Update-Aufträge abholen, eine fremde Verbindung abwerfen (MQTT trennt bei gleicher clientId den älteren Teilnehmer). Die gesamte Rechtetrennung fällt auf diese eine Bindung zurück, weil dieselbe Variable auch in allen Themenpfaden steckt. Nebenwirkung, die gewollt ist: die Variable ist nur belegt, wenn das Zertifikat an einem Ding hängt — ein Zertifikat ohne Registrierung kann sich gar nicht verbinden |
iot:Publish |
urg/<umgebung>/geraet/<name>/*, $aws/things/<name>/jobs/*, $aws/things/<name>/shadow/* |
Kein Platzhalter weiter oben im Baum, also kein Weg in den Zweig eines anderen Roboters oder in einen Verwaltungspfad. Die feste Umgebung schliesst zusätzlich aus, dass ein Prüfgerät in die Betriebsumgebung hineinredet |
iot:Subscribe |
dieselben Pfade als topicfilter/… |
Das Gerät bestellt örtlich mit #; in der Richtlinie steht *, weil ein ARN keine MQTT-Platzhalter kennt. Wichtig ist, was davor steht: der Pfad ist bis zum Gerätenamen fest |
iot:Receive |
dieselben Pfade | AWS prüft dieses Recht beim Zustellen jeder einzelnen Nachricht, nicht nur beim Bestellen — die Erlaubnis zum Bestellen reicht nicht |
iot:DescribeJobExecution, GetPendingJobExecutions, StartNextPendingJobExecution, UpdateJobExecution |
thing/<name> und job/* |
Für den HTTP-Weg der Job-Schnittstelle, wenn das Gerät Fortschritt meldet. job/* ist nötig, weil die Auftragskennung erst zur Laufzeit entsteht; die Bindung hält über thing/ |
Nicht in der Richtlinie und mit Absicht: Zertifikate. Ein Zertifikat gehört genau einem Gerät und entsteht bei dessen Erstanmeldung, nicht bei terraform apply. Ebenso hängt der Anmeldedienst das Zertifikat an Richtlinie und Ding — sonst müsste jeder neue Roboter durch ein terraform apply, und ein Techniker vor Ort könnte kein Gerät in Betrieb nehmen.
3.4 Das Job-Dokument, Feld für Feld¶
Der Vertrag zwischen Cloud und Gateway. Vorlage: terraform/update-vorlagen/job-dokument.json.tftpl; gelesen wird er von pi/ugo-aktualisierer.sh. Feste Werte setzt Terraform, ${aws:iot:parameter:…} setzt derjenige, der aus der Vorlage einen Job erzeugt.
| Feld | Beispiel | Bedeutung / Regel |
|---|---|---|
dokument_fassung |
1 |
ändert sich nur, wenn Felder wegfallen |
vorhaben |
"ugo-update" |
Erkennungsmarke; alles andere ignoriert das Gateway |
stufe |
"pruefgeraete" |
pruefgeraete | vorhut | zehntel | flotte, kommt aus der Vorlage |
schicht |
"server" |
Nur server oder kiosk. Alles andere lehnt das Gateway ab — das ist die Stelle, an der ein Tippfehler in einem Ausrollbefehl aufhört, gefährlich zu sein. Das Fahrgestell wird von diesem Weg nie angefasst. |
fassung |
"2026.08.19-3" |
was gespielt wird; Schlüssel der Tabelle fassungen |
fassung_vorher_erwartet |
"2026.08.12-1" |
leer = beliebig. Sonst: passt der Stand nicht, wird nicht gespielt |
paket.adresse |
vorsigniert, kurzlebig | aus $${aws:iot:s3-presigned-url:…}, Gültigkeit aus update_presigned_gueltig_sekunden |
paket.sha256 |
Prüfsumme des tar.zst |
wird vor dem Entpacken geprüft |
paket.signatur_b64 |
ECDSA-P256-Signatur | über die 32 Byte des SHA-256, nicht über das Paket. KMS liefert sie DER-kodiert — genau die Form, die openssl erwartet, also kein Umformschritt dazwischen |
paket.signatur_verfahren |
"ECDSA_SHA_256" |
— |
paket.signatur_schluessel |
alias/urg-<umgebung>-update-signatur |
Der öffentliche Teil liegt auf dem Gerät unter /etc/ugo/paket-signatur.pub (rund 100 Byte) und braucht danach nichts mehr — keine Kette, keine Uhr, kein Netz. Der Preis dieser Wahl: kein Widerruf, bewusst genommen |
paket.groesse_bytes |
Zahl als Zeichenkette | Vorabprüfung gegen freien Plattenplatz |
zeiger.dokument zeiger.paket |
vorsigniert, feste Schlüssel pakete/zeiger/<stufe>.json bzw. .tar.zst |
Zweiter Weg, siehe unten |
sperren.nicht_waehrend_fahrt |
true |
Ein Roboter, der gerade einen Auftrag fährt, wird nicht neu gestartet. Auch nicht „kurz" |
sperren.mindest_akku_prozent |
40 |
Das Gateway rechnet mit max(Dokument, Gerätevorgabe) — das Dokument darf strenger sein, nie lockerer |
sperren.nur_im_wartungsfenster |
true |
Fenster je Kunde in /etc/ugo/wartungsfenster.conf. Steht dort keins, wird gar nicht aktualisiert — kein Vorgabewert, keine Kulanz. Ein Fenster ist eine Zusage, die ein Mensch dem Kunden gegeben hat |
gesundheit.frist_sekunden |
60 |
danach rollt das Gerät von allein zurück |
gesundheit.pfad / .port |
"/" / 5173 |
Gesundheitsprüfung nach dem Umschalten |
rueckrollen.erlaubt |
true |
— |
rueckrollen.melde_thema |
urg/<umgebung>/geraet/{seriennummer}/update/rueckroll |
siehe 3.6 |
Die drei Sperren stehen im Dokument, sind aber keine Bitte. Das Gerät prüft sie selbst und meldet REJECTED mit Grund, wenn eine greift.
Warning
Warum dieselben Angaben zweimal im Dokument stehen. Fassung, Prüfsumme und Signatur ändern sich mit jeder Freigabe; eine Job-Vorlage ist dagegen fest. AWS füllt ${aws:iot:parameter:…} beim Anlegen eines Jobs über --document-parameters. Ungeprüft ist, ob AWS das auch für eigene (nicht AWS-verwaltete) Vorlagen zulässt — die Beschreibung von CreateJob schränkt documentParameters auf verwaltete Vorlagen ein, und ohne Konto lässt sich das nicht ausprobieren. Deshalb trägt das Dokument beides, und das Gerät nimmt, was da ist:
- Parameter — der kurze Weg, wenn AWS ihn zulässt.
- Zeiger — sind die Felder unersetzt geblieben, lädt das Gerät
zeiger.dokumentund nimmt die Angaben von dort.
Der Zeiger ist kein Vertrauensproblem: das Gerät glaubt ihm nur, was die Signatur trägt. Wer den Zeiger austauscht, ohne den Signaturschlüssel zu haben, erreicht genau eine Zeile im Protokoll (signatur-ungueltig).
3.5 Ausrollen in vier Stufen¶
| Stufe | Vorlage | Ziel | Wartezeit davor |
|---|---|---|---|
| 1 Prüffeld | update_stufe1_pruefgeraete |
Dinggruppe urg-<umgebung>-pruefgeraete — hier steht 8982504805371oD |
— |
| 2 Vorhut | update_stufe2_vorhut |
ein einzelnes abgesprochenes Kundengerät | 48 h |
| 3 Zehntel | update_stufe3_zehntel |
von Hand zusammengestellte Liste über mehrere Kunden — bewusst keine feste Gruppe | 72 h |
| 4 Flotte | update_stufe4_flotte |
Dinggruppe urg-<umgebung> |
eine Woche |
terraform apply legt nur die Vorlagen an. Der Job selbst — und damit das Ziel — entsteht von Hand über aws iot create-job. Das ist Absicht: „auf welche Geräte" soll nie ein Nebeneffekt sein. Die Wartezeiten kann Terraform nicht erzwingen; sie entstehen dadurch, dass ein Mensch den nächsten Job anlegt.
Die Job-Kennung muss mit urg-<umgebung>-update- beginnen: die Notbrems-Lambda bricht nur Jobs mit diesem Präfix ab, und ihre Rolle darf auch nur diese anfassen.
Warum IoT Jobs und nicht ein eigener Weg: der Roboter holt, niemand drückt. Kein offener Port beim Kunden. Ein Gerät, das eine Woche aus war, bekommt seinen Job beim nächsten Verbinden — ohne dass irgendwo von Hand eine Liste „noch offen" geführt wird.
3.6 Rückrollen¶
Kommt der Roboter nach dem Umschalten nicht binnen der Frist gesund hoch, hängt er den Verweis zurück, startet neu und meldet auf urg/<umgebung>/geraet/<seriennummer>/update/rueckroll:
{ "fassung": "2026.08.19-3", "schicht": "server", "grund": "gesundheit", "job_id": "urg-pruef-update-stufe1-…", "zeitpunkt": "…" }
Die Lambda trägt die Seriennummer in eine Menge ein. Ab drei verschiedenen Geräten wird gesperrt = true auf dem Posten (fassung + schicht) gesetzt und die laufenden Jobs dieser Fassung werden abgebrochen. Verschiedene Geräte, nicht drei Meldungen — ein Gerät in einer Schleife ist ein Gerätefehler, kein Fassungsfehler.
3.7 Kartensicherung — der Umweg über eine vorsignierte Adresse¶
Warum ein Umweg: MQTT trägt höchstens 128 KB je Nachricht; eine Carto-Karte eines Krankenhausflurs liegt darüber. Stückeln hiesse, eine eigene Wiederzusammensetzung samt Lücken- und Reihenfolgeproblem zu bauen — für eine Verbindung, die in einem Krankenhaus regelmässig abreisst.
- Gerät →
…/karte/anfrage: nur der Steckbrief —{vorgang:"ablageadressen", archivId, name, pruefsumme, dateien:[{name, bytes}], anfrageId}. Das ist klein und passt immer. - Lambda legt eine vorläufige Zeile an („erwartet") und antwortet auf
…/karte/antwortmit je einer vorsignierten PUT-Adresse, 15 Minuten gültig. Erlaubt sind genau drei Dateinamen:karte.pbstream,raster.png,beschreibung.json. Eine vorsignierte Adresse ist ein Schreibrecht — wer den Dateinamen bestimmen dürfte, bestimmte den Ort im Eimer. - Gerät lädt per HTTPS unmittelbar nach S3,
beschreibung.jsonzuletzt. Reisst die Verbindung ab, wird wiederholt; die Schlüssel bleiben dieselben. - S3 meldet die eingetroffene
beschreibung.jsonzurück; erst dieser Schritt setzt die Zeile auf „gesichert".
Warum die Beschreibung der Auslöser ist und nicht die Carto-Karte: sie ist die Marke für einen vollständigen Satz. Bricht es vorher ab, liegt oben Bruchstückhaftes ohne Beschreibung — sichtbar unvollständig statt scheinbar heil.
Unverändert Gesichertes wird übersprungen (Vergleich über die Prüfsumme). Das ist kein Geiz: manche Häuser hängen den Roboter an einen Mobilfunkstick.
3.8 Anmeldestrecke¶
Der Kernpunkt, um den sich das ganze Verfahren dreht: der private Schlüssel verlässt das Gerät nie. Der Roboter erzeugt sein Schlüsselpaar selbst und schickt nur eine Zertifikatsanfrage (CSR). Die Cloud ruft CreateCertificateFromCsr auf; AWS IoT unterschreibt mit seiner eigenen CA. Ein Einbruch in unsere Cloud gibt einem Angreifer damit keine Möglichkeit, sich als ein Roboter auszugeben — der Schlüssel lag dort nie. Ein Plattentausch am Roboter kostet dafür ein neues Zertifikat; das ist der bewusst in Kauf genommene, günstigere Preis.
Es wird ausdrücklich keine eigene Zertifizierungsstelle betrieben. Eine eigene CA hiesse einen CA-Schlüssel besitzen, sichern, drehen und im Ernstfall sperren — eine Aufgabe für eine Organisation mit Schlüsseltresor und Verfahren, nicht für ein Vorhaben, dessen Kern ist, dass ein Roboter auch ohne Cloud fährt.
| Schritt | Was passiert |
|---|---|
| 1 | Techniker gibt auf der Technikerseite den einmaligen Beitrittscode ein → POST /api/wolke {aktion:"anmelden", beitrittscode} |
| 2 | Das Gerät liest seine Seriennummer vom Fahrgestell, erzeugt Schlüssel und CSR |
| 3 | POST <WOLKE_ANMELDUNG_URL> mit {beitrittscode, seriennummer, typ, csr} |
| 4 | Der Dienst prüft Code und Rumpf, legt Ding, Zertifikat und Verknüpfungen an und antwortet |
| 5 | Das Gerät legt Zertifikat und Vermerk ab und verbindet sich neu |
Endpunkt: POST /anmeldung (HTTP-API, gedrosselt auf 10 Anfragen je Sekunde, Spitze 20). Es gibt genau diesen einen Pfad — keinen Zweig, keinen Parameter und keinen Fehlerfall, aus dem ein Fahrbefehl entstehen könnte.
| Feld der Anfrage | Prüfung |
|---|---|
beitrittscode |
Zeichenkette, höchstens 64 Zeichen |
seriennummer |
höchstens 64 Zeichen, ^[A-Za-z0-9_-]+$ — sie wird Dingname und Teil eines Themenpfades, es darf nichts Fremdes hinein |
typ |
^[A-Za-z0-9_-]{1,32}$. Das ist die Baureihe (uGo, uServe, …), nicht die Einsatzart. Bewusst eine Zeichenprüfung statt einer Liste: der Katalog der Baureihen ist offen, und eine geschlossene Liste hiesse, dass ein Roboter einer neuen Baureihe im Krankenhaus stehenbleibt, bis jemand eine Lambda neu ausrollt |
csr |
höchstens 8192 Zeichen, muss -----BEGIN (NEW )?CERTIFICATE REQUEST----- enthalten. Ein fertiges Zertifikat wird abgewiesen — wer eines einreicht, versucht etwas anderes als eine Anmeldung |
| Feld der Antwort | Bedeutung |
|---|---|
zertifikatPem |
das ausgestellte Zertifikat |
thingName |
= Seriennummer, zugleich die clientId |
kunde |
Kurzkennzeichen des Betreibers, kein Klarname |
iotEndpunkt |
MQTT-Endpunkt |
Fehlerverhalten, das bewusst so ist: unbekannter, abgelaufener und verbrauchter Code ergeben dieselbe Meldung mit 403. Wer Codes rät, darf aus der Antwort nicht lernen, ob er nah dran war; der Techniker vor Ort braucht die Unterscheidung nicht, sein nächster Schritt ist in allen drei Fällen derselbe. Der Ablauf wird zusätzlich geprüft, nicht nur dem TTL der Tabelle überlassen — DynamoDB löscht abgelaufene Einträge träge (bis 48 Stunden).
Wiederholung nach Abbruch: ein bereits verbrauchter Code wird noch einmal angenommen, wenn dieselbe Seriennummer mit demselben CSR-Fingerabdruck wiederkommt; dann wird dasselbe Zertifikat erneut herausgegeben, statt ein zweites auszustellen.
Auf dem Gerät gilt: ganz oder gar nicht. Schlägt ein Schritt fehl, bleibt kein halbes Zertifikat und kein halber Vermerk zurück. War das Gerät vorher schon angemeldet, wird an der vorhandenen Identität nichts angefasst — sonst nähme ein Tippfehler im Code einem laufenden Roboter seine Anmeldung. Der Beitrittscode wird nirgends protokolliert.
3.9 Ein AWS-Fallstrick, der teuer war¶
Danger
AWS IoT fasst beim Anlegen einer Job-Vorlage jede vollständig ausgeschriebene vorsignierte S3-Adresse tatsächlich an. Existiert das Objekt nicht, meldet AWS „Permission denied". BELEGT 20.08.2026.
Adressen, in denen ein ${aws:iot:parameter:…} steckt, werden übersprungen — sie sind zur Anlegezeit nicht auflösbar. Deshalb scheiterte nie die Paketadresse, sondern immer nur die beiden Zeiger, deren Schlüssel fest ausgeschrieben sind.
Der Beweis kam aus dem Fehler selbst: sobald die vier zeiger/<stufe>.json angelegt waren, nannte AWS plötzlich zeiger/<stufe>.tar.zst — gleiche Meldung, anderer Schlüssel. Die Lehre, teurer als nötig bezahlt: eine Fehlermeldung, die sich ändert, ist ein Messwert. Gesucht wurde stattdessen viermal an den Rechten, weil die Meldung von Rechten sprach.
Zweiter, unabhängiger Punkt an derselben Stelle: AWS prüft beim Anlegen einer Vorlage auch, ob die angegebene Rolle die vorsignierte Adresse überhaupt erzeugen darf — die Rechte müssen dann schon wirksam sein, nicht nur beschrieben. Terraform kannte nur die Kante zur Rolle, nicht zu deren eingebetteter Richtlinie, und legte drei von vier Vorlagen zu früh an. Behoben mit einem depends_on je Vorlage. Bleibt in einem frischen Konto derselbe Fehler, ist die Antwort schlicht ein zweites terraform apply: IAM ist „eventually consistent".