Smart Products , UX
Implementierung eines Mender-Clients in C
Wenn Sie IoT-Geräte jeglicher Art entwickeln und vor allem einsetzen wollen, ist eine Funktion wichtiger als alle anderen: FOTA. Die Abkürzung steht für Firmware Over The Air und ermöglicht es Ihnen, die Firmware des Geräts zu aktualisieren, vor allem drahtlos. Sie fragen sich vielleicht, warum diese Funktion so wichtig ist? Sie wollen doch nur einen einfachen internetfähigen Schalter entwickeln, warum sollten Sie den jemals aktualisieren wollen?
Einleitung - Warum FOTA?
Es gibt mehrere Gründe, warum Updates und die Möglichkeit von Updates in den letzten Jahren stark an Bedeutung gewonnen haben. In erster Linie ist die Systemkomplexität in die Höhe geschnellt. Ein einfacher internetfähiger Switch enthält einen kompletten IP-Stack und höchstwahrscheinlich eine TLS-Implementierung. Auf der Anwendungsebene könnte ein MQTT-Client oder eine andere Art von Protokoll zum Einsatz kommen. Um TLS zu verwenden, benötigen Sie Zertifikate. Die meisten Zertifikate haben ein Ablaufdatum, und müssen deshalb erneuert werden. Es kann auch andere Gründe geben, warum Sie ein Zertifikat ersetzen wollen, zum Beispiel wenn es gestohlen wurde. Das Austauschen von Zertifikaten ist einer der guten Gründe, warum Sie die Möglichkeit haben sollten, Ihre Geräte zu aktualisieren.
Alle diese Funktionen (Protokolle, Sicherheit, IP…) werden von Bibliotheken bereitgestellt. Insgesamt wird die Anzahl der Codezeilen für dieses einfache Gerät in die Zehn- bis Hunderttausende gehen. Es ist anzunehmen, dass sich irgendwo ein oder mehrere Fehler verstecken. Wenn Ihr Gerät verkauft wird und im Einsatz ist, könnte jemand diesen Fehler finden, und plötzlich haben alle Ihre Geräte eine bekannte und dokumentierte Sicherheitslücke. Ein Angreifer kann zwar nur ein Gerät zum Absturz bringen, aber wenn Sie Pech haben, kann er Ihre gesamte Geräteflotte übernehmen und einen DDOS-Angriff auf Ihr Backend oder, noch schlimmer, auf das eines anderen durchführen.
Es gibt bestimmte Maßnahmen, mit denen Sie sich davor schützen können, dass ein solcher Angriff zu einem Albtraum wird, aber es ist fast unmöglich, diese Art von Fehlern ganz zu vermeiden. Ein Beispiel dafür ist die kürzlich entdeckte Schwachstelle in curl, einer weit verbreiteten Bibliothek und einem Tool, CVE-2023-38545. Laut der Website von curl gibt es weltweit 20 Milliarden curl-Installationen. Die bevorstehende EU RED-Verordnung wird außerdem vorschreiben, dass alle IoT-Geräte, die in der Europäischen Union verkauft werden, über eine Art von Funktion zur Aktualisierung ihrer Firmware verfügen müssen, und zwar genau mit dem Ziel, die Sicherheit der eingesetzten Geräte nach ihrer Herstellung zu erhöhen.
Nicht zuletzt möchten Sie vielleicht auch weitere Funktionen zu Ihren Geräten hinzufügen, nachdem sie verkauft wurden, entweder weil sie nicht rechtzeitig fertig geworden sind, weil Sie nach der Markteinführung eine großartige Idee hatten oder weil vielleicht ein Mitbewerber über Funktionen verfügt, die Ihre Geräte ebenfalls haben sollten.
Nachdem das “Warum?” beantwortet ist, erfahren Sie nun, wie wir eine bestehende Golang-Client-Bibliothek nach C portiert und auf kleinen Mikrocontrollern eingesetzt haben.
CMender
Im Jahr 2019 arbeiteten wir an einem ESP32-basierten, WiFi-fähigen Smart-Home-Gerät und benötigten eine Möglichkeit, die Firmware aus der Ferne zu aktualisieren. Jemand empfahl mender wegen seiner serverseitigen Funktionen, und es schien tatsächlich großartig zu sein. Das Problem war, dass der Client in Golang geschrieben war und es keine Möglichkeit gab, ihn auf unserer ressourcenbeschränkten MCU laufen zu lassen. Also haben wir unseren eigenen geschrieben.
Wir haben beschlossen, den Code als Open Source zu veröffentlichen und diesen Artikel zu schreiben, um einige Hintergrundinformationen zu liefern. Sie können den Code auf GitHub finden. Dieser Client wurde vor vielen Jahren entwickelt und in Betrieb genommen, aber auch wenn ein offizieller C++-Client in Arbeit ist, besteht möglicherweise noch Bedarf für unseren Client. Lesen Sie weiter, um herauszufinden, warum 🙂
Anforderungen an den Over-the-Air-Update-Client
Beginnen wir also mit den Grundlagen. Was soll der OTA-Client leisten und wie funktioniert er?
Keine Interferenzen
Der Client darf den “normalen” Code, der auf dem Gerät läuft, nicht beeinträchtigen. Umgekehrt gilt das Gleiche: Selbst wenn der “normale” Code defekt ist - z. B. wenn ein Fehler im Cloud-Client eine Verbindung verhindert - müssen wir in der Lage sein, die Firmware zu aktualisieren, um den Fehler zu beheben. Dies kann zwar nicht garantiert werden, aber es gibt einige Möglichkeiten, die Chancen zu Ihren Gunsten zu beeinflussen, z. B. indem Sie den OTA-Client in einem eigenen Thread laufen lassen, anstatt ihn in eine globale Ereignisschleife einzubinden.
Geschwindigkeit und Zuverlässigkeit
Ein Update sollte bei laufendem System installiert werden können, ohne die Benutzerfreundlichkeit zu beeinträchtigen. Mit anderen Worten: Wir wollen schnell auf die neueste Version umsteigen können. Wir müssen auch in der Lage sein, zur vorherigen Version zurückzukehren, wenn entweder der Bootloader die neue Firmware nicht laden kann oder die neue Firmware nicht richtig initialisiert werden kann. Beides lässt sich mit dem üblichen A/B-Partitionierungsschema erreichen. Wir haben einfach zwei Partitionen und installieren die neue Firmware in die inaktive Partition. Viele Systeme wie mcuboot oder der Bootloader von Espressif unterstützen dies bereits, so dass wir den cmender-Client in diese Systeme mit plattformspezifischem Code integrieren müssen.
Eine andere Form der Zuverlässigkeit besteht darin, die Wahrscheinlichkeit zu verringern, dass der Code während der Laufzeit versagt. Die Nichtverwendung von dynamischer Speicherzuweisung, es sei denn, man kennt die Anzahl und Größe zur Kompilierzeit nicht, ist ein gutes Beispiel dafür.
ROM- und RAM-Nutzung
Wir wollen den Code auf MCUs wie dem ESP32 oder sogar dem ESP8266 ausführen. Diese sind sowohl in der Flash- als auch in der RAM-Größe begrenzt. Das bedeutet, dass der Code klein gehalten werden muss und der RAM-Speicher sparsam verwendet werden muss. Es ist auch eine gute Idee, Daten, die zur Laufzeit nicht geändert werden müssen, mit dem const-Schlüsselwort zu markieren. Dies reduziert den RAM-Bedarf auf einem System, das Daten aus dem Flash lesen und ausführen kann, da der Lader keine schreibgeschützten Variablen vom Flash in den RAM kopieren muss.
Abhängigkeiten
In diesem Blog-Beitrag haben wir ein paar Mal von “plattformspezifischem Code” gesprochen. Was wir damit meinen, ist, dass wir alles, was plattformspezifisch ist, in eine austauschbare Plattform-Bibliothek innerhalb des cmender-Clients abstrahiert haben. Dies ist sehr nützlich, damit der Client auf so vielen Plattformen wie möglich funktioniert, ohne dass der Code für all diese Plattformen in die Kernlogik des Clients aufgenommen werden muss. Beispiele hierfür sind das Laden/Speichern von Daten oder das Protokollieren von Nachrichten auf der Konsole.
Disclaimer
tinygo
Sie fragen sich vielleicht, warum wir den ursprünglichen Client für unsere MCU nicht mit tinygo kompiliert haben. Nun, erstens kannten wir tinygo damals noch nicht und haben es bis heute nicht benutzt. Wir mussten uns auch die RAM- und Flash-Nutzung von tinygo-Programmen genau ansehen, da wir aufgrund der Anforderungen der restlichen Software, die auf dem Gerät läuft, darin SEHR eingeschränkt waren. Das A/B-Partitionsschema, das für FOTA benötigt wird, hat dabei sicherlich nicht geholfen.
Der offizielle C++-Client
Damals gab es ihn noch nicht, und zum Zeitpunkt der Erstellung dieses Artikels gab es noch keine stabile Version. Damals gab es tatsächlich Diskussionen über C-Clients, aber sie kamen nur sehr langsam voran und niemand hatte bisher begonnen, Code zu schreiben. Die Firma hat viele Kunden mit sehr unterschiedlichen Bedürfnissen und hat deshalb einen größeren Arbeitsaufwand. Für unseren Anwendungsfall konnten wir jedoch innerhalb von ein paar Wochen einen funktionierenden Client schreiben. Danach hat es einige Monate gedauert, bis wir ihn zur Produktionsreife gebracht haben, indem wir ihn getestet und Unit-Tests geschrieben haben.
Davon abgesehen sieht es nicht so aus, als ob der aktuelle C++-Client ohne Linux und CMake einsatzbereit wäre - er ist also nicht plattformunabhängig. Das bedeutet, dass wir eine ganze Menge an dessen Code arbeiten müssen. Außerdem ist der Großteil unserer MCU-basierten Arbeit komplett in C geschrieben, und eine C++-Komponente hinzuzufügen ist nicht ideal. Zudem scheinen diese Komponenten die dynamische Speicherzuweisung und libstdc++ zu nutzen, was auf unseren Geräten zu Ressourcen- und Zuverlässigkeitsproblemen führen könnte.
Version
Die Entwicklung liegt viele Jahre zurück, daher basiert alles auf mender 1.5. Einige Aspekte können sich seither geändert haben, daher sollte man dies im Hinterkopf behalten.
Wo soll man überhaupt anfangen?
Indem man die Device API documentation liest. Dort können wir sehen, dass die API eigentlich sehr einfach ist. Wir müssen uns mit gerätespezifischen Anmeldeinformationen authentifizieren, wir müssen Statusaktualisierungen über die Inventar-API bereitstellen und wir müssen nach Aktualisierungen fragen. Einfach, oder? Nun ja, leider nur, wenn Sie keine der Funktionen nutzen wollen, die den offiziellen Client so großartig machen. Dinge wie Rollback oder alle Arten von Wettlaufbedingungen wie das Löschen einer Bereitstellung vom Server, während der Client sie installiert. In dem Code steckt eine Menge Wissen und Erfahrung, und wenn wir bei Null anfangen und jede Funktion einzeln implementieren würden, könnten wir nicht innerhalb weniger Monate einen produktionsreifen Client erstellen.
Manuelle Transpilierung
Das mag zunächst verrückt klingen, ist aber erstaunlich einfach. Das liegt daran, dass C und golang sehr ähnliche Sprachen sind. Keine von beiden unterstützt OOP auf Sprachebene (vor allem nicht Vererbung), die beiden unterstützen keine Ausnahmen, also machen Sie das typische if (err) { return err; } und sie haben beide eine sehr ähnliche Syntax. Schauen wir uns ein Beispiel an:
GO
func (m *MenderAuthManager) GenerateKey() error {
if err := m.keyStore.Generate(); err != nil {
log.Errorf("failed to generate device key: %v", err)
return errors.Wrapf(err, "failed to generate device key")
}
if err := m.keyStore.Save(); err != nil {
log.Errorf("failed to save device key: %s", err)
return NewFatalError(err)
}
return nil
}
C
mender_err_t mender_authmgr_generate_key(struct mender_authmgr *m) {
mender_err_t err;
err = mender_keystore_generate(m->keystore);
if (err) {
LOGE("failed to generate device key: %u", err);
return err;
}
err = mender_keystore_save(m->keystore);
if (err) {
LOGE("failed to save device key: %u", err);
return MENDER_ERR_FATAL(err);
}
return MERR_NONE;
}
Der Prozess läuft also folgendermaßen ab:
Konvertieren Sie die Syntax
Variablen definieren
Log-Anweisungen umwandeln
Konvertieren von Rückgabeanweisungen
mender_err_t ist ein uint32_t und wir verwenden das höchstwertige Bit, um anzuzeigen, ob der Fehler schwerwiegend ist oder nicht. Wir müssen auch eine neue Enum-Variante für jeden Fall erstellen, da wir keine Strings in Rückgabewerten speichern wollen.
Ergebnisse
Das haben wir für die wichtigsten Teile wie state.go gemacht und es hat sehr gut funktioniert. Der Code sieht in Bezug auf die Datentypen etwas seltsam aus, weil wir eine Menge “Interface”- und Callback-Zeiger weitergeben und speichern müssen. Glücklicherweise wird das alles statisch zugewiesen, so dass wir uns keine Gedanken über Lebenszeiten machen müssen. Allerdings endete dies in wunderschönem Code wie diesem 🙈 :
mender_create(
&mender,
&store,
&authmgr,
&stack,
&client,
&dev,
&iv_data,
CONFIG_PROJECT_FIRMWARE_VERSION,
CONFIG_MENDER_DEVICE_TYPE,
CONFIG_MENDER_SERVER_URL,
CONFIG_MENDER_UPDATE_POLL_INTERVAL,
get_earliest_update_time,
CONFIG_MENDER_INVENTORY_POLL_INTERVAL,
CONFIG_MENDER_RETRY_POLL_INTERVAL);
Unit-Tests
Der ursprüngliche Client hat eine ganze Reihe nützlicher Unit-Tests. Leider waren sie nicht flexibel genug, um sie gegen unseren C-Client testen zu können. Stattdessen haben wir sie alle in C umgeschrieben. GOs Test-Framework unterscheidet sich zu sehr von cmocka, so dass eine Transpilierung hier nicht in Frage kam. Die Tests sind stark Mock-basiert, also mussten wir auch eine Menge davon schreiben. Mit cmocka ist das einfach, aber eine Menge langweiliger Copy-and-Paste-Arbeit.
Hier ist ein Beispiel: GO
func TestStateInventoryUpdate(t *testing.T) {
ius := inventoryUpdateState
ctx := new(StateContext)
s, _ := ius.Handle(ctx, &stateTestController{
inventoryErr: errors.New("some err"),
})
assert.IsType(t, &CheckWaitState{}, s)
s, _ = ius.Handle(ctx, &stateTestController{})
assert.IsType(t, &CheckWaitState{}, s)
// no artifact name should fail
s, _ = ius.Handle(ctx, &stateTestController{
inventoryErr: errNoArtifactName,
})
assert.IsType(t, &ErrorState{}, s)
}
C
static void test_state_inventory_update(void **state __unused) {
mender_statemachine_create(&sm, store, mender);
will_return_always(mender_time_now_test, FAKE_TIME);
/* error */
mender_inventory_refresh_expect(mender, MERR_UNKNOWN);
sm.current_state = MENDER_STATE_INVENTORY_UPDATE;
assert_int_equal(mender_statemachine_run_once(&sm), MERR_NONE);
assert_int_equal(sm.current_state, MENDER_STATE_INVENTORY_UPDATE_ASYNC);
assert_int_equal(sm.last_error, MERR_UNKNOWN);
assert_int_equal(sm.next_state_update, 0);
assert_int_equal(mender_statemachine_run_once(&sm), MERR_NONE);
assert_int_equal(sm.current_state, MENDER_STATE_CHECK_WAIT);
assert_int_equal(sm.next_state_update, 0);
/* success */
mender_inventory_refresh_expect(mender, MERR_NONE);
sm.current_state = MENDER_STATE_INVENTORY_UPDATE;
assert_int_equal(mender_statemachine_run_once(&sm), MERR_NONE);
assert_int_equal(sm.current_state, MENDER_STATE_INVENTORY_UPDATE_ASYNC);
assert_int_equal(sm.last_error, MERR_NONE);
assert_int_equal(sm.next_state_update, 0);
assert_int_equal(mender_statemachine_run_once(&sm), MERR_NONE);
assert_int_equal(sm.current_state, MENDER_STATE_CHECK_WAIT);
assert_int_equal(sm.next_state_update, 0);
/* no artifact name should fail */
mender_inventory_refresh_expect(mender, MERR_NO_ARTIFACT_NAME);
sm.current_state = MENDER_STATE_INVENTORY_UPDATE;
assert_int_equal(mender_statemachine_run_once(&sm), MERR_NONE);
assert_int_equal(sm.current_state, MENDER_STATE_INVENTORY_UPDATE_ASYNC);
assert_int_equal(sm.last_error, MERR_NO_ARTIFACT_NAME);
assert_int_equal(sm.next_state_update, 0);
assert_int_equal(mender_statemachine_run_once(&sm), MERR_NONE);
assert_int_equal(sm.current_state, MENDER_STATE_ERROR);
assert_int_equal(sm.next_state_update, 0);
}
Sie werden feststellen, dass wir den Zustandsautomaten häufiger aufrufen als den ursprünglichen Code. Das liegt daran, dass wir aufgrund des Mangels an Threads viele zusätzliche Zustände benötigen, was wir im nächsten Abschnitt erklären werden.
Asynchroner Code
Der Zustandsautomat des ursprünglichen Sender-Clients war nur dazu gedacht, logische Zustände wie Update-Check und Update-Fetch darzustellen. Die Aktionen selbst, wie das Herunterladen eines Updates, waren blockierende Implementierungen. Wir haben cmenders Statemachine auf einem separaten Thread laufen lassen, anstatt sie in eine bestehende Ereignisschleife zu integrieren (was trotzdem noch unterstützt wird), weil wir auf esp-idf-Fehler gestoßen sind, bei denen blockierende Socket-Operationen das gesamte System lahmlegen können. Also haben wir stattdessen den gesamten Client asynchron gemacht. In C bedeutet das eine Menge Zustandsautomaten und Callback-Funktionen.
Wir haben ein paar Module wie den http client, die in sich geschlossen sind, also ihre eigenen asynchronen Zustandsautomaten verwalten und einfach einen Callback mit dem Ergebnis ausführen, sobald sie fertig sind. Für die main mender state machine müssen wir zusätzliche Zustände hinzufügen, die signalisieren, dass wir gerade auf einen asynchronen Callback warten.So haben wir zum Beispiel neben dem update_check-Status auch einen update_check_async-Status. Das ist ziemlich einfach, weil wir nur eine Funktion überall dort aufteilen müssen, wo es im ursprünglichen Code einen blockierenden Aufruf gab, um dann einen neuen Zustand hinzufügen und den Zustandsautomaten im Callback auslösen, der uns sagt, dass die asynchrone Operation abgeschlossen ist. Mit anderen Worten: Wir haben manuell getan, was Sprachen mit async-await-Unterstützung wie Rust automatisch auf Compiler-Ebene tun.
Optimierungen der Ressourcennutzung
Auf einem ESP32 funktionierten die meisten Dinge, aber sie waren nicht ideal. Für einen anderen Kunden mussten wir das Ganze auf einem ESP8266 laufen lassen, wo die Lage etwas beengter war. Hier sind also alle Optimierungen, die wir für eine oder beide Plattformen vornehmen mussten.
SSL
Wir mussten viel über die Funktionsweise von SSL lernen, um die Probleme zu verstehen und die notwendige Feinabstimmung vornehmen zu können. Abhängig von der Serverkonfiguration und den verwendeten Zertifikaten kann SSL sehr speicher- und rechenintensiv sein. So sehr, dass der ESP32 Dutzende von Sekunden brauchte, um den SSL-Handshake zu durchlaufen, und dabei so viel RAM benötigte, dass für die Hauptanwendung nichts mehr übrig blieb. Da wir also keine nutzlosen Geräte ausliefern wollten, die sich selbst aktualisieren, ohne dass etwas anderes läuft, mussten wir die Situation irgendwie verbessern.
Chiffren
Zunächst wählten wir Chiffriersuiten und -kurven aus, die so wenig RAM wie möglich benötigen und trotzdem sicher sind. Nach einigem Benchmarking stellte sich heraus, dass dies (für mbedtls) MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 und MBEDTLS_ECP_DP_SECP256R1 sind. Wir haben passende Zertifikate ausgewählt, die diese unterstützen.
Handshake
Der Handshake ist ebenfalls ein Problem, da möglicherweise große Zertifikate übertragen werden müssen. Um dieses Problem zu lösen, haben wir eine benutzerdefinierte CA verwendet, deren öffentlicher Schlüssel in die Firmware kompiliert ist, so dass wir das Senden einer langen Zertifikatskette auf der Serverseite vermeiden können.
Aushandlung der maximalen Fragmentlänge (MFLN)
Standardmäßig benötigt SSL zwei 16 KB große Puffer - einen zum Senden und einen zum Empfangen. Das ist offensichtlich zu groß für die armen kleinen MCUs. Glücklicherweise gibt es eine großartige Funktion namens MFLN, die die Verwendung von Puffern mit einer Größe von jeweils 512 Byte ermöglicht. Auf der Serverseite mussten wir die openssl-Version dafür aktualisieren, da es sich um eine sehr neue Funktion handelte. Der mbedtls-Client unterstützte dies bereits, und es funktionierte gut - bis auf den Handshake. Zu dieser Zeit unterstützte mbedtls den Handshake einfach nicht und fragmentierte nur die eigentliche Nutzlast. Unsere Abhilfe bestand darin, sicherzustellen, dass alle Handshake-Nachrichten zufällig in 512 Bytes passen. Die Verwendung kleinerer Zertifikate und der Verzicht auf eine große CA-Kette haben dabei sicherlich geholfen.
Nur ein Handshake zur gleichen Zeit
Während der Sender-Client nie mehrere Anfragen gleichzeitig ausführen muss, hatten wir auch Hauptanwendungscode mit einer lang laufenden MQTT-Verbindung (über https). Glücklicherweise tritt der hohe Speicherverbrauch aus kryptografischen Gründen nur während des Handshakes auf. Danach sind wir wieder bei unseren 512-Byte-Puffern angelangt. Wir mussten also nur dafür sorgen, dass nie beide Clients gleichzeitig den Handshake durchführen. Dank der mbedtls api, die so konzipiert ist, dass wir wissen, wann der Handshake stattfindet, war eine Mutex, die sowohl der Sender-Client als auch der MQTT-Client gemeinsam nutzen, alles, was wir brauchten, um dies zu lösen.
Unkomprimierte Updates
Ursprünglich unterstützte mender nur gzip-komprimierte Updates. Das Problem mit gzip ist, dass es aufgrund des Sliding-Window-Ansatzes viele Kilobyte RAM während der Dekomprimierung benötigt. Da MCU-Updates ohnehin winzig sind, haben wir beschlossen, Unterstützung für unkomprimierte Updates hinzuzufügen. Da mender-artifact sowohl ein CLI-Tool als auch eine Bibliothek ist, die für die serverseitige Verifikation verwendet wird, ist es das einzige Repository, das eine Änderung benötigt.
Nebenbemerkung: Der Autor des PR ist als Ghost markiert, da der Autor ursprünglich einen separaten Account namens “mzimmermanngcx” für die Arbeit verwendet hat, der später gelöscht wurde, weil er stattdessen seinen persönlichen Account verwenden wollte.
ED25519 Autorisierungsanfragen
Der Sender-Client muss bestimmte Anfragen mit einem RSA-Schlüssel signieren. Dies ist ein benutzerdefiniertes Protokoll der Anwendungsschicht, das nicht Teil von SSL ist. Auf dem ESP8266 war dies ziemlich langsam, also haben wir stattdessen Unterstützung für die Verwendung von ED25519 hinzugefügt. Aus unbekannten Gründen wurde dies nicht zusammengeführt, aber jemand anderes machte die gleiche Arbeit ein Jahr später, die dann zusammengeführt wurde. 🤷♂
Abschließende Worte
Sind wir mit dem cmender-Client zufrieden? Größtenteils, ja. Er scheint in der Produktion gut zu funktionieren, wir haben einen Haufen Unit-Tests, und wir haben eine Linux-Portierung für einfache Tests. Der Code sieht aufgrund der Anzahl der verwendeten Callbacks ein wenig seltsam aus. Ohne async-await könnten wir aber wahrscheinlich kein wesentlich besseres Design erstellen, also belassen wir es dabei.
Der Code ist vollständig plattformübergreifend, da viele Dinge - von der Socket-Kommunikation bis zur Kryptographie - in einer Plattformbibliothek implementiert sind. Also viel Spaß beim Ausprobieren 🙂 .