diff --git a/.air.toml b/.air.toml
index 9520ff3..91be4cb 100644
--- a/.air.toml
+++ b/.air.toml
@@ -1,11 +1,11 @@
-root = "./src"
+root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./src/."
delay = 1000
- exclude_dir = ["assets", "tmp", "vendor"]
+ exclude_dir = ["tmp", "vendor", "ui/dist", "ui/node_modules", "ui/src"]
exclude_file = []
exclude_regex = []
exclude_unchanged = false
diff --git a/.gitignore b/.gitignore
index d59a62b..56dfd25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
build
tmp
+/sebrauc.toml
+/htpasswd
diff --git a/.golangci.yaml b/.golangci.yaml
index 3898f1c..2e06378 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -1,7 +1,6 @@
linters:
presets:
- bugs
- - unused
- import
- module
@@ -14,11 +13,17 @@ linters:
disable:
- scopelint
+ - noctx
linters-settings:
lll:
line-length: 88
+ tab-width: 4
gocognit:
min-complexity: 10
nestif:
min-complexity: 3
+ errcheck:
+ exclude-functions:
+ - "(*github.com/gin-gonic/gin.Context).Error"
+ - "(*github.com/gin-gonic/gin.Context).AbortWithError"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bf556ef..cc4ae18 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,10 +6,7 @@ repos:
name: GolangCI Lint
- id: go-test-repo-mod
name: Backend tests
- - repo: https://github.com/pre-commit/mirrors-prettier
- rev: v2.4.1
- hooks:
- - id: prettier
+
- repo: local
hooks:
- id: tsc
@@ -20,3 +17,8 @@ repos:
args: ["-p", "./ui/tsconfig.json"]
additional_dependencies: ["typescript@4.5.2"]
pass_filenames: false
+
+ - repo: https://github.com/pre-commit/mirrors-prettier
+ rev: v2.4.1
+ hooks:
+ - id: prettier
diff --git a/.woodpecker.yml b/.woodpecker.yml
new file mode 100644
index 0000000..69668a7
--- /dev/null
+++ b/.woodpecker.yml
@@ -0,0 +1,14 @@
+pipeline:
+ frontend:
+ image: node:16-alpine
+ commands:
+ - cd ui
+ - npm install -g pnpm
+ - pnpm install
+ - pnpm run build
+ backend:
+ image: golangci/golangci-lint:latest
+ commands:
+ - go get -t ./src/...
+ - golangci-lint run --timeout 5m
+ - go test -v ./src/...
diff --git a/Makefile b/Makefile
index ed465a0..8da4406 100644
--- a/Makefile
+++ b/Makefile
@@ -1,19 +1,35 @@
SRC_DIR=./src
UI_DIR=./ui
-VERSION=$(shell git tag --sort=-version:refname | head -n 1)
+APIDOC_FILE=./src/server/swagger/swagger.yaml
+
+VER=$(or ${VERSION},$(shell git tag --sort=-version:refname | head -n 1))
setup:
- go get -t ./src/...
cd ${UI_DIR} && pnpm install
test:
go test -v ./src/...
+lint:
+ golangci-lint run
+ cd ${UI_DIR} && npm run format && npm run lint
+
build-ui:
- cd ${UI_DIR} && VITE_VERSION=${VERSION} pnpm run build
+ cd ${UI_DIR} && pnpm run build
build-server:
- go build -tags prod -ldflags "-s -w -X code.thetadev.de/TSGRain/SEBRAUC/src/util.version=${VERSION}" -o build/sebrauc ./src/.
+ go build -tags prod -ldflags "-s -w -X code.thetadev.de/TSGRain/SEBRAUC/src/util.version=${VER}" -o build/sebrauc ./src/.
build: build-ui build-server
+
+generate-apidoc:
+ SWAGGER_GENERATE_EXTENSION=false swagger generate spec --scan-models -o ${APIDOC_FILE}
+
+generate-apiclient:
+ openapi-generator generate -i ${APIDOC_FILE} -g typescript-axios -o ${UI_DIR}/src/sebrauc-client -p "supportsES6=true"
+ cd ${UI_DIR} && npm run format
+
+clean:
+ rm -f build/*
+ rm -rf ${UI_DIR}/dist/**
diff --git a/README.md b/README.md
deleted file mode 100644
index c074425..0000000
--- a/README.md
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..ecb1dfc
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,460 @@
+.. image:: ui/src/assets/logo_border.svg
+
+SEBRAUC ist eine einfach zu bedienende Weboberfläche für den
+`RAUC `__ Firmware-Updater, die es
+erlaubt, Softwareupdates auf Embedded-Systemen wie dem Raspberry Pi zu
+installieren.
+
+Ich habe die Anwendung für die TSGRain-Bewässerungssteuerung entwickelt,
+da es noch keine einfache Möglichkeit gab, RAUC-Updates über eine
+Weboberfläche zu installieren.
+
+Der Updatevorgang funktioniert ähnlich wie bei älteren
+WLAN-Routern. Das ``*.raucb``-Firmwarepaket kann über den Browser an das
+Gerät übertragen und installiert werden. Im Gegensatz zu OTA-basierten
+Lösungen wie `hawkBit `__
+wird keine Internetverbindung, sondern nur ein lokaler WLAN-Access-Point
+für die Datenübertragung benötigt. Das macht SEBRAUC ideal für fest
+installierte Geräte an abgelegenen Orten wie unser
+TSGRain-Bewässerungssystem.
+
+Funktion
+########
+
+Startseite
+==========
+
+.. image:: _screenshots/update0.png
+ :width: 500
+
+Auf der Startseite findet der Nutzer einen großen Upload-Button, mit dem
+er ein ``*.raucb``-Image auswählen und hochladen kann.
+
+Update hochladen
+================
+
+.. image:: _screenshots/update1.png
+ :width: 500
+
+Wurde eine Update-Datei ausgewählt, wird sie auf den Server
+hochgeladen und zunächst temporär im Ordner ``/tmp/sebrauc`` gespeichert.
+Dabei wird der Uploadfortschritt mit einer kreisförmigen Skala
+angezeigt.
+
+Update installieren
+===================
+
+.. image:: _screenshots/update2.png
+ :width: 500
+
+Ist der Uplodad der Datei abgeschlossen, beginnt die Installation.
+SEBRAUC startet den RAUC-Updater mit dem entsprechenden
+Kommandozeilenbefehl.
+
+Während des Updatevorgangs liefert RAUC folgende Konsolenausgabe:
+
+.. code-block::
+
+ installing
+ 0% Installing
+ 0% Determining slot states
+ 20% Determining slot states done.
+ 20% Checking bundle
+ 20% Verifying signature
+ 40% Verifying signature done.
+ 40% Checking bundle done.
+ 40% Checking manifest contents
+ 60% Checking manifest contents done.
+ 60% Determining target install group
+ 80% Determining target install group done.
+ 80% Updating slots
+ 80% Checking slot rootfs.0
+ 90% Checking slot rootfs.0 done.
+ 90% Copying image to rootfs.0
+ 100% Copying image to rootfs.0 done.
+ 100% Updating slots done.
+ 100% Installing done.
+ idle
+ Installing `/app/tsgrain-update-raspberrypi3.raucb` succeeded
+
+Aus dieser Konsolenausgabe lässt sich der aktuelle Fortschritt in
+Prozent und der Status ermitteln. SEBRAUC leitet diese Daten
+in Echtzeit an die Weboberfläche weiter. So kann der Nutzer live
+den Updatevorgang mitverfolgen.
+
+Wenn RAUC eine Fehlermeldung ausgibt, wird diese mit einem roten
+Warndreieck am unteren Bildschirmrand angezeigt.
+
+
+Installation abgeschlossen
+==========================
+
+.. image:: _screenshots/update3.png
+ :width: 500
+
+Ist die Installation abgeschlossen, kehrt SEBRAUC zum Startbildschirm
+zurück. Am unteren Rand erhält der Nutzer den Hinweis, dass das System
+neu gestartet werden muss, um die neue Software in Betrieb zu nehmen.
+Drückt man auf den :guilabel:`Restart`-Button, startet das System mit
+einer Verzögerung von 3 Sekunden neu.
+
+Systeminformationen
+===================
+
+.. image:: _screenshots/sysinfo.png
+ :width: 500
+
+Mit der Schaltfläche oben rechts lässt sich ein Informationsbildschirm
+einblenden, der einige Statusinformationen über das System und den
+RAUC-Updater anzeigt.
+So lässt sich in Erfahrung bringen, welches Betriebssystem in welcher Version
+gerade ausgeführt wird und welche der 2 Root-Partitionen gerade aktiv ist.
+
+Der RAUC-Updater hat eine Konfigurationsoption, mit der festgelegt werden
+kann, welche Firmware und Firmware-Variante akzeptiert wird. Diese Werte
+werden ebenfalls auf dem Systeminformationsbildschirm angezeigt,
+damit der Nutzer sehen kann, welche Firmware-Images mit seinem Gerät
+kompatibel sind.
+
+
+Technische Details
+##################
+
+SEBRAUC besteht aus zwei Komponenten: dem in Go geschriebenen Backend
+und einer mit Preact und Typescript umgesetzten Single-Page-Weboberfläche.
+
+Das Backend steuert den RAUC-Updater und stellt die Web-API bereit, die vom
+Frontend angesprochen wird. Ich habe für das Backend das
+`Gin-Webframework `_ verwendet. Hierbei
+handelt es sich um ein minimales und performantes Framework in der Programmiersprache
+Go, vergleichbar mit Flask für Python oder Express für
+Javascript. Es erlaubt die einfache Erstellung von Routen und Handlern
+für Webanfragen, bietet aber im Gegensatz zu größeren Frameworks
+wie Django keine eingebaute Datenbankintegration oder Administrationsoberfläche.
+
+Eine asynchrone Kommunikation vom Server zum Client mittels Websockets
+kann mit dem Modul ``github.com/gorilla/websocket`` leicht in eine Gin-Anwendung
+integriert werden.
+
+SEBRAUC verwendet eine solche Websocket-Verbindung, um den aktuellen Zustand
+des Systems an das Frontend zu streamen. Dadurch erhält der Nutzer immer die aktuellen
+Informationen, ohne dass die Seite neu aufgerufen werden oder die Daten
+durch das Frontend in bestimmten Zeitabständen aktualisiert werden müssen.
+
+Beim Frontend kam das Javascript-Framework `Preact `_
+zum Einsatz, was eine kleinere und schnellere Alternative zu React darstellt.
+
+Da Preact in komprimierter Form nur 3kB groß ist, eignet es sich perfekt
+für kleine Webanwendungen, die schnell laden sollen. So ist die fertige
+SEBRAUC-Webanwendung inklusive Styles und Icons nur 22kB groß. Hätte ich hierfür
+React oder Vue verwendet, würde die Webseite mindestens doppelt so groß sein.
+
+Der Autor Ryan Carniato hat in
+`diesem Blogpost `_
+verschiedene Webframeworks, darunter React, Vue und Preact miteinander verglichen.
+Dafür hat er eine simple Todo-Liste mit allen Frameworks gebaut und
+die Größe des generierten Javascript-Codes verglichen. Laut diesem Vergleich ist
+die React-Anwendung mit 37.5kB fast siebenmal so groß wie die nur 5.6kB große Preact-Anwendung.
+
+Um auch das Stylesheet der Anwendung kompakt zu halten, wurde auf fertige
+CSS-Komponentenbibliotheken wie Bootstrap verzichtet und stattdessen
+selbstgeschriebenes scss verwendet.
+
+
+REST-API
+========
+
+Die Weboberfläche kommuniziert über REST-API-Anfragen mit dem Server.
+
+Die API ist im OpenAPI 2.0-Format dokumentiert (die yaml-Dokumentation
+befindet sich unter ``src/server/swagger/swagger.yaml``).
+
+OpenAPI ist ein Standard für maschinenlesbare API-Dokumentationen,
+aus denen mit geeigneten Tools menschenlesbare Dokumentationsseiten
+sowie fertige API-Clients in verschiedenen Programmiersprachen
+erzeugen lassen.
+
+In diesem Projekt verwende ich ein Tool (``go-swagger``),
+das Kommentare und Structs aus dem Programmcode einlesen und daraus die
+OpenAPI-Dokumentation generieren kann. Daraus wird dann der Code für den
+Typescript-Client für die Weboberfläche generiert.
+
+
+Konfiguration
+#############
+
+Die Konfiguration erfolgt über eine Datei im ``*.json``, ``*.yaml`` oder
+``*.toml``-Format. Eine Beispielkonfiguration findet sich
+`hier `__.
+
+Alternativ kann die Konfiguration auch mittels Umgebungsvariablen angepasst
+werden, wobei verschachtelte Datenstrukturen mit Unterstrichen
+addressiert werden. Beispiel:
+
+::
+
+ SERVER_ADDRESS=80 SERVER_COMPRESSION_BROTLI=false ./sebrauc
+
+SEBRAUC sucht zuerst nach einer Konfigurationsdatei mit dem Namen
+``sebrauc.{json,yml,yaml,toml}`` im aktuellen Arbeitsverzeichnis und
+anschließend im Ordner ``/etc/sebrauc``. Der Pfad zur
+Konfigurationsdatei kann auch manuell mit dem Flag ``-c`` angegeben
+werden.
+
+.. list-table::
+ :widths: 35 50 35
+ :header-rows: 1
+
+ * - Option
+ - Beschreibung
+ - Standard
+ * - ``Tmpdir``
+ - Temporäres Verzeichnis
+ - ``"/tmp/sebrauc"``
+ * - ``Server.Address``
+ - IP-Adresse, von der der Server Verbindungen
+
+ akzeptiert. Leer lassen, um Verbindungen von
+
+ allen Adressen zu akzeptieren.
+ - ``""``
+ * - ``Server.Port``
+ - Server-Port
+ - ``80``
+ * - ``Server.Websocket.Ping``
+ - Zeitabstand, in denen ein Ping vom
+
+ Websocket-Server an den Client erfolgt.
+ - ``45``
+ * - ``Server.Websocket.Timeout``
+ - Timeout in Sekunden, nachdem der Server
+
+ eine Websocket-Verbindung ohne Antwort trennt.
+ - ``45``
+ * - ``Server.Compression.Gzip``
+ - Webseiteninhalte mit Gzip komprimieren,
+
+ wenn vom Browser unterstützt.
+
+ Optionen: ``"false"``, ``"default"``,
+
+ ``"min"``, ``"max"``, ``"1"``-``"9"``
+ - ``"default"``
+ * - ``Server.Compression.Brotli``
+ - Webseiteninhalte mit Brotli komprimieren,
+
+ wenn vom Browser unterstützt.
+
+ Optionen: ``"false"``, ``"default"``,
+
+ ``"min"``, ``"max"``, ``"1"``-``"11"``
+ - ``"default"``
+ * - ``Authentication.Enable``
+ - HTTP Basic-Authentifizierung
+
+ (Passwortabfrage im Browser) aktivieren.
+ - ``false``
+ * - ``Authentication.PasswdFile``
+ - Apache-kompatible Passwortdatei.
+
+ Suche nach einer Passwortdatei
+
+ unter `./htpasswd` oder `/etc/sebrauc/htpasswd`,
+
+ wenn leergelassen.
+ - ``""``
+ * - ``Sysinfo.ReleaseFile``
+ - Datei, aus der OS-Informationen gelesen werden.
+ - ``"/etc/os-release"``
+ * - ``Sysinfo.NameKey``
+ - Schlüssel für den Namen des Betriebssystems
+
+ aus der ReleaseFile.
+ - ``"NAME"``
+ * - ``Sysinfo.VersionKey``
+ - Schlüssel für die Version des Betriebssystems
+
+ aus der ReleaseFile.
+ - ``"VERSION"``
+ * - ``Sysinfo.HostnameFile``
+ - Datei, aus der der Hostname gelesen wird.
+ - ``"/etc/hostname"``
+ * - ``Sysinfo.UptimeFile``
+ - Datei, aus der die Betriebszeit des Systems
+
+ gelesen wird.
+ - ``"/proc/uptime"``
+ * - ``Commands.RaucStatus``
+ - RAUC-Befehl, um den Status im JSON-Format
+
+ auszulesen.
+ - ``"rauc status``
+
+ ``--output-format=json"``
+ * - ``Commands.RaucInstall``
+ - RAUC-Befehl, um ein Update-Paket zu installieren.
+ - ``"rauc install"``
+ * - ``Commands.Reboot``
+ - Befehl, um das System neu zu starten.
+ - ``"shutdown -r 0"``
+
+
+Passwortdatei
+=============
+
+Ist die Authentifizierung aktiviert, liest SEBRAUC Nutzernamen und
+Passwörter aus Apache-kompatiblen Passwortdateien (standardmäßig unter
+``./htpasswd`` oder ``/etc/sebrauc/htpasswd``).
+
+Die Passwörter können im Klartext oder als Hashes vorliegen.
+Gehashte Passwörter lassen sich mit mit dem Befehl ``htpasswd -nB username``
+erzeugen. Hierfür wird unter Linux das Paket ``apache-tools`` benötigt.
+
+.. code-block::
+
+ # Klartextpasswort (nur zum Testen verwenden)
+ username:password
+
+ # Gehashtes Passwort
+ test:$2y$05$EzzPOZprUhPE.1ru1gM8du0ZNpmsU40EFDZ1PmzZtBzkMHsJVK1au
+
+
+Entwicklung und Build
+#####################
+
+Systemvoraussetzungen
+=====================
+
+Um SEBRAUC zu kompilieren werden folgende Programme benötigt:
+
+- Golang (Version 1.16 oder neuer)
+- NodeJS
+- PNPM-Paketmanager (installieren mit ``npm i -g pnpm``)
+
+Zur Entwicklung werden zusätzlich folgende Tools verwendet:
+
+- `golangci-lint `_ (Überprüft Go-Code auf
+ Formatierung, Code Style und bestimmete Fehler)
+- `air `_ (Kompiliert und startet
+ Go-Anwendungen bei Codeänderungen neu)
+- `go-swagger `_
+ (Generierung einer OpenAPI-Dokumentation aus Go-Servercode, der nach
+ einem bestimmten Schema kommentiert wurde)
+- `openapi-generator `_
+ (Erzeugt fertige API-Clients aus OpenAPI-Dokumentationen)
+
+Befehle
+=======
+
+Das Projekt enthät eine Makefile, mit der sich oft benötigte Befehle
+schnell ausführen lassen.
+
+``make setup``
+ Dieser Befehl muss vor dem Arbeiten an dem Projekt ausgeführt werden.
+ Hiermit werden sämtliche Abhängigkeiten für das Frontend
+
+``make lint``
+ Überprüfe den Backend-Code mit ``golangci-lint`` auf Fehler und schlechte
+ Programmierpraktiken.
+ Zudem wird der Typescript-Code im Frontend mit dem Typescript-Compiler
+ auf Typfehler geprüft.
+
+``make build``
+ Baue die Produktionsversion der Software.
+ Die Softwareversion, die in die Software einkompiliert wird,
+ wird aus dem aktuellsten Git-Tag ermittelt.
+
+``make test``
+ Führt die Tests für das Backend aus.
+
+``make generate-apidoc``
+ Liest die API-Dokumentation aus den Kommentaren im Quellcode ein
+ und produziert daraus eine Swagger-Dokumentationsdatei im yaml-Format.
+
+``make generate-apiclient``
+ Erzeugt den Typescript-API-Client für das Frontend aus der Swagger
+ API-Dokumentation. Um die API-Dokumentation zu aktualisieren, muss
+ vorher ``make generate-apidoc`` ausgeführt werden.
+
+
+Um die SEBRAUC-Software zu bauen, müssen nach dem Herunterladen des Repositorys
+folgende Befehle ausgeführt werden:
+
+.. code-block:: sh
+
+ # Abhängigkeiten installieren
+ make setup
+
+ # Build für die CPU-Architektur des laufenden Systems
+ make build
+
+ # Build für andere CPU-Architekturen (Cross-Compile)
+ GOARCH=arm make build
+
+Die kompilierte Anwendung befindet wird im Ordner ``build``
+abgelegt.
+
+
+RAUC-Mock
+=========
+
+Um SEBRAUC testweise auf einem Entwicklerrechner ohne den RAUC-Updater
+laufen zu lassen, habe ich ein kleines Go-Programm geschrieben, das
+die Konsolenausgabe von RAUC simuliert.
+
+Man kann dieses Programm anstelle der echten RAUC-Befehle in die
+Konfigurationsdatei eintragen.
+
+.. code-block:: toml
+
+ # Commands to be run by SEBRAUC
+ [Commands]
+ # RAUC status command (outputs updater status in json format)
+ RaucStatus = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock status --output-format=json"
+ # RAUC install command (installs FW image passed as argument)
+ RaucInstall = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock install"
+
+Wird SEBRAUC im Testmodus (d.h. ohne das Build-Flag ``-tags prod``) gebaut,
+führt das Programm stets den Mock-RAUC-Befehl aus und ignoriert die konfigurierten
+Befehle. Ob sich SEBRAUC im Testmodus befindet, lässt sich an der Hinweisnachricht
+beim Start erkennen.
+
+.. code-block:: text
+
+ Test mode active - no update operations are executed.
+ Build with -tags prod to enable live mode.
+
+Das Verhalten von ``rauc_mock`` kann mittels Umgebungsvariablen angepasst werden.
+Ist die Variable ``RAUC_MOCK_FAIL`` gesetzt, wird eine fehlerhafte Installation
+simuliert. Mit dem Setzen der Variable ``RAUC_MOCK_TEST`` reduziert man die Verzögerung,
+mit der die Nachrichten angezeigt werden, von 500 auf 10 Millisekunden. Diese Option
+dient dazu, automatische Tests zu beschleunigen, während die Wartezeit bei manuellen
+Tests lang genug ist, um sämtliche Statusnachrichten lesen zu können.
+
+
+Auto-Reload des Backends
+========================
+
+Wenn man am Backend-Code arbeitet und die Webseite oder API dabei gleichzeitig
+testen möchte, hat man mit kompilierten Sprachen wie Go das Problem, dass sich
+Codeänderungen nicht sofort auf das Verhalten der Anwendung auswirken.
+Erst nach einer Rekompilierung und einem Neustart des Servers ist eine
+Änderung wirksam.
+
+Glücklicherweise gibt es Tools wie ``air``, die die Quellcodedateien überwachen
+und die Anwendung bei Änderung automatisch kompilieren und neu starten.
+
+Um den SEBRAUC-Server mit Auto-Reload zu starten,
+muss man einfach den Befehl ``air`` im Stammverzeichnis des Projekts ausführen
+
+
+Frontend
+========
+
+Um den Entwicklungsserver für das Frontend zu starten, navigiert man in das
+``ui``-Verzeichnis und startet den Server mit dem Befehl ``npm run dev``.
+
+Im Entwicklungsmodus kommuniziert das Frontend mit dem Backend unter der
+Adresse ``localhost:8080``. Man kann also parallel SEBRAUC mit entsprechender
+Konfiguration (Port: 8080) starten, um das Frontend mit einem funktionsfähigen
+Server zu testen.
diff --git a/_screenshots/sysinfo.png b/_screenshots/sysinfo.png
new file mode 100644
index 0000000..a6ca4e2
Binary files /dev/null and b/_screenshots/sysinfo.png differ
diff --git a/_screenshots/update0.png b/_screenshots/update0.png
new file mode 100644
index 0000000..faa7ebe
Binary files /dev/null and b/_screenshots/update0.png differ
diff --git a/_screenshots/update1.png b/_screenshots/update1.png
new file mode 100644
index 0000000..2196373
Binary files /dev/null and b/_screenshots/update1.png differ
diff --git a/_screenshots/update2.png b/_screenshots/update2.png
new file mode 100644
index 0000000..7dfcfed
Binary files /dev/null and b/_screenshots/update2.png differ
diff --git a/_screenshots/update3.png b/_screenshots/update3.png
new file mode 100644
index 0000000..399edef
Binary files /dev/null and b/_screenshots/update3.png differ
diff --git a/go.mod b/go.mod
index 5b0a27c..92ad7c6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,10 +3,29 @@ module code.thetadev.de/TSGRain/SEBRAUC
go 1.16
require (
- github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/gofiber/fiber/v2 v2.21.0
- github.com/gofiber/websocket/v2 v2.0.12
+ code.thetadev.de/TSGRain/ginzip v0.1.1
+ github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db
+ github.com/fortytw2/leaktest v1.3.0
+ github.com/gin-contrib/cors v1.3.1
+ github.com/gin-gonic/gin v1.7.7
+ github.com/go-errors/errors v1.4.1 // indirect
+ github.com/golang/protobuf v1.5.2 // indirect
+ github.com/google/go-cmp v0.5.6 // indirect
github.com/google/uuid v1.3.0
+ github.com/gorilla/websocket v1.4.1
+ github.com/jinzhu/configor v1.2.1
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.14 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/stretchr/testify v1.7.0
+ github.com/tg123/go-htpasswd v1.2.0
+ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
+ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
+ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+ google.golang.org/protobuf v1.27.1 // indirect
+ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
diff --git a/go.sum b/go.sum
index 6bcb5c4..c082427 100644
--- a/go.sum
+++ b/go.sum
@@ -1,48 +1,128 @@
-github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E=
-github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
+code.thetadev.de/TSGRain/ginzip v0.1.1 h1:+X0L6qumEZiKYSLmM+Q0LqKVHsKvdcg4CVzsEpvM7fk=
+code.thetadev.de/TSGRain/ginzip v0.1.1/go.mod h1:BH7VkvpP83vPRyMQ8rLIjKycQwGzF+/mFV0BKzg+BuA=
+github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
+github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
+github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
+github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/fasthttp/websocket v1.4.3-rc.9 h1:CWJH0vONrOatdKXZgkgbFKWllijD9aY50C5KfbSDcWk=
-github.com/fasthttp/websocket v1.4.3-rc.9/go.mod h1:eXL2zqDbexYJxaCw8/PQlm7VcMK6uoGvwbYbTdt4dFo=
-github.com/gofiber/fiber/v2 v2.20.1/go.mod h1:/LdZHMUXZvTTo7gU4+b1hclqCAdoQphNQ9bi9gutPyI=
-github.com/gofiber/fiber/v2 v2.21.0 h1:tdRNrgqWqcHWBwE3o51oAleEVsil4Ro02zd2vMEuP4Q=
-github.com/gofiber/fiber/v2 v2.21.0/go.mod h1:MR1usVH3JHYRyQwMe2eZXRSZHRX38fkV+A7CPB+DlDQ=
-github.com/gofiber/websocket/v2 v2.0.12 h1:jKwTrXiOut9UGOGEzFTAD6gq+/78mM3NcrI05VbxjAU=
-github.com/gofiber/websocket/v2 v2.0.12/go.mod h1:lQRy0u5ACJfiez/e/bhGeYvM0/M940Y3NFw14U3/otI=
-github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db h1:oZ4U9IqO8NS+61OmGTBi8vopzqTRxwQeogyBHdrhjbc=
+github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db/go.mod h1:Pk7/9x6tyChFTkahDvLBQMlvdsWvfC+yU8HTT5VD314=
+github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
+github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA=
+github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
+github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
+github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
+github.com/go-errors/errors v1.4.1 h1:IvVlgbzSsaUNudsw5dcXSzF3EWyXTi5XrAdngnuhRyg=
+github.com/go-errors/errors v1.4.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
+github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
-github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
+github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
+github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
+github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4 h1:ocK/D6lCgLji37Z2so4xhMl46se1ntReQQCUIU4BWI8=
-github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.29.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
-github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
-github.com/valyala/fasthttp v1.31.0 h1:lrauRLII19afgCs2fnWRJ4M5IkV0lo2FqA61uGkNBfE=
-github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
-github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
-github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
-golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
+github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
+github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
+golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
+gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/openapi.yml b/openapi.yml
deleted file mode 100644
index a9332ce..0000000
--- a/openapi.yml
+++ /dev/null
@@ -1,107 +0,0 @@
-openapi: "3.0.3"
-info:
- title: SEBRAUC
- version: "0.0.1"
-servers:
- - url: http://localhost:8080/api
-paths:
- /status:
- get:
- responses:
- "200":
- description: OK
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/RaucStatus"
- default:
- description: "Server error"
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/StatusMessage"
- /update:
- post:
- requestBody:
- content:
- multipart/form-data:
- schema:
- type: object
- properties:
- updateFile:
- type: string
- format: binary
- responses:
- "200":
- description: "OK"
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/StatusMessage"
- default:
- description: "Server error"
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/StatusMessage"
-
- /reboot:
- post:
- responses:
- "200":
- description: "OK"
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/StatusMessage"
- default:
- description: "Server error"
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/StatusMessage"
-
-components:
- schemas:
- RaucStatus:
- type: object
- properties:
- installing:
- description: "True if the installer is running"
- type: boolean
- percent:
- description: "Installation progress"
- type: integer
- minimum: 0
- maximum: 100
- message:
- description: "Current installation step"
- type: string
- example: "Copying image to rootfs.0"
- last_error:
- description: "Installation error message"
- type: string
- example: "Failed to check bundle identifier: Invalid identifier. Did you pass a valid RAUC bundle?"
- log:
- description: "Full command line output of the current installation"
- type: string
- example: "0% Installing\n0% Determining slot states\n20% Determining slot states done.\n"
- required:
- - installing
- - percent
- - message
- - last_error
- - log
-
- StatusMessage:
- type: object
- properties:
- success:
- description: "Is operation successful"
- type: boolean
- msg:
- description: "Success message"
- type: string
- example: "Update started"
- required:
- - msg
diff --git a/sebrauc.example.toml b/sebrauc.example.toml
new file mode 100644
index 0000000..5f95ce0
--- /dev/null
+++ b/sebrauc.example.toml
@@ -0,0 +1,44 @@
+# SEBRAUC config file example
+
+# Temporary directory
+# Update packages are stored temporarily under Tmpdir/update-.raucb
+Tmpdir = "/tmp/sebrauc"
+
+# Webserver options
+[Server]
+# IP address to accept connections from
+Address = ""
+# Port to listen at
+Port = 8080
+
+# Websocket connection settings: Ping interval/Timeout in seconds
+[Server.Websocket]
+Ping = 45
+Timeout = 15
+
+# Compression settings. Refer to code.thetadev.de/TSGRain/ginzip for details.
+[Server.Compression]
+Gzip = "default"
+Brotli = "default"
+
+[Authentication]
+Enable = true
+PasswdFile = "htpasswd"
+
+# Where to obtain system info from
+[Sysinfo]
+ReleaseFile = "/etc/os-release"
+# Keys to look for in the OS release file
+NameKey = "NAME"
+VersionKey = "VERSION"
+HostnameFile = "/etc/hostname"
+UptimeFile = "/proc/uptime"
+
+# Commands to be run by SEBRAUC
+[Commands]
+# RAUC status command (outputs updater status in json format)
+RaucStatus = "rauc status --output-format=json"
+# RAUC install command (installs FW image passed as argument)
+RaucInstall = "rauc install"
+# System reboot command
+Reboot = "shutdown -r 0"
diff --git a/src/assets/assets.go b/src/assets/assets.go
deleted file mode 100644
index 8f2c9de..0000000
--- a/src/assets/assets.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package assets
-
-import (
- "embed"
-)
-
-//go:embed files/**
-var Assets embed.FS
diff --git a/src/assets/files/index.html b/src/assets/files/index.html
deleted file mode 100644
index 0554e60..0000000
--- a/src/assets/files/index.html
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
- Chat Example
-
-
-
-
-
-
-
-
-
diff --git a/src/config/config.go b/src/config/config.go
new file mode 100644
index 0000000..a46106d
--- /dev/null
+++ b/src/config/config.go
@@ -0,0 +1,130 @@
+package config
+
+import (
+ "strings"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
+ "github.com/jinzhu/configor"
+)
+
+var (
+ cfgFilePaths = []string{"sebrauc", "/etc/sebrauc/sebrauc"}
+ cfgFileTypes = []string{"toml", "yaml", "yml", "json"}
+)
+
+// SEBRAUC config object
+type Config struct {
+ // Temporary directory
+ // Update packages are stored temporarily under Tmpdir/update-.raucb
+ Tmpdir string
+
+ // Webserver options
+ Server struct {
+ // IP address to accept connections from
+ Address string
+ // Port to listen at
+ Port int `default:"80"`
+
+ // Websocket connection settings: Ping interval/Timeout in seconds
+ Websocket struct {
+ Ping int `default:"45"`
+ Timeout int `default:"15"`
+ }
+
+ // Compression settings. Refer to code.thetadev.de/TSGRain/ginzip for details.
+ Compression struct {
+ Gzip string
+ Brotli string
+ }
+ }
+
+ Authentication struct {
+ Enable bool `default:"false"`
+ PasswdFile string
+ }
+
+ // Where to obtain system info from
+ Sysinfo struct {
+ ReleaseFile string `default:"/etc/os-release"`
+ // Keys to look for in the OS release file
+ NameKey string `default:"NAME"`
+ VersionKey string `default:"VERSION"`
+ HostnameFile string `default:"/etc/hostname"`
+ UptimeFile string `default:"/proc/uptime"`
+ }
+
+ // Commands to be run by SEBRAUC
+ // Note that these are overriden when running in development mode
+ Commands struct {
+ // RAUC status command (outputs updater status in json format)
+ RaucStatus string `default:"rauc status --output-format=json"`
+ // RAUC install command (installs FW image passed as argument)
+ RaucInstall string `default:"rauc install"`
+ // System reboot command
+ Reboot string `default:"shutdown -r 0"`
+ }
+}
+
+func findConfigFile(pathIn string) string {
+ fpath, err := util.FindFile(pathIn, cfgFilePaths, cfgFileTypes)
+ if err != nil {
+ if pathIn != "" {
+ panic("cfg file not found: " + err.Error())
+ }
+ return ""
+ }
+
+ return fpath
+}
+
+func stripTrailingSlashes(pathIn string) string {
+ return strings.TrimRight(pathIn, "/")
+}
+
+func loadConfig(cfgFile string) *Config {
+ cfg := new(Config)
+ cfgor := configor.New(&configor.Config{
+ // Debug: true,
+ ENVPrefix: "SEBRAUC",
+ })
+
+ err := cfgor.Load(cfg, cfgFile)
+ if err != nil {
+ panic(err)
+ }
+
+ // Override commands with testing options
+ if mode.IsDev() {
+ cfg.Commands.RaucStatus = testcmd.RaucStatus
+ cfg.Commands.RaucInstall = testcmd.RaucInstall
+ cfg.Commands.Reboot = testcmd.Reboot
+ }
+
+ cfg.Tmpdir = stripTrailingSlashes(cfg.Tmpdir)
+
+ return cfg
+}
+
+// GetWithFlags returns the configuration extracted from cmdline args,
+// env variables or config file.
+func GetWithFlags(pathIn string, portIn int) *Config {
+ cfg := loadConfig(findConfigFile(pathIn))
+
+ // Override port with cmdline flag if set
+ if portIn > 0 {
+ cfg.Server.Port = portIn
+ }
+
+ return cfg
+}
+
+// Get returns the configuration extracted from env variables or config file.
+func Get() *Config {
+ return loadConfig(findConfigFile(""))
+}
+
+func GetDefault() *Config {
+ return loadConfig("")
+}
diff --git a/src/config/config_test.go b/src/config/config_test.go
new file mode 100644
index 0000000..332437a
--- /dev/null
+++ b/src/config/config_test.go
@@ -0,0 +1,161 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDefault(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ cfg := Get()
+
+ assert.Equal(t, "", cfg.Server.Address)
+ assert.Equal(t, 80, cfg.Server.Port)
+
+ assert.Equal(t, 45, cfg.Server.Websocket.Ping)
+ assert.Equal(t, 15, cfg.Server.Websocket.Timeout)
+
+ assert.Equal(t, "", cfg.Server.Compression.Gzip)
+ assert.Equal(t, "", cfg.Server.Compression.Brotli)
+
+ assert.Equal(t, "", cfg.Tmpdir)
+
+ assert.Equal(t, false, cfg.Authentication.Enable)
+ assert.Equal(t, "", cfg.Authentication.PasswdFile)
+
+ assert.Equal(t, "/etc/os-release", cfg.Sysinfo.ReleaseFile)
+ assert.Equal(t, "NAME", cfg.Sysinfo.NameKey)
+ assert.Equal(t, "VERSION", cfg.Sysinfo.VersionKey)
+ assert.Equal(t, "/etc/hostname", cfg.Sysinfo.HostnameFile)
+ assert.Equal(t, "/proc/uptime", cfg.Sysinfo.UptimeFile)
+
+ assert.Equal(t, "rauc status --output-format=json", cfg.Commands.RaucStatus)
+ assert.Equal(t, "rauc install", cfg.Commands.RaucInstall)
+ assert.Equal(t, "shutdown -r 0", cfg.Commands.Reboot)
+}
+
+func TestConfigFile(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ cfile := filepath.Join(fixtures.GetTestfilesDir(), "sebrauc.toml")
+ cfg := GetWithFlags(cfile, 0)
+
+ assert.Equal(t, "127.0.0.1", cfg.Server.Address)
+ assert.Equal(t, 8001, cfg.Server.Port)
+
+ assert.Equal(t, 30, cfg.Server.Websocket.Ping)
+ assert.Equal(t, 10, cfg.Server.Websocket.Timeout)
+
+ assert.Equal(t, "max", cfg.Server.Compression.Gzip)
+ assert.Equal(t, "false", cfg.Server.Compression.Brotli)
+
+ assert.Equal(t, "/var/tmp", cfg.Tmpdir)
+
+ assert.Equal(t, true, cfg.Authentication.Enable)
+ assert.Equal(t, "/etc/htpasswd", cfg.Authentication.PasswdFile)
+
+ assert.Equal(t, "/etc/release", cfg.Sysinfo.ReleaseFile)
+ assert.Equal(t, "PRETTY_NAME", cfg.Sysinfo.NameKey)
+ assert.Equal(t, "VER", cfg.Sysinfo.VersionKey)
+ assert.Equal(t, "/etc/hn", cfg.Sysinfo.HostnameFile)
+ assert.Equal(t, "/proc/up", cfg.Sysinfo.UptimeFile)
+
+ assert.Equal(t, "myrauc status --output-format=json", cfg.Commands.RaucStatus)
+ assert.Equal(t, "myrauc install", cfg.Commands.RaucInstall)
+ assert.Equal(t, "reboot", cfg.Commands.Reboot)
+}
+
+func TestEnvvar(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ fixtures.ResetEnv()
+ defer fixtures.ResetEnv()
+
+ os.Setenv("SEBRAUC_TMPDIR", "/var/tmp")
+ os.Setenv("SEBRAUC_SERVER_ADDRESS", "127.0.0.1")
+ os.Setenv("SEBRAUC_SERVER_PORT", "8001")
+ os.Setenv("SEBRAUC_SERVER_WEBSOCKET_PING", "30")
+ os.Setenv("SEBRAUC_SERVER_WEBSOCKET_TIMEOUT", "10")
+ os.Setenv("SEBRAUC_SERVER_COMPRESSION_GZIP", "max")
+ os.Setenv("SEBRAUC_SERVER_COMPRESSION_BROTLI", "false")
+ os.Setenv("SEBRAUC_AUTHENTICATION_ENABLE", "true")
+ os.Setenv("SEBRAUC_AUTHENTICATION_PASSWDFILE", "/etc/htpasswd")
+ os.Setenv("SEBRAUC_SYSINFO_RELEASEFILE", "/etc/release")
+ os.Setenv("SEBRAUC_SYSINFO_NAMEKEY", "PRETTY_NAME")
+ os.Setenv("SEBRAUC_SYSINFO_VERSIONKEY", "VER")
+ os.Setenv("SEBRAUC_SYSINFO_HOSTNAMEFILE", "/etc/hn")
+ os.Setenv("SEBRAUC_SYSINFO_UPTIMEFILE", "/proc/up")
+ os.Setenv("SEBRAUC_COMMANDS_RAUCSTATUS", "myrauc status --output-format=json")
+ os.Setenv("SEBRAUC_COMMANDS_RAUCINSTALL", "myrauc install")
+ os.Setenv("SEBRAUC_COMMANDS_REBOOT", "reboot")
+
+ cfg := Get()
+
+ assert.Equal(t, "127.0.0.1", cfg.Server.Address)
+ assert.Equal(t, 8001, cfg.Server.Port)
+
+ assert.Equal(t, 30, cfg.Server.Websocket.Ping)
+ assert.Equal(t, 10, cfg.Server.Websocket.Timeout)
+
+ assert.Equal(t, "max", cfg.Server.Compression.Gzip)
+ assert.Equal(t, "false", cfg.Server.Compression.Brotli)
+
+ assert.Equal(t, "/var/tmp", cfg.Tmpdir)
+
+ assert.Equal(t, true, cfg.Authentication.Enable)
+ assert.Equal(t, "/etc/htpasswd", cfg.Authentication.PasswdFile)
+
+ assert.Equal(t, "/etc/release", cfg.Sysinfo.ReleaseFile)
+ assert.Equal(t, "PRETTY_NAME", cfg.Sysinfo.NameKey)
+ assert.Equal(t, "VER", cfg.Sysinfo.VersionKey)
+ assert.Equal(t, "/etc/hn", cfg.Sysinfo.HostnameFile)
+ assert.Equal(t, "/proc/up", cfg.Sysinfo.UptimeFile)
+
+ assert.Equal(t, "myrauc status --output-format=json", cfg.Commands.RaucStatus)
+ assert.Equal(t, "myrauc install", cfg.Commands.RaucInstall)
+ assert.Equal(t, "reboot", cfg.Commands.Reboot)
+}
+
+func TestDevMode(t *testing.T) {
+ cfg := Get()
+
+ //nolint:lll
+ assert.Equal(t, "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock status --output-format=json", cfg.Commands.RaucStatus)
+ //nolint:lll
+ assert.Equal(t, "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock install", cfg.Commands.RaucInstall)
+ assert.Equal(t, "touch /tmp/sebrauc_reboot_test", cfg.Commands.Reboot)
+}
+
+func TestFlags(t *testing.T) {
+ cfg := GetWithFlags("", 8001)
+
+ assert.Equal(t, 8001, cfg.Server.Port)
+}
+
+func TestStripTrailingSlashes(t *testing.T) {
+ tests := []struct {
+ in string
+ out string
+ }{
+ {in: "/tmp", out: "/tmp"},
+ {in: "/tmp/", out: "/tmp"},
+ {in: "/tmp///", out: "/tmp"},
+ }
+
+ for i, tt := range tests {
+ t.Run(fmt.Sprint(i), func(t *testing.T) {
+ res := stripTrailingSlashes(tt.in)
+ assert.Equal(t, tt.out, res)
+ })
+ }
+}
diff --git a/src/fixtures/rauc_mock/main.go b/src/fixtures/rauc_mock/main.go
index 4c9b884..228aeda 100644
--- a/src/fixtures/rauc_mock/main.go
+++ b/src/fixtures/rauc_mock/main.go
@@ -41,22 +41,54 @@ LastError: Failed to check bundle identifier: Invalid identifier. ` +
idle
Installing ` + "/app/demo` failed"
-func main() {
- arg := ""
- if len(os.Args) > 1 {
- arg = os.Args[1]
- }
-
- var lines string
- switch arg {
- case "fail":
- lines = outputFailure
- default:
- lines = outputSuccess
- }
+const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` +
+ `"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` +
+ `"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` +
+ `"parent":null,"mountpoint":null,"boot_status":"good"}},{"rootfs.0":` +
+ `{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` +
+ `"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}`
+func printLinesWithDelay(lines string, delay time.Duration) {
for _, line := range strings.Split(lines, "\n") {
fmt.Println(line)
- time.Sleep(500 * time.Millisecond)
+ time.Sleep(delay)
+ }
+}
+
+func getBoolEnvvar(name string) bool {
+ val := strings.ToLower(os.Getenv(name))
+ return val != "" && val != "false" && val != "0"
+}
+
+func main() {
+ method := ""
+ if len(os.Args) > 1 {
+ method = os.Args[1]
+ }
+
+ test := getBoolEnvvar("RAUC_MOCK_TEST")
+ failure := getBoolEnvvar("RAUC_MOCK_FAIL")
+
+ delay := 500 * time.Millisecond
+ if test {
+ delay = 10 * time.Millisecond
+ }
+
+ switch method {
+ case "install":
+ if failure {
+ printLinesWithDelay(outputFailure, delay)
+ } else {
+ printLinesWithDelay(outputSuccess, delay)
+ }
+ case "status":
+ if os.Args[2] != "--output-format=json" {
+ fmt.Println("output format must be json")
+ os.Exit(1)
+ }
+ fmt.Println(statusJson)
+ default:
+ fmt.Println("invalid method")
+ os.Exit(1)
}
}
diff --git a/src/fixtures/testcmd/testcmd.go b/src/fixtures/testcmd/testcmd.go
new file mode 100644
index 0000000..b157bea
--- /dev/null
+++ b/src/fixtures/testcmd/testcmd.go
@@ -0,0 +1,8 @@
+package testcmd
+
+//nolint:lll
+const (
+ RaucStatus = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock status --output-format=json"
+ RaucInstall = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock install"
+ Reboot = "touch /tmp/sebrauc_reboot_test"
+)
diff --git a/src/fixtures/testfiles/htpasswd b/src/fixtures/testfiles/htpasswd
new file mode 100644
index 0000000..c6382c7
--- /dev/null
+++ b/src/fixtures/testfiles/htpasswd
@@ -0,0 +1,3 @@
+plain:1234
+md5:$apr1$V2wxHBfb$gBU2yIYjTIeciKapglql6/
+bcrypt:$2y$05$f9rV6uTQEEnNR1saPksExOR31LauUZzpLDhpCrodAvxX3zZ6nLy12
diff --git a/src/fixtures/testfiles/os-release b/src/fixtures/testfiles/os-release
new file mode 100644
index 0000000..84a41a1
--- /dev/null
+++ b/src/fixtures/testfiles/os-release
@@ -0,0 +1,5 @@
+ID=tsgrain
+NAME="TSGRain distro"
+VERSION="0.0.1"
+VERSION_ID=0.0.1
+PRETTY_NAME="TSGRain distro 0.0.1"
diff --git a/src/fixtures/testfiles/sebrauc.toml b/src/fixtures/testfiles/sebrauc.toml
new file mode 100644
index 0000000..2700a3f
--- /dev/null
+++ b/src/fixtures/testfiles/sebrauc.toml
@@ -0,0 +1,32 @@
+# SEBRAUC config file for testing
+# Dont use for real, the commands and paths are not correct
+
+Tmpdir = "/var/tmp/"
+
+[Server]
+Address = "127.0.0.1"
+Port = 8001
+
+[Server.Websocket]
+Ping = 30
+Timeout = 10
+
+[Server.Compression]
+Gzip = "max"
+Brotli = "false"
+
+[Authentication]
+Enable = true
+PasswdFile = "/etc/htpasswd"
+
+[Sysinfo]
+ReleaseFile = "/etc/release"
+NameKey = "PRETTY_NAME"
+VersionKey = "VER"
+HostnameFile = "/etc/hn"
+UptimeFile = "/proc/up"
+
+[Commands]
+RaucStatus = "myrauc status --output-format=json"
+RaucInstall = "myrauc install"
+Reboot = "reboot"
diff --git a/src/fixtures/testutil.go b/src/fixtures/testutil.go
index ee2f352..624eb29 100644
--- a/src/fixtures/testutil.go
+++ b/src/fixtures/testutil.go
@@ -3,8 +3,11 @@ package fixtures
import (
"os"
"path/filepath"
+ "strings"
)
+var envPrefixes = []string{"SEBRAUC", "RAUC_MOCK"}
+
func doesFileExist(filepath string) bool {
_, err := os.Stat(filepath)
return !os.IsNotExist(err)
@@ -38,3 +41,20 @@ func GetTestfilesDir() string {
CdProjectRoot()
return filepath.Join("src", "fixtures", "testfiles")
}
+
+func ResetEnv() {
+ for _, envvar := range os.Environ() {
+ split := strings.SplitN(envvar, "=", 2)
+ if len(split) != 2 {
+ continue
+ }
+
+ key := split[0]
+
+ for _, prefix := range envPrefixes {
+ if strings.HasPrefix(key, prefix) {
+ _ = os.Unsetenv(key)
+ }
+ }
+ }
+}
diff --git a/src/fixtures/testutil_test.go b/src/fixtures/testutil_test.go
index 44b32ef..79941f3 100644
--- a/src/fixtures/testutil_test.go
+++ b/src/fixtures/testutil_test.go
@@ -35,3 +35,15 @@ func TestCdProjectRoot(t *testing.T) {
CdProjectRoot()
assert.True(t, doesFileExist("go.sum"))
}
+
+func TestResetEnv(t *testing.T) {
+ os.Setenv("RAUC_MOCK_TEST", "1")
+ os.Setenv("SEBRAUC_PORT", "8001")
+
+ ResetEnv()
+
+ _, exists := os.LookupEnv("RAUC_MOCK_TEST")
+ assert.False(t, exists)
+ _, exists = os.LookupEnv("SEBRAUC_PORT")
+ assert.False(t, exists)
+}
diff --git a/src/main.go b/src/main.go
index 2e83122..3fcd902 100644
--- a/src/main.go
+++ b/src/main.go
@@ -1,22 +1,45 @@
package main
import (
+ "flag"
"fmt"
"log"
+ "os"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/config"
"code.thetadev.de/TSGRain/SEBRAUC/src/server"
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
)
-func main() {
- fmt.Println("SEBRAUC " + util.Version())
+const titleArt = ` _____ __________ ____ ___ __ ________
+ / ___// ____/ __ )/ __ \/ | / / / / ____/
+ \__ \/ __/ / __ / /_/ / /| |/ / / / /
+ ___/ / /___/ /_/ / _, _/ ___ / /_/ / /___
+/____/_____/_____/_/ |_/_/ |_\____/\____/ `
- if util.TestMode {
+func main() {
+ run(os.Args[1:])
+}
+
+func run(args []string) {
+ fmt.Println(titleArt + util.Version() + "\n")
+
+ cmdFlags := flag.NewFlagSet("sebrauc", flag.ExitOnError)
+ port := cmdFlags.Int("p", 0, "HTTP port")
+ cfgPath := cmdFlags.String("c", "", "Config file path")
+ _ = cmdFlags.Parse(args)
+
+ if mode.IsDev() {
fmt.Println("Test mode active - no update operations are executed.")
fmt.Println("Build with -tags prod to enable live mode.")
}
- srv := server.NewServer(":8080")
+ cfg := config.GetWithFlags(*cfgPath, *port)
+
+ fmt.Printf("Starting server at %s:%d\n", cfg.Server.Address, cfg.Server.Port)
+
+ srv := server.NewServer(cfg)
err := srv.Run()
if err != nil {
log.Fatalln(err)
diff --git a/src/model/error.go b/src/model/error.go
new file mode 100644
index 0000000..b2f1396
--- /dev/null
+++ b/src/model/error.go
@@ -0,0 +1,24 @@
+package model
+
+// Error model
+//
+// The Error contains error relevant information.
+//
+//swagger:model Error
+type Error struct {
+ // The general error message according to HTTP specification.
+ //
+ // required: true
+ // example: Unauthorized
+ Error string `json:"error"`
+ // The http error code.
+ //
+ // required: true
+ // example: 500
+ StatusCode int `json:"status_code"`
+ // Concrete error message.
+ //
+ // required: true
+ // example: already running
+ Message string `json:"msg"`
+}
diff --git a/src/model/rauc_status.go b/src/model/rauc_status.go
new file mode 100644
index 0000000..eb0d92d
--- /dev/null
+++ b/src/model/rauc_status.go
@@ -0,0 +1,34 @@
+package model
+
+// RaucStatus model
+//
+// RaucStatus contains information about the current RAUC updater status.
+//
+//swagger:model RaucStatus
+//nolint:lll
+type RaucStatus struct {
+ // True if the installer is running
+ // required: true
+ Installing bool `json:"installing"`
+
+ // Installation progress
+ // required: true
+ // minimum: 0
+ // maximum: 100
+ Percent int `json:"percent"`
+
+ // Current installation step
+ // required: true
+ // example: Copying image to rootfs.0
+ Message string `json:"message"`
+
+ // Installation error message
+ // required: true
+ // example: Failed to check bundle identifier: Invalid identifier.
+ LastError string `json:"last_error"`
+
+ // Full command line output of the current installation
+ // required: true
+ // example: 0% Installing 0% Determining slot states 20% Determining slot states done
+ Log string `json:"log"`
+}
diff --git a/src/model/status_message.go b/src/model/status_message.go
new file mode 100644
index 0000000..d01fe03
--- /dev/null
+++ b/src/model/status_message.go
@@ -0,0 +1,17 @@
+package model
+
+// StatusMessage model
+//
+// StatusMessage contains the status of an operation.
+//
+//swagger:model StatusMessage
+type StatusMessage struct {
+ // Is operation successful?
+ // required: true
+ Success bool `json:"success"`
+
+ // Status message text
+ // required: true
+ // example: Update started
+ Msg string `json:"msg"`
+}
diff --git a/src/model/system_info.go b/src/model/system_info.go
new file mode 100644
index 0000000..6c923d7
--- /dev/null
+++ b/src/model/system_info.go
@@ -0,0 +1,75 @@
+package model
+
+// SystemInfo model
+//
+// SystemInfo contains information about the running system.
+//
+//swagger:model SystemInfo
+type SystemInfo struct {
+ // Hostname of the system
+ // required: true
+ // example: raspberrypi3
+ Hostname string `json:"hostname"`
+
+ // Name of the os distribution
+ // required: true
+ // example: Poky
+ OsName string `json:"os_name"`
+
+ // Operating system version
+ // required: true
+ // example: 1.0.2
+ OsVersion string `json:"os_version"`
+
+ // System uptime in seconds
+ // required: true
+ // example: 5832
+ Uptime int `json:"uptime"`
+
+ // Compatible firmware name
+ // required: true
+ // example: Poky
+ RaucCompatible string `json:"rauc_compatible"`
+
+ // Compatible firmware variant
+ // required: true
+ // example: rpi-prod
+ RaucVariant string `json:"rauc_variant"`
+
+ // List of RAUC root filesystems
+ // required: true
+ RaucRootfs map[string]Rootfs `json:"rauc_rootfs"`
+}
+
+//swagger:model Rootfs
+type Rootfs struct {
+ // Block device
+ // required: true
+ // example: /dev/mmcblk0p2
+ Device string `json:"device"`
+
+ // Filesystem
+ // required: true
+ // example: ext4
+ Type string `json:"type"`
+
+ // Mount path (null when not mounted)
+ // required: true
+ // nullable: true
+ // example: /
+ Mountpoint *string `json:"mountpoint"`
+
+ // Is the filesystem bootable?
+ // required: true
+ Bootable bool `json:"bootable"`
+
+ // Is the filesystem booted?
+ // required: true
+ Booted bool `json:"booted"`
+
+ // Is the filesystem the next boot target?
+ // required: true
+ Primary bool `json:"primary"`
+
+ Bootname string `json:"-"`
+}
diff --git a/src/rauc/rauc.go b/src/rauc/rauc.go
index 7585285..d4b81dc 100644
--- a/src/rauc/rauc.go
+++ b/src/rauc/rauc.go
@@ -9,6 +9,7 @@ import (
"strconv"
"sync"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/model"
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
)
@@ -18,32 +19,26 @@ var (
)
type Rauc struct {
- broadcast chan string
- status RaucStatus
- runningMtx sync.Mutex
+ cmdRaucInstall string
+ bc util.Broadcaster
+ status model.RaucStatus
+ runningMtx sync.Mutex
}
-type RaucStatus struct {
- Installing bool `json:"installing"`
- Percent int `json:"percent"`
- Message string `json:"message"`
- LastError string `json:"last_error"`
- Log string `json:"log"`
-}
-
-func NewRauc(broadcast chan string) *Rauc {
- r := &Rauc{
- broadcast: broadcast,
+func New(cmdRaucInstall string) *Rauc {
+ return &Rauc{
+ cmdRaucInstall: cmdRaucInstall,
}
+}
- r.broadcast <- r.GetStatusJson()
-
- return r
+func (r *Rauc) SetBroadcaster(bc util.Broadcaster) {
+ r.bc = bc
+ r.bcStatus()
}
func (r *Rauc) completed(updateFile string) {
r.status.Installing = false
- r.broadcast <- r.GetStatusJson()
+ r.bcStatus()
_ = os.Remove(updateFile)
}
@@ -65,12 +60,12 @@ func (r *Rauc) RunRauc(updateFile string) error {
}
// Reset installer
- r.status = RaucStatus{
+ r.status = model.RaucStatus{
Installing: true,
}
- r.broadcast <- r.GetStatusJson()
+ r.bcStatus()
- cmd := util.CommandFromString(fmt.Sprintf("%s %s", util.UpdateCmd, updateFile))
+ cmd := util.CommandFromString(r.cmdRaucInstall + " " + updateFile)
readPipe, _ := cmd.StdoutPipe()
cmd.Stderr = cmd.Stdout
@@ -100,7 +95,7 @@ func (r *Rauc) RunRauc(updateFile string) error {
}
if hasUpdate {
- r.broadcast <- r.GetStatusJson()
+ r.bcStatus()
}
}
}()
@@ -122,11 +117,19 @@ func (r *Rauc) RunRauc(updateFile string) error {
return nil
}
-func (r *Rauc) GetStatus() RaucStatus {
+func (r *Rauc) GetStatus() model.RaucStatus {
return r.status
}
-func (r *Rauc) GetStatusJson() string {
- statusJson, _ := json.Marshal(r.status)
- return string(statusJson)
+func (r *Rauc) GetStatusJson() []byte {
+ statusJson, err := json.Marshal(r.status)
+ if err != nil {
+ return []byte{}
+ }
+
+ return statusJson
+}
+
+func (r *Rauc) bcStatus() {
+ r.bc.Broadcast(r.GetStatusJson())
}
diff --git a/src/rauc/rauc_test.go b/src/rauc/rauc_test.go
new file mode 100644
index 0000000..19595cd
--- /dev/null
+++ b/src/rauc/rauc_test.go
@@ -0,0 +1,118 @@
+package rauc
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "github.com/stretchr/testify/assert"
+)
+
+type broadcasterMock struct {
+ messages []string
+}
+
+func (b *broadcasterMock) Broadcast(msg []byte) {
+ b.messages = append(b.messages, string(msg))
+}
+
+func TestRauc(t *testing.T) {
+ //nolint:lll
+ tests := []struct {
+ name string
+ fail string
+ messages []string
+ }{
+ {
+ name: "ok",
+ fail: "",
+ messages: []string{
+ "{\"installing\":false,\"percent\":0,\"message\":\"\",\"last_error\":\"\",\"log\":\"\"}",
+ "{\"installing\":true,\"percent\":0,\"message\":\"\",\"last_error\":\"\",\"log\":\"\"}",
+ "{\"installing\":true,\"percent\":0,\"message\":\"Installing\",\"last_error\":\"\",\"log\":\"0% Installing\\n\"}",
+ "{\"installing\":true,\"percent\":0,\"message\":\"Determining slot states\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n\"}",
+ "{\"installing\":true,\"percent\":20,\"message\":\"Determining slot states done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n\"}",
+ "{\"installing\":true,\"percent\":20,\"message\":\"Checking bundle\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n\"}",
+ "{\"installing\":true,\"percent\":20,\"message\":\"Verifying signature\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n\"}",
+ "{\"installing\":true,\"percent\":40,\"message\":\"Verifying signature done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n\"}",
+ "{\"installing\":true,\"percent\":40,\"message\":\"Checking bundle done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n\"}",
+ "{\"installing\":true,\"percent\":40,\"message\":\"Checking manifest contents\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n\"}",
+ "{\"installing\":true,\"percent\":60,\"message\":\"Checking manifest contents done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n\"}",
+ "{\"installing\":true,\"percent\":60,\"message\":\"Determining target install group\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n\"}",
+ "{\"installing\":true,\"percent\":80,\"message\":\"Determining target install group done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n\"}",
+ "{\"installing\":true,\"percent\":80,\"message\":\"Updating slots\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n\"}",
+ "{\"installing\":true,\"percent\":80,\"message\":\"Checking slot rootfs.0\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n80% Checking slot rootfs.0\\n\"}",
+ "{\"installing\":true,\"percent\":90,\"message\":\"Checking slot rootfs.0 done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n80% Checking slot rootfs.0\\n90% Checking slot rootfs.0 done.\\n\"}",
+ "{\"installing\":true,\"percent\":90,\"message\":\"Copying image to rootfs.0\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n80% Checking slot rootfs.0\\n90% Checking slot rootfs.0 done.\\n90% Copying image to rootfs.0\\n\"}",
+ "{\"installing\":true,\"percent\":100,\"message\":\"Copying image to rootfs.0 done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n80% Checking slot rootfs.0\\n90% Checking slot rootfs.0 done.\\n90% Copying image to rootfs.0\\n100% Copying image to rootfs.0 done.\\n\"}",
+ "{\"installing\":true,\"percent\":100,\"message\":\"Updating slots done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n80% Checking slot rootfs.0\\n90% Checking slot rootfs.0 done.\\n90% Copying image to rootfs.0\\n100% Copying image to rootfs.0 done.\\n100% Updating slots done.\\n\"}",
+ "{\"installing\":true,\"percent\":100,\"message\":\"Installing done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n80% Checking slot rootfs.0\\n90% Checking slot rootfs.0 done.\\n90% Copying image to rootfs.0\\n100% Copying image to rootfs.0 done.\\n100% Updating slots done.\\n100% Installing done.\\n\"}",
+ "{\"installing\":false,\"percent\":100,\"message\":\"Installing done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n20% Verifying signature\\n40% Verifying signature done.\\n40% Checking bundle done.\\n40% Checking manifest contents\\n60% Checking manifest contents done.\\n60% Determining target install group\\n80% Determining target install group done.\\n80% Updating slots\\n80% Checking slot rootfs.0\\n90% Checking slot rootfs.0 done.\\n90% Copying image to rootfs.0\\n100% Copying image to rootfs.0 done.\\n100% Updating slots done.\\n100% Installing done.\\nInstalling `/app/tsgrain-update-raspberrypi3.raucb` succeeded\\n\"}",
+ },
+ },
+ {
+ name: "fail",
+ fail: "1",
+ messages: []string{
+ "{\"installing\":false,\"percent\":0,\"message\":\"\",\"last_error\":\"\",\"log\":\"\"}",
+ "{\"installing\":true,\"percent\":0,\"message\":\"\",\"last_error\":\"\",\"log\":\"\"}",
+ "{\"installing\":true,\"percent\":0,\"message\":\"Installing\",\"last_error\":\"\",\"log\":\"0% Installing\\n\"}",
+ "{\"installing\":true,\"percent\":0,\"message\":\"Determining slot states\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n\"}",
+ "{\"installing\":true,\"percent\":20,\"message\":\"Determining slot states done.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n\"}",
+ "{\"installing\":true,\"percent\":20,\"message\":\"Checking bundle\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n\"}",
+ "{\"installing\":true,\"percent\":40,\"message\":\"Checking bundle failed.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n40% Checking bundle failed.\\n\"}",
+ "{\"installing\":true,\"percent\":100,\"message\":\"Installing failed.\",\"last_error\":\"\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n40% Checking bundle failed.\\n100% Installing failed.\\n\"}",
+ "{\"installing\":true,\"percent\":100,\"message\":\"Installing failed.\",\"last_error\":\"Failed to check bundle identifier: Invalid identifier. Did you pass a valid RAUC bundle?\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n40% Checking bundle failed.\\n100% Installing failed.\\nLastError: Failed to check bundle identifier: Invalid identifier. Did you pass a valid RAUC bundle?\\n\"}",
+ "{\"installing\":false,\"percent\":100,\"message\":\"Installing failed.\",\"last_error\":\"Failed to check bundle identifier: Invalid identifier. Did you pass a valid RAUC bundle?\",\"log\":\"0% Installing\\n0% Determining slot states\\n20% Determining slot states done.\\n20% Checking bundle\\n40% Checking bundle failed.\\n100% Installing failed.\\nLastError: Failed to check bundle identifier: Invalid identifier. Did you pass a valid RAUC bundle?\\nInstalling /app/demo` failed\\n\"}",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fixtures.ResetEnv()
+ defer fixtures.ResetEnv()
+
+ os.Setenv("RAUC_MOCK_TEST", "1")
+ os.Setenv("RAUC_MOCK_FAIL", tt.fail)
+
+ updater := New(testcmd.RaucInstall)
+ bc := &broadcasterMock{}
+ updater.SetBroadcaster(bc)
+
+ testfile := createTmpfile()
+
+ err := updater.RunRauc(testfile)
+ assert.NoError(t, err)
+
+ // Dont run multiple updates concurrently
+ err = updater.RunRauc(testfile)
+ assert.ErrorIs(t, err, util.ErrAlreadyRunning)
+
+ // Wait for updater to finish
+ for updater.GetStatus().Installing {
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ assert.False(t, util.DoesFileExist(testfile), "update file was not deleted")
+
+ assert.Equal(t, tt.messages, bc.messages)
+ })
+ }
+}
+
+func createTmpfile() string {
+ tmpdir := util.GetTmpdir("")
+
+ tmpfile := filepath.Join(tmpdir, "test.raucb")
+ _, err := os.Create(tmpfile)
+ if err != nil {
+ panic(err)
+ }
+
+ return tmpfile
+}
diff --git a/src/server/hub.go b/src/server/hub.go
deleted file mode 100644
index 77e1dfd..0000000
--- a/src/server/hub.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package server
-
-import (
- "log"
- "sync"
-
- "github.com/gofiber/websocket/v2"
-)
-
-type hubClient struct{}
-
-type MessageHub struct {
- Broadcast chan string
-
- clients map[*websocket.Conn]hubClient
- register chan *websocket.Conn
- unregister chan *websocket.Conn
- lastMessage string
-
- running bool
- runningMtx sync.Mutex
-}
-
-func NewHub() *MessageHub {
- return &MessageHub{
- clients: make(map[*websocket.Conn]hubClient),
- register: make(chan *websocket.Conn),
- Broadcast: make(chan string, 5),
- unregister: make(chan *websocket.Conn),
- }
-}
-
-func (hub *MessageHub) sendMessage(conn *websocket.Conn, message string) {
- if err := conn.WriteMessage(
- websocket.TextMessage, []byte(message)); err != nil {
- log.Println("write error:", err)
-
- _ = conn.WriteMessage(websocket.CloseMessage, []byte{})
- _ = conn.Close()
- delete(hub.clients, conn)
- }
-}
-
-func (hub *MessageHub) Run() {
- hub.runningMtx.Lock()
- isRunning := hub.running
- hub.running = true
- hub.runningMtx.Unlock()
-
- if isRunning {
- return
- }
-
- for {
- select {
- case conn := <-hub.register:
- hub.clients[conn] = hubClient{}
- log.Println("connection registered")
-
- case message := <-hub.Broadcast:
- log.Println("message received:", message)
- hub.lastMessage = message
-
- // Send the message to all clients
- for conn := range hub.clients {
- hub.sendMessage(conn, message)
- }
-
- case conn := <-hub.unregister:
- // Remove the client from the hub
- delete(hub.clients, conn)
-
- log.Println("connection unregistered")
- }
- }
-}
-
-func (hub *MessageHub) Handler(conn *websocket.Conn) {
- // When the function returns, unregister the client and close the connection
- defer func() {
- hub.unregister <- conn
- conn.Close()
- }()
-
- // Register the client
- hub.register <- conn
-
- if hub.lastMessage != "" {
- hub.sendMessage(conn, hub.lastMessage)
- }
-
- for {
- _, _, err := conn.ReadMessage()
- if err != nil {
- return // Calls the deferred function, i.e. closes the connection on error
- }
- }
-}
diff --git a/src/server/middleware/authentication.go b/src/server/middleware/authentication.go
new file mode 100644
index 0000000..c2f33f2
--- /dev/null
+++ b/src/server/middleware/authentication.go
@@ -0,0 +1,36 @@
+package middleware
+
+import (
+ "net/http"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "github.com/gin-gonic/gin"
+ "github.com/tg123/go-htpasswd"
+)
+
+var pwdFilePaths = []string{"htpasswd", "/etc/sebrauc/htpasswd"}
+
+// Authentication requires HTTP basic auth or an active session
+func Authentication(pwdFile string) gin.HandlerFunc {
+ fpath, err := util.FindFile(pwdFile, pwdFilePaths, nil)
+ if err != nil {
+ panic("passwd file not found: " + err.Error())
+ }
+
+ myauth, err := htpasswd.New(fpath, htpasswd.DefaultSystems, nil)
+ if err != nil {
+ panic(err)
+ }
+
+ return func(c *gin.Context) {
+ if user, pass, ok := c.Request.BasicAuth(); ok {
+ if myauth.Match(user, pass) {
+ c.Set(gin.AuthUserKey, user)
+ return
+ }
+ }
+
+ c.Header("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
+ c.AbortWithStatus(http.StatusUnauthorized)
+ }
+}
diff --git a/src/server/middleware/authentication_test.go b/src/server/middleware/authentication_test.go
new file mode 100644
index 0000000..f430032
--- /dev/null
+++ b/src/server/middleware/authentication_test.go
@@ -0,0 +1,54 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAuthentication(t *testing.T) {
+ testfiles := fixtures.GetTestfilesDir()
+ pwdfile := filepath.Join(testfiles, "htpasswd")
+
+ router := gin.New()
+ router.Use(Authentication(pwdfile))
+ router.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "HelloWorld") })
+
+ tests := []struct {
+ name string
+ }{
+ {name: "plain"},
+ {name: "md5"},
+ {name: "bcrypt"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name+"_ok", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/", nil)
+ req.SetBasicAuth(tt.name, "1234")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "HelloWorld", w.Body.String())
+ assert.Empty(t, w.Header().Get("WWW-Authenticate"))
+ })
+ }
+
+ t.Run("fail", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/", nil)
+ req.SetBasicAuth("plain", "asdf")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+ assert.Empty(t, w.Body.String())
+ assert.Equal(t, "Basic realm=\"Authorization Required\"",
+ w.Header().Get("WWW-Authenticate"))
+ })
+}
diff --git a/src/server/middleware/cache.go b/src/server/middleware/cache.go
new file mode 100644
index 0000000..8c97b26
--- /dev/null
+++ b/src/server/middleware/cache.go
@@ -0,0 +1,7 @@
+package middleware
+
+import "github.com/gin-gonic/gin"
+
+func Cache(c *gin.Context) {
+ c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable")
+}
diff --git a/src/server/middleware/cache_test.go b/src/server/middleware/cache_test.go
new file mode 100644
index 0000000..eaf7f84
--- /dev/null
+++ b/src/server/middleware/cache_test.go
@@ -0,0 +1,25 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCache(t *testing.T) {
+ router := gin.New()
+ router.Use(Cache)
+ router.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "HelloWorld") })
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "HelloWorld", w.Body.String())
+ assert.Equal(t, "public, max-age=604800, immutable",
+ w.Header().Get("Cache-Control"))
+}
diff --git a/src/server/middleware/compression.go b/src/server/middleware/compression.go
new file mode 100644
index 0000000..0fb0100
--- /dev/null
+++ b/src/server/middleware/compression.go
@@ -0,0 +1,14 @@
+package middleware
+
+import (
+ "code.thetadev.de/TSGRain/ginzip"
+ "github.com/gin-gonic/gin"
+)
+
+func Compression(gzip, brotli string) gin.HandlerFunc {
+ opts := ginzip.DefaultOptions()
+ opts.GzipLevel = gzip
+ opts.BrotliLevel = brotli
+
+ return ginzip.New(opts)
+}
diff --git a/src/server/middleware/error_handler.go b/src/server/middleware/error_handler.go
new file mode 100644
index 0000000..94aac0f
--- /dev/null
+++ b/src/server/middleware/error_handler.go
@@ -0,0 +1,58 @@
+package middleware
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/model"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ nice "github.com/ekyoung/gin-nice-recovery"
+ "github.com/gin-gonic/gin"
+)
+
+// ErrorHandler creates a gin middleware for handling errors.
+func ErrorHandler(isApi bool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Next()
+
+ if len(c.Errors) > 0 {
+ for _, e := range c.Errors {
+ writeError(c, e.Err, isApi)
+ }
+ }
+ }
+}
+
+func PanicHandler(isApi bool) gin.HandlerFunc {
+ return nice.Recovery(func(c *gin.Context, err interface{}) {
+ writeError(c, fmt.Errorf("[PANIC] %s", err), isApi)
+ })
+}
+
+func writeError(c *gin.Context, err error, isApi bool) {
+ status := http.StatusInternalServerError
+
+ var httpErr util.HttpError
+ if errors.As(err, &httpErr) {
+ status = httpErr.StatusCode()
+ }
+
+ // only write error message if there is no content
+ if c.Writer.Size() != -1 {
+ c.Status(status)
+ return
+ }
+
+ if isApi {
+ // Machine-readable JSON error message
+ c.JSON(status, &model.Error{
+ Error: http.StatusText(status),
+ StatusCode: status,
+ Message: err.Error(),
+ })
+ } else {
+ // Human-readable error message
+ c.String(status, "%d %s: %s", status, http.StatusText(status), err.Error())
+ }
+}
diff --git a/src/server/middleware/error_handler_test.go b/src/server/middleware/error_handler_test.go
new file mode 100644
index 0000000..4efedb1
--- /dev/null
+++ b/src/server/middleware/error_handler_test.go
@@ -0,0 +1,140 @@
+package middleware
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestErrorHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ controller gin.HandlerFunc
+ isApi bool
+ expectResponse string
+ expectStatus int
+ }{
+ {
+ name: "error",
+ controller: controllerError,
+ isApi: false,
+ expectResponse: "400 Bad Request: error test",
+ expectStatus: http.StatusBadRequest,
+ },
+ {
+ name: "error_api",
+ controller: controllerError,
+ isApi: true,
+ //nolint:lll
+ expectResponse: `{"error":"Bad Request","status_code":400,"msg":"error test"}`,
+ expectStatus: http.StatusBadRequest,
+ },
+ {
+ name: "generic_error",
+ controller: controllerErrorGeneric,
+ isApi: false,
+ expectResponse: "500 Internal Server Error: generic error",
+ expectStatus: http.StatusInternalServerError,
+ },
+ {
+ name: "generic_error_api",
+ controller: controllerErrorGeneric,
+ isApi: true,
+ //nolint:lll
+ expectResponse: `{"error":"Internal Server Error","status_code":500,"msg":"generic error"}`,
+ expectStatus: http.StatusInternalServerError,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ router := gin.New()
+ router.Use(ErrorHandler(tt.isApi))
+ router.GET("/", tt.controller)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, tt.expectStatus, w.Code)
+ assert.Equal(t, tt.expectResponse, w.Body.String())
+ })
+ }
+}
+
+func TestPanicHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ controller gin.HandlerFunc
+ isApi bool
+ expectResponse string
+ expectStatus int
+ }{
+ {
+ name: "panic",
+ controller: controllerPanic,
+ isApi: false,
+ expectResponse: "500 Internal Server Error: [PANIC] panic message",
+ expectStatus: http.StatusInternalServerError,
+ },
+ {
+ name: "panic_api",
+ controller: controllerPanic,
+ isApi: true,
+ //nolint:lll
+ expectResponse: `{"error":"Internal Server Error","status_code":500,"msg":"[PANIC] panic message"}`,
+ expectStatus: http.StatusInternalServerError,
+ },
+ {
+ name: "panic_w_error",
+ controller: controllerPanicErr,
+ isApi: false,
+ expectResponse: "500 Internal Server Error: [PANIC] panic message in error",
+ expectStatus: http.StatusInternalServerError,
+ },
+ {
+ name: "panic_w_error_api",
+ controller: controllerPanicErr,
+ isApi: true,
+ //nolint:lll
+ expectResponse: `{"error":"Internal Server Error","status_code":500,"msg":"[PANIC] panic message in error"}`,
+ expectStatus: http.StatusInternalServerError,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ router := gin.New()
+ router.Use(PanicHandler(tt.isApi))
+ router.GET("/", tt.controller)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, tt.expectStatus, w.Code)
+ assert.Equal(t, tt.expectResponse, w.Body.String())
+ })
+ }
+}
+
+func controllerError(c *gin.Context) {
+ c.Error(util.HttpErrNew("error test", http.StatusBadRequest))
+}
+
+func controllerErrorGeneric(c *gin.Context) {
+ c.Error(errors.New("generic error"))
+}
+
+func controllerPanic(c *gin.Context) {
+ panic("panic message")
+}
+
+func controllerPanicErr(c *gin.Context) {
+ panic(errors.New("panic message in error"))
+}
diff --git a/src/server/server.go b/src/server/server.go
index b9ca070..fe41c45 100644
--- a/src/server/server.go
+++ b/src/server/server.go
@@ -1,158 +1,266 @@
+// SEBRAUC
+//
+// # REST API for the SEBRAUC firmware updater
+//
+// ---
+// Schemes: http, https
+// Version: 0.2.0
+// License: MIT
+//
+// swagger:meta
package server
import (
"errors"
"fmt"
"net/http"
- "strings"
"time"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/config"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/model"
"code.thetadev.de/TSGRain/SEBRAUC/src/rauc"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/server/middleware"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/server/stream"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/server/swagger"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/sysinfo"
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
"code.thetadev.de/TSGRain/SEBRAUC/ui"
- "github.com/gofiber/fiber/v2"
- "github.com/gofiber/fiber/v2/middleware/compress"
- "github.com/gofiber/fiber/v2/middleware/cors"
- "github.com/gofiber/fiber/v2/middleware/filesystem"
- "github.com/gofiber/fiber/v2/middleware/logger"
- "github.com/gofiber/websocket/v2"
+ "github.com/gin-contrib/cors"
+ "github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type SEBRAUCServer struct {
- address string
- raucUpdater *rauc.Rauc
- hub *MessageHub
- tmpdir string
+ config *config.Config
+ streamer *stream.API
+ updater *rauc.Rauc
+ sysinfo *sysinfo.Sysinfo
+ tmpdir string
}
-type statusMessage struct {
- Success bool `json:"success"`
- Msg string `json:"msg"`
-}
+func NewServer(config *config.Config) *SEBRAUCServer {
+ updater := rauc.New(config.Commands.RaucInstall)
+ streamer := stream.New(
+ time.Duration(config.Server.Websocket.Ping)*time.Second,
+ time.Duration(config.Server.Websocket.Timeout)*time.Second,
+ []string{},
+ )
+ sysinfo := sysinfo.New(
+ config.Commands.RaucStatus,
+ config.Sysinfo.ReleaseFile,
+ config.Sysinfo.NameKey,
+ config.Sysinfo.VersionKey,
+ config.Sysinfo.HostnameFile,
+ config.Sysinfo.UptimeFile,
+ )
-func NewServer(address string) *SEBRAUCServer {
- hub := NewHub()
+ updater.SetBroadcaster(streamer)
- raucUpdater := rauc.NewRauc(hub.Broadcast)
-
- tmpdir, err := util.GetTmpdir()
- if err != nil {
- panic(err)
- }
+ tmpdir := util.GetTmpdir(config.Tmpdir)
return &SEBRAUCServer{
- address: address,
- raucUpdater: raucUpdater,
- hub: hub,
- tmpdir: tmpdir,
+ config: config,
+ updater: updater,
+ streamer: streamer,
+ sysinfo: sysinfo,
+ tmpdir: tmpdir,
}
}
+func (srv *SEBRAUCServer) getRouter() *gin.Engine {
+ router := gin.New()
+ router.Use(gin.Logger())
+ _ = router.SetTrustedProxies(nil)
+
+ if mode.IsDev() {
+ router.Use(cors.Default())
+ }
+
+ router.Use(middleware.ErrorHandler(false), middleware.PanicHandler(false))
+ router.NoRoute(func(c *gin.Context) { c.Error(util.ErrPageNotFound) })
+
+ if srv.config.Authentication.Enable {
+ router.Use(middleware.Authentication(srv.config.Authentication.PasswdFile))
+ }
+
+ api := router.Group("/api",
+ middleware.ErrorHandler(true), middleware.PanicHandler(true))
+
+ // API ROUTES
+ api.GET("/ws", srv.streamer.Handle)
+ api.GET("/status", srv.controllerStatus)
+ api.GET("/info", srv.controllerInfo)
+
+ api.POST("/update", srv.controllerUpdate)
+ api.POST("/reboot", srv.controllerReboot)
+
+ // Error routes for testing
+ if mode.IsDev() {
+ router.GET("/error", srv.controllerError)
+ router.GET("/panic", srv.controllerPanic)
+
+ api.GET("/error", srv.controllerError)
+ api.GET("/panic", srv.controllerPanic)
+ }
+
+ // UI
+ uiGroup := router.Group("/", middleware.Compression(
+ srv.config.Server.Compression.Gzip,
+ srv.config.Server.Compression.Brotli),
+ )
+ ui.Register(uiGroup)
+ swagger.Register(uiGroup)
+
+ return router
+}
+
func (srv *SEBRAUCServer) Run() error {
- app := fiber.New(fiber.Config{
- AppName: "SEBRAUC",
- BodyLimit: 1024 * 1024 * 1024,
- ErrorHandler: errorHandler,
- DisableStartupMessage: true,
- })
+ router := srv.getRouter()
- app.Use(logger.New())
-
- app.Use(compress.New(compress.Config{
- Next: func(c *fiber.Ctx) bool {
- return strings.HasPrefix(c.Path(), "/api")
- },
- }))
-
- // just for testing
- app.Use("/api", cors.New())
-
- app.Use("/api/ws", func(c *fiber.Ctx) error {
- // IsWebSocketUpgrade returns true if the client
- // requested upgrade to the WebSocket protocol.
- if websocket.IsWebSocketUpgrade(c) {
- c.Locals("allowed", true)
- return c.Next()
- }
- return fiber.ErrUpgradeRequired
- })
-
- app.Use("/", filesystem.New(filesystem.Config{
- Root: http.FS(ui.Assets),
- PathPrefix: ui.AssetsDir,
- MaxAge: 7200,
- }))
-
- // ROUTES
- app.Get("/api/ws", websocket.New(srv.hub.Handler))
- app.Post("/api/update", srv.controllerUpdate)
- app.Get("/api/status", srv.controllerStatus)
- app.Post("/api/reboot", srv.controllerReboot)
-
- // Start messaging hub
- go srv.hub.Run()
-
- return app.Listen(srv.address)
+ return router.Run(fmt.Sprintf("%s:%d",
+ srv.config.Server.Address, srv.config.Server.Port))
}
-func (srv *SEBRAUCServer) controllerUpdate(c *fiber.Ctx) error {
+// swagger:operation POST /update startUpdate
+//
+// # Start the update process
+//
+// ---
+// consumes:
+// - multipart/form-data
+// produces: [application/json]
+// parameters:
+// - name: updateFile
+// in: formData
+// description: RAUC firmware image file (*.raucb)
+// required: true
+// type: file
+//
+// responses:
+//
+// 200:
+// description: Ok
+// schema:
+// $ref: "#/definitions/StatusMessage"
+// 409:
+// description: already running
+// schema:
+// $ref: "#/definitions/Error"
+// 500:
+// description: Server Error
+// schema:
+// $ref: "#/definitions/Error"
+func (srv *SEBRAUCServer) controllerUpdate(c *gin.Context) {
file, err := c.FormFile("updateFile")
if err != nil {
- return err
+ c.Error(err)
+ return
}
uid, err := uuid.NewRandom()
if err != nil {
- return err
+ c.Error(err)
+ return
}
updateFile := fmt.Sprintf("%s/update_%s.raucb", srv.tmpdir, uid.String())
- err = c.SaveFile(file, updateFile)
+ err = c.SaveUploadedFile(file, updateFile)
if err != nil {
- return err
+ c.Error(err)
+ return
}
- err = srv.raucUpdater.RunRauc(updateFile)
+ err = srv.updater.RunRauc(updateFile)
if err == nil {
writeStatus(c, true, "Update started")
- } else if errors.Is(err, util.ErrAlreadyRunning) {
- return fiber.NewError(fiber.StatusConflict, "already running")
} else {
- return err
+ c.Error(err)
+ return
}
- return nil
}
-func (srv *SEBRAUCServer) controllerStatus(c *fiber.Ctx) error {
- c.Context().SetStatusCode(200)
- _ = c.JSON(srv.raucUpdater.GetStatus())
- return nil
+// swagger:operation GET /status getStatus
+//
+// # Get the current status of the RAUC updater
+//
+// ---
+// produces: [application/json]
+// responses:
+//
+// 200:
+// description: Ok
+// schema:
+// $ref: "#/definitions/RaucStatus"
+// 500:
+// description: Server Error
+// schema:
+// $ref: "#/definitions/Error"
+func (srv *SEBRAUCServer) controllerStatus(c *gin.Context) {
+ c.JSON(http.StatusOK, srv.updater.GetStatus())
}
-func (srv *SEBRAUCServer) controllerReboot(c *fiber.Ctx) error {
- go util.Reboot(5 * time.Second)
+// swagger:operation GET /info getInfo
+//
+// # Get the current system info
+//
+// ---
+// produces: [application/json]
+// responses:
+//
+// 200:
+// description: Ok
+// schema:
+// $ref: "#/definitions/SystemInfo"
+// 500:
+// description: Server Error
+// schema:
+// $ref: "#/definitions/Error"
+func (srv *SEBRAUCServer) controllerInfo(c *gin.Context) {
+ info, err := srv.sysinfo.GetSysinfo()
+ if err != nil {
+ c.Error(err)
+ } else {
+ c.JSON(http.StatusOK, info)
+ }
+}
+
+// swagger:operation POST /reboot startReboot
+//
+// # Reboot the system
+//
+// ---
+// produces: [application/json]
+// responses:
+//
+// 200:
+// description: Ok
+// schema:
+// $ref: "#/definitions/StatusMessage"
+// 500:
+// description: Server Error
+// schema:
+// $ref: "#/definitions/Error"
+func (srv *SEBRAUCServer) controllerReboot(c *gin.Context) {
+ go util.Reboot(srv.config.Commands.Reboot, 5*time.Second)
writeStatus(c, true, "System is rebooting")
- return nil
}
-func errorHandler(c *fiber.Ctx, err error) error {
- // API error handling
- if strings.HasPrefix(c.Path(), "/api") {
- writeStatus(c, false, err.Error())
- }
- return err
+// controllerError throws an error for testing
+func (srv *SEBRAUCServer) controllerError(c *gin.Context) {
+ c.Error(util.HttpErrNew("error test", http.StatusBadRequest))
}
-func writeStatus(c *fiber.Ctx, success bool, msg string) {
- _ = c.JSON(statusMessage{
+// controllerPanic panics for testing
+func (srv *SEBRAUCServer) controllerPanic(c *gin.Context) {
+ panic(errors.New("panic message"))
+}
+
+func writeStatus(c *gin.Context, success bool, msg string) {
+ c.JSON(http.StatusOK, model.StatusMessage{
Success: success,
Msg: msg,
})
-
- if success {
- c.Context().SetStatusCode(200)
- }
}
diff --git a/src/server/server_test.go b/src/server/server_test.go
new file mode 100644
index 0000000..30b25b1
--- /dev/null
+++ b/src/server/server_test.go
@@ -0,0 +1,206 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "regexp"
+ "testing"
+ "time"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/config"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/model"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+type testServer struct {
+ srv *SEBRAUCServer
+ router *gin.Engine
+}
+
+func newTestServer() *testServer {
+ return newTestServerCfg(config.GetDefault())
+}
+
+func newTestServerCfg(cfg *config.Config) *testServer {
+ sebraucServer := NewServer(cfg)
+ router := sebraucServer.getRouter()
+
+ return &testServer{
+ srv: sebraucServer,
+ router: router,
+ }
+}
+
+func (srv *testServer) testRequest(t assert.TestingT, method string, url string,
+ body io.Reader, contentType string,
+) *httptest.ResponseRecorder {
+ req, err := http.NewRequest(method, url, body)
+ req.Header.Set("Content-Type", contentType)
+ assert.Nil(t, err)
+
+ w := httptest.NewRecorder()
+ srv.router.ServeHTTP(w, req)
+
+ return w
+}
+
+func TestUpdate(t *testing.T) {
+ fixtures.ResetEnv()
+ defer fixtures.ResetEnv()
+ util.RemoveTmpdir("")
+
+ os.Setenv("RAUC_MOCK_TEST", "1")
+
+ srv := newTestServer()
+
+ updateContent := []byte("mock update file")
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("updateFile", "update.raucb")
+ assert.Nil(t, err)
+ _, err = part.Write(updateContent)
+ assert.Nil(t, err)
+ err = writer.Close()
+ assert.Nil(t, err)
+
+ w := srv.testRequest(t, "POST", "/api/update", body, writer.FormDataContentType())
+
+ assert.Equal(t, 200, w.Code)
+ assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
+ assert.Equal(t,
+ `{"success":true,"msg":"Update started"}`,
+ w.Body.String(),
+ )
+
+ // Find update file
+ tmpdir := util.GetTmpdir("")
+ //nolint:lll
+ updateFileExp := regexp.MustCompile(
+ `update_[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\.raucb`)
+ updateFile := ""
+
+ tmpdirObj, err := os.Open(tmpdir)
+ assert.Nil(t, err)
+ list, err := tmpdirObj.ReadDir(-1)
+ assert.Nil(t, err)
+
+ for _, f := range list {
+ if updateFileExp.MatchString(f.Name()) {
+ updateFile = filepath.Join(tmpdir, f.Name())
+ break
+ }
+ }
+ assert.NotEmpty(t, updateFile, "update file not found")
+
+ // Check update file
+ content, err := os.ReadFile(updateFile)
+ assert.Nil(t, err)
+ assert.Equal(t, updateContent, content)
+
+ // Wait for update to complete
+ time.Sleep(1000 * time.Millisecond)
+
+ // Update file should be removed when update is completed
+ assert.NoFileExists(t, updateFile)
+
+ // Get final status
+ w = srv.testRequest(t, "GET", "/api/status", nil, "")
+
+ assert.Equal(t, 200, w.Code)
+ assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
+ //nolint:lll
+ assert.Equal(t,
+ `{"installing":false,"percent":100,"message":"Installing done.","last_error":"","log":"0% Installing\n0% Determining slot states\n20% Determining slot states done.\n20% Checking bundle\n20% Verifying signature\n40% Verifying signature done.\n40% Checking bundle done.\n40% Checking manifest contents\n60% Checking manifest contents done.\n60% Determining target install group\n80% Determining target install group done.\n80% Updating slots\n80% Checking slot rootfs.0\n90% Checking slot rootfs.0 done.\n90% Copying image to rootfs.0\n100% Copying image to rootfs.0 done.\n100% Updating slots done.\n100% Installing done.\nInstalling `+"`/app/tsgrain-update-raspberrypi3.raucb`"+` succeeded\n"}`,
+ w.Body.String(),
+ )
+}
+
+func TestStatus(t *testing.T) {
+ srv := newTestServer()
+ w := srv.testRequest(t, "GET", "/api/status", nil, "")
+
+ assert.Equal(t, 200, w.Code)
+ assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
+ assert.Equal(t,
+ `{"installing":false,"percent":0,"message":"","last_error":"","log":""}`,
+ w.Body.String(),
+ )
+}
+
+func TestInfo(t *testing.T) {
+ srv := newTestServer()
+ w := srv.testRequest(t, "GET", "/api/info", nil, "")
+
+ assert.Equal(t, 200, w.Code)
+ assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
+
+ var info model.SystemInfo
+ err := json.Unmarshal(w.Body.Bytes(), &info)
+ assert.Nil(t, err)
+
+ assert.Equal(t, "TSGRain", info.RaucCompatible)
+ assert.Equal(t, "dev", info.RaucVariant)
+ assert.Len(t, info.RaucRootfs, 2)
+}
+
+func TestReboot(t *testing.T) {
+ srv := newTestServer()
+ testfile := "/tmp/sebrauc_reboot_test"
+ _ = os.Remove(testfile)
+
+ w := srv.testRequest(t, "POST", "/api/reboot", nil, "")
+
+ assert.Equal(t, 200, w.Code)
+ assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
+ assert.Equal(t,
+ `{"success":true,"msg":"System is rebooting"}`,
+ w.Body.String(),
+ )
+
+ time.Sleep(5100 * time.Millisecond)
+
+ assert.FileExists(t, testfile)
+}
+
+func TestAuth(t *testing.T) {
+ testfiles := fixtures.GetTestfilesDir()
+
+ cfg := config.GetDefault()
+ cfg.Authentication.Enable = true
+ cfg.Authentication.PasswdFile = filepath.Join(testfiles, "htpasswd")
+
+ srv := newTestServerCfg(cfg)
+
+ t.Run("fail", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/", nil)
+ req.SetBasicAuth("plain", "asdf")
+ srv.router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+ assert.Empty(t, w.Body.String())
+ assert.Equal(t, "Basic realm=\"Authorization Required\"",
+ w.Header().Get("WWW-Authenticate"))
+ })
+
+ t.Run("ok", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/", nil)
+ req.SetBasicAuth("plain", "1234")
+ srv.router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.NotEmpty(t, w.Body.String())
+ assert.Empty(t, w.Header().Get("WWW-Authenticate"))
+ })
+}
diff --git a/src/server/stream/client.go b/src/server/stream/client.go
new file mode 100644
index 0000000..c00249f
--- /dev/null
+++ b/src/server/stream/client.go
@@ -0,0 +1,119 @@
+package stream
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+const (
+ writeWait = 2 * time.Second
+)
+
+var ping = func(conn *websocket.Conn) error {
+ return conn.WriteMessage(websocket.PingMessage, nil)
+}
+
+var writeBytes = func(conn *websocket.Conn, data []byte) error {
+ return conn.WriteMessage(websocket.TextMessage, data)
+}
+
+type client struct {
+ conn *websocket.Conn
+ onClose func(*client)
+ write chan []byte
+ id uint
+ once once
+}
+
+func newClient(conn *websocket.Conn, id uint, onClose func(*client)) *client {
+ return &client{
+ conn: conn,
+ write: make(chan []byte, 1),
+ id: id,
+ onClose: onClose,
+ }
+}
+
+// Close closes the connection.
+func (c *client) Close() {
+ c.once.Do(func() {
+ c.conn.Close()
+ close(c.write)
+ })
+}
+
+// NotifyClose closes the connection and notifies that the connection was closed.
+func (c *client) NotifyClose() {
+ c.once.Do(func() {
+ c.conn.Close()
+ close(c.write)
+ c.onClose(c)
+ })
+}
+
+// startWriteHandler starts listening on the client connection.
+// As we do not need anything from the client,
+// we ignore incoming messages. Leaves the loop on errors.
+func (c *client) startReading(pongWait time.Duration) {
+ defer c.NotifyClose()
+ c.conn.SetReadLimit(64)
+ _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ c.conn.SetPongHandler(func(appData string) error {
+ _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+ })
+ for {
+ if _, _, err := c.conn.NextReader(); err != nil {
+ printWebSocketError("ReadError", err)
+ return
+ }
+ }
+}
+
+// startWriteHandler starts the write loop. The method has the following tasks:
+// * ping the client in the interval provided as parameter
+// * write messages send by the channel to the client
+// * on errors exit the loop.
+func (c *client) startWriteHandler(pingPeriod time.Duration) {
+ pingTicker := time.NewTicker(pingPeriod)
+ defer func() {
+ c.NotifyClose()
+ pingTicker.Stop()
+ }()
+
+ for {
+ select {
+ case message, ok := <-c.write:
+ if !ok {
+ return
+ }
+
+ _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := writeBytes(c.conn, message); err != nil {
+ printWebSocketError("WriteError", err)
+ return
+ }
+ case <-pingTicker.C:
+ _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := ping(c.conn); err != nil {
+ printWebSocketError("PingError", err)
+ return
+ }
+ }
+ }
+}
+
+func printWebSocketError(prefix string, err error) {
+ var closeError *websocket.CloseError
+ ok := errors.As(err, &closeError)
+
+ if ok && closeError != nil && (closeError.Code == 1000 || closeError.Code == 1001) {
+ // normal closure
+ return
+ }
+
+ fmt.Println("WebSocket:", prefix, err)
+}
diff --git a/src/server/stream/once.go b/src/server/stream/once.go
new file mode 100644
index 0000000..2df2523
--- /dev/null
+++ b/src/server/stream/once.go
@@ -0,0 +1,38 @@
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package stream
+
+import (
+ "sync"
+ "sync/atomic"
+)
+
+// Modified version of sync.Once
+// (https://github.com/golang/go/blob/master/src/sync/once.go)
+// This version unlocks the mutex early and therefore doesn't
+// hold the lock while executing func f().
+type once struct {
+ m sync.Mutex
+ done uint32
+}
+
+func (o *once) Do(f func()) {
+ if atomic.LoadUint32(&o.done) == 1 {
+ return
+ }
+ if o.mayExecute() {
+ f()
+ }
+}
+
+func (o *once) mayExecute() bool {
+ o.m.Lock()
+ defer o.m.Unlock()
+ if o.done == 0 {
+ atomic.StoreUint32(&o.done, 1)
+ return true
+ }
+ return false
+}
diff --git a/src/server/stream/once_test.go b/src/server/stream/once_test.go
new file mode 100644
index 0000000..720f65b
--- /dev/null
+++ b/src/server/stream/once_test.go
@@ -0,0 +1,43 @@
+package stream
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_Execute(t *testing.T) {
+ executeOnce := once{}
+ execution := make(chan struct{})
+ fExecute := func() {
+ execution <- struct{}{}
+ }
+ go executeOnce.Do(fExecute)
+ go executeOnce.Do(fExecute)
+
+ select {
+ case <-execution:
+ // expected
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("Execute should be executed once")
+ }
+
+ select {
+ case <-execution:
+ t.Fatal("should only execute once")
+ case <-time.After(100 * time.Millisecond):
+ // expected
+ }
+
+ assert.False(t, executeOnce.mayExecute())
+
+ go executeOnce.Do(fExecute)
+
+ select {
+ case <-execution:
+ t.Fatal("should only execute once")
+ case <-time.After(100 * time.Millisecond):
+ // expected
+ }
+}
diff --git a/src/server/stream/stream.go b/src/server/stream/stream.go
new file mode 100644
index 0000000..6a91fbc
--- /dev/null
+++ b/src/server/stream/stream.go
@@ -0,0 +1,161 @@
+package stream
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
+ "github.com/gin-gonic/gin"
+ "github.com/gorilla/websocket"
+)
+
+// The API provides a handler for a WebSocket stream API.
+type API struct {
+ clients map[uint]*client
+ lock sync.RWMutex
+ pingPeriod time.Duration
+ pongTimeout time.Duration
+ upgrader *websocket.Upgrader
+ counter *util.Counter
+ lastBroadcast []byte
+}
+
+// New creates a new instance of API.
+// pingPeriod: is the interval, in which is server sends the a ping to the client.
+// pongTimeout: is the duration after the connection will be terminated,
+// when the client does not respond with the pong command.
+func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string) *API {
+ return &API{
+ clients: make(map[uint]*client),
+ pingPeriod: pingPeriod,
+ pongTimeout: pingPeriod + pongTimeout,
+ upgrader: newUpgrader(allowedWebSocketOrigins),
+ counter: &util.Counter{},
+ }
+}
+
+// NotifyDeletedUser closes existing connections for the given user.
+func (a *API) NotifyDeletedClient(userID uint) error {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+ if client, ok := a.clients[userID]; ok {
+ client.Close()
+ delete(a.clients, userID)
+ }
+ return nil
+}
+
+// Notify notifies the clients with the given userID that a new messages was created.
+func (a *API) Notify(userID uint, msg []byte) {
+ a.lock.RLock()
+ defer a.lock.RUnlock()
+ if client, ok := a.clients[userID]; ok {
+ client.write <- msg
+ }
+}
+
+func (a *API) Broadcast(msg []byte) {
+ a.lock.RLock()
+ defer a.lock.RUnlock()
+ for _, client := range a.clients {
+ client.write <- msg
+ }
+ a.lastBroadcast = msg
+}
+
+func (a *API) remove(remove *client) {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+ delete(a.clients, remove.id)
+}
+
+func (a *API) register(client *client) {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+ a.clients[client.id] = client
+
+ // Send new clients the last broadcast so they get the current state
+ if a.lastBroadcast != nil {
+ client.write <- a.lastBroadcast
+ }
+}
+
+func (a *API) Handle(ctx *gin.Context) {
+ conn, err := a.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
+ if err != nil {
+ ctx.Error(err)
+ return
+ }
+
+ client := newClient(conn, a.counter.Increment(), a.remove)
+ a.register(client)
+ go client.startReading(a.pongTimeout)
+ go client.startWriteHandler(a.pingPeriod)
+}
+
+// Close closes all client connections and stops answering new connections.
+func (a *API) Close() {
+ a.lock.Lock()
+ defer a.lock.Unlock()
+
+ for _, client := range a.clients {
+ client.Close()
+ }
+ for k := range a.clients {
+ delete(a.clients, k)
+ }
+}
+
+func isAllowedOrigin(r *http.Request, allowedOrigins []*regexp.Regexp) bool {
+ origin := r.Header.Get("origin")
+ if origin == "" {
+ return true
+ }
+
+ u, err := url.Parse(origin)
+ if err != nil {
+ return false
+ }
+
+ if strings.EqualFold(u.Host, r.Host) {
+ return true
+ }
+
+ for _, allowedOrigin := range allowedOrigins {
+ if allowedOrigin.Match([]byte(strings.ToLower(u.Hostname()))) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader {
+ compiledAllowedOrigins := compileAllowedWebSocketOrigins(allowedWebSocketOrigins)
+
+ return &websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ if mode.IsDev() {
+ return true
+ }
+ return isAllowedOrigin(r, compiledAllowedOrigins)
+ },
+ }
+}
+
+func compileAllowedWebSocketOrigins(allowedOrigins []string) []*regexp.Regexp {
+ var compiledAllowedOrigins []*regexp.Regexp
+ for _, origin := range allowedOrigins {
+ compiledAllowedOrigins = append(compiledAllowedOrigins,
+ regexp.MustCompile(origin))
+ }
+
+ return compiledAllowedOrigins
+}
diff --git a/src/server/stream/stream_test.go b/src/server/stream/stream_test.go
new file mode 100644
index 0000000..771e6b2
--- /dev/null
+++ b/src/server/stream/stream_test.go
@@ -0,0 +1,472 @@
+package stream
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
+ "github.com/fortytw2/leaktest"
+ "github.com/gin-gonic/gin"
+ "github.com/gorilla/websocket"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFailureOnNormalHttpRequest(t *testing.T) {
+ mode.Set(mode.TestDev)
+ defer mode.Set(mode.Dev)
+
+ defer leaktest.Check(t)()
+
+ server, api := bootTestServer()
+ defer server.Close()
+ defer api.Close()
+
+ resp, err := http.Get(server.URL)
+ assert.Nil(t, err)
+ assert.Equal(t, 400, resp.StatusCode)
+ resp.Body.Close()
+}
+
+func TestWriteMessageFails(t *testing.T) {
+ mode.Set(mode.TestDev)
+ defer mode.Set(mode.Dev)
+
+ oldWrite := writeBytes
+ // try emulate an write error, mostly this should kill the ReadMessage
+ // goroutine first but you'll never know.
+ writeBytes = func(conn *websocket.Conn, data []byte) error {
+ return errors.New("asd")
+ }
+ defer func() {
+ writeBytes = oldWrite
+ }()
+ defer leaktest.Check(t)()
+
+ server, api := bootTestServer()
+ defer server.Close()
+ defer api.Close()
+
+ wsURL := wsURL(server.URL)
+ user := testClient(t, wsURL)
+
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+ client := getClient(api, 1)
+ assert.NotNil(t, client)
+
+ api.Notify(1, []byte("HI"))
+ user.expectNoMessage()
+}
+
+func TestWritePingFails(t *testing.T) {
+ mode.Set(mode.TestDev)
+ defer mode.Set(mode.Dev)
+
+ oldPing := ping
+ // try emulate an write error, mostly this should kill the ReadMessage
+ // gorouting first but you'll never know.
+ ping = func(conn *websocket.Conn) error {
+ return errors.New("asd")
+ }
+ defer func() {
+ ping = oldPing
+ }()
+
+ defer leaktest.CheckTimeout(t, 10*time.Second)()
+
+ server, api := bootTestServer()
+ defer api.Close()
+ defer server.Close()
+
+ wsURL := wsURL(server.URL)
+ user := testClient(t, wsURL)
+ defer user.conn.Close()
+
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+ client := getClient(api, 1)
+
+ assert.NotNil(t, client)
+
+ time.Sleep(api.pingPeriod) // waiting for ping
+
+ api.Notify(1, []byte("HI"))
+ user.expectNoMessage()
+}
+
+func TestPing(t *testing.T) {
+ mode.Set(mode.TestDev)
+ defer mode.Set(mode.Dev)
+
+ server, api := bootTestServer()
+ defer server.Close()
+ defer api.Close()
+
+ wsURL := wsURL(server.URL)
+
+ user := createClient(t, wsURL)
+ defer user.conn.Close()
+
+ ping := make(chan bool)
+ oldPingHandler := user.conn.PingHandler()
+ user.conn.SetPingHandler(func(appData string) error {
+ err := oldPingHandler(appData)
+ ping <- true
+ return err
+ })
+
+ startReading(user)
+
+ expectNoMessage(user)
+
+ select {
+ case <-time.After(2 * time.Second):
+ assert.Fail(t, "Expected ping but there was one :(")
+ case <-ping:
+ // expected
+ }
+
+ expectNoMessage(user)
+ api.Notify(1, []byte("HI"))
+ user.expectMessage([]byte("HI"))
+}
+
+func TestCloseClientOnNotReading(t *testing.T) {
+ mode.Set(mode.TestDev)
+ defer mode.Set(mode.Dev)
+
+ server, api := bootTestServer()
+ defer server.Close()
+ defer api.Close()
+
+ wsURL := wsURL(server.URL)
+
+ ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ assert.Nil(t, err)
+ resp.Body.Close()
+ defer ws.Close()
+
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+ assert.NotNil(t, getClient(api, 1))
+
+ time.Sleep(api.pingPeriod + api.pongTimeout)
+
+ assert.Nil(t, getClient(api, 1))
+}
+
+func TestMessageDirectlyAfterConnect(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ defer leaktest.Check(t)()
+ server, api := bootTestServer()
+ defer server.Close()
+ defer api.Close()
+
+ wsURL := wsURL(server.URL)
+
+ user := testClient(t, wsURL)
+ defer user.conn.Close()
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+ api.Notify(1, []byte("msg"))
+ user.expectMessage([]byte("msg"))
+}
+
+func TestDeleteClientShouldCloseConnection(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ defer leaktest.Check(t)()
+ server, api := bootTestServer()
+ defer server.Close()
+ defer api.Close()
+
+ wsURL := wsURL(server.URL)
+
+ user := testClient(t, wsURL)
+ defer user.conn.Close()
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+ api.Notify(1, []byte("HI"))
+ user.expectMessage([]byte("HI"))
+
+ assert.Nil(t, api.NotifyDeletedClient(1))
+
+ api.Notify(1, []byte("HI"))
+ user.expectNoMessage()
+}
+
+func TestNotify(t *testing.T) {
+ mode.Set(mode.TestDev)
+ defer mode.Set(mode.Dev)
+
+ defer leaktest.Check(t)()
+ server, api := bootTestServer()
+ defer server.Close()
+
+ wsURL := wsURL(server.URL)
+
+ client1 := testClient(t, wsURL)
+ defer client1.conn.Close()
+
+ client2 := testClient(t, wsURL)
+ defer client2.conn.Close()
+
+ client3 := testClient(t, wsURL)
+ defer client3.conn.Close()
+
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+
+ api.Notify(1, []byte("msg"))
+ expectMessage([]byte("msg"), client1)
+ expectNoMessage(client2)
+ expectNoMessage(client3)
+
+ assert.Nil(t, api.NotifyDeletedClient(1))
+
+ api.Notify(1, []byte("msg"))
+ expectNoMessage(client1)
+ expectNoMessage(client2)
+ expectNoMessage(client3)
+
+ api.Notify(2, []byte("msg"))
+ expectNoMessage(client1)
+ expectMessage([]byte("msg"), client2)
+ expectNoMessage(client3)
+
+ api.Notify(3, []byte("msg"))
+ expectNoMessage(client1)
+ expectNoMessage(client2)
+ expectMessage([]byte("msg"), client3)
+
+ api.Close()
+}
+
+func TestBroadcast(t *testing.T) {
+ defer leaktest.Check(t)()
+ server, api := bootTestServer()
+ defer server.Close()
+
+ wsURL := wsURL(server.URL)
+
+ client1 := testClient(t, wsURL)
+ defer client1.conn.Close()
+
+ client2 := testClient(t, wsURL)
+ defer client2.conn.Close()
+
+ client3 := testClient(t, wsURL)
+ defer client3.conn.Close()
+
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+
+ testMsg1 := []byte("hello1")
+ api.Broadcast(testMsg1)
+ expectMessage(testMsg1, client1, client2, client3)
+
+ assert.Nil(t, api.NotifyDeletedClient(1))
+
+ testMsg2 := []byte("hello2")
+ api.Broadcast(testMsg2)
+ expectNoMessage(client1)
+ expectMessage(testMsg2, client2, client3)
+}
+
+func TestLastBroadcast(t *testing.T) {
+ defer leaktest.Check(t)()
+ server, api := bootTestServer()
+ defer server.Close()
+
+ wsURL := wsURL(server.URL)
+
+ testMsg1 := []byte("hello1")
+ api.Broadcast(testMsg1)
+
+ client1 := testClient(t, wsURL)
+ defer client1.conn.Close()
+
+ client2 := testClient(t, wsURL)
+ defer client2.conn.Close()
+
+ // the server may take some time to register the client
+ time.Sleep(100 * time.Millisecond)
+
+ expectMessage(testMsg1, client1, client2)
+}
+
+func Test_sameOrigin_returnsTrue(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ req := httptest.NewRequest("GET", "http://example.com/stream", nil)
+ req.Header.Set("Origin", "http://example.com")
+ actual := isAllowedOrigin(req, nil)
+ assert.True(t, actual)
+}
+
+func Test_sameOrigin_returnsTrue_withCustomPort(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+ req := httptest.NewRequest("GET", "http://example.com:8080/stream", nil)
+ req.Header.Set("Origin", "http://example.com:8080")
+ actual := isAllowedOrigin(req, nil)
+ assert.True(t, actual)
+}
+
+func Test_isAllowedOrigin_withoutAllowedOrigins_failsWhenNotSameOrigin(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ req := httptest.NewRequest("GET", "http://example.com/stream", nil)
+ req.Header.Set("Origin", "http://gorify.example.com")
+ actual := isAllowedOrigin(req, nil)
+ assert.False(t, actual)
+}
+
+func Test_isAllowedOriginMatching(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ compiledAllowedOrigins := compileAllowedWebSocketOrigins(
+ []string{"go.{4}\\.example\\.com", "go\\.example\\.com"},
+ )
+
+ req := httptest.NewRequest("GET", "http://example.me/stream", nil)
+ req.Header.Set("Origin", "http://gorify.example.com")
+ assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))
+
+ req.Header.Set("Origin", "http://go.example.com")
+ assert.True(t, isAllowedOrigin(req, compiledAllowedOrigins))
+
+ req.Header.Set("Origin", "http://hello.example.com")
+ assert.False(t, isAllowedOrigin(req, compiledAllowedOrigins))
+}
+
+func Test_emptyOrigin_returnsTrue(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ req := httptest.NewRequest("GET", "http://example.com/stream", nil)
+ actual := isAllowedOrigin(req, nil)
+ assert.True(t, actual)
+}
+
+func Test_otherOrigin_returnsFalse(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ req := httptest.NewRequest("GET", "http://example.com/stream", nil)
+ req.Header.Set("Origin", "http://otherexample.de")
+ actual := isAllowedOrigin(req, nil)
+ assert.False(t, actual)
+}
+
+func Test_invalidOrigin_returnsFalse(t *testing.T) {
+ mode.Set(mode.Prod)
+ defer mode.Set(mode.Dev)
+
+ req := httptest.NewRequest("GET", "http://example.com/stream", nil)
+ req.Header.Set("Origin", "http\\://otherexample.de")
+ actual := isAllowedOrigin(req, nil)
+ assert.False(t, actual)
+}
+
+func Test_compileAllowedWebSocketOrigins(t *testing.T) {
+ assert.Equal(t, 0, len(compileAllowedWebSocketOrigins([]string{})))
+ assert.Equal(t, 3, len(compileAllowedWebSocketOrigins([]string{"^.*$", "", "abc"})))
+}
+
+func getClient(api *API, user uint) *client {
+ api.lock.RLock()
+ defer api.lock.RUnlock()
+
+ return api.clients[user]
+}
+
+func testClient(t *testing.T, url string) *testingClient {
+ client := createClient(t, url)
+ startReading(client)
+ return client
+}
+
+func startReading(client *testingClient) {
+ go func() {
+ for {
+ _, payload, err := client.conn.ReadMessage()
+ if err != nil {
+ return
+ }
+
+ client.readMessage <- payload
+ }
+ }()
+}
+
+func createClient(t *testing.T, url string) *testingClient {
+ ws, resp, err := websocket.DefaultDialer.Dial(url, nil)
+ assert.Nil(t, err)
+ resp.Body.Close()
+
+ readMessages := make(chan []byte)
+
+ return &testingClient{conn: ws, readMessage: readMessages, t: t}
+}
+
+type testingClient struct {
+ conn *websocket.Conn
+ readMessage chan []byte
+ t *testing.T
+}
+
+func (c *testingClient) expectMessage(expected []byte) {
+ select {
+ case <-time.After(50 * time.Millisecond):
+ assert.Fail(c.t, "Expected message but none was send :(")
+ case actual := <-c.readMessage:
+ assert.Equal(c.t, expected, actual)
+ }
+}
+
+func expectMessage(expected []byte, clients ...*testingClient) {
+ for _, client := range clients {
+ client.expectMessage(expected)
+ }
+}
+
+func expectNoMessage(clients ...*testingClient) {
+ for _, client := range clients {
+ client.expectNoMessage()
+ }
+}
+
+func (c *testingClient) expectNoMessage() {
+ select {
+ case <-time.After(50 * time.Millisecond):
+ // no message == as expected
+ case msg := <-c.readMessage:
+ assert.Fail(c.t, "Expected NO message but there was one :(", fmt.Sprint(msg))
+ }
+}
+
+func bootTestServer() (*httptest.Server, *API) {
+ r := gin.New()
+ // ping every 500 ms, and the client has 500 ms to respond
+ api := New(500*time.Millisecond, 500*time.Millisecond, []string{})
+
+ r.GET("/", api.Handle)
+ server := httptest.NewServer(r)
+ return server, api
+}
+
+func wsURL(httpURL string) string {
+ return "ws" + strings.TrimPrefix(httpURL, "http")
+}
diff --git a/src/server/swagger/swagger.go b/src/server/swagger/swagger.go
new file mode 100644
index 0000000..733b372
--- /dev/null
+++ b/src/server/swagger/swagger.go
@@ -0,0 +1,25 @@
+package swagger
+
+import (
+ _ "embed"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/server/middleware"
+ "github.com/gin-gonic/gin"
+)
+
+//go:embed swagger.html
+var swaggerHtml []byte
+
+//go:embed swagger.yaml
+var swaggerYaml []byte
+
+func Register(r gin.IRouter) {
+ swg := r.Group("/api/swagger", middleware.Cache)
+
+ swg.GET("/", func(c *gin.Context) {
+ c.Data(200, "text/html", swaggerHtml)
+ })
+ swg.GET("/swagger.yaml", func(c *gin.Context) {
+ c.Data(200, "text/yaml", swaggerYaml)
+ })
+}
diff --git a/src/server/swagger/swagger.html b/src/server/swagger/swagger.html
new file mode 100644
index 0000000..cc6855f
--- /dev/null
+++ b/src/server/swagger/swagger.html
@@ -0,0 +1,20 @@
+
+
+
+ SEBRAUC API documentation
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/server/swagger/swagger.yaml b/src/server/swagger/swagger.yaml
new file mode 100644
index 0000000..e21f243
--- /dev/null
+++ b/src/server/swagger/swagger.yaml
@@ -0,0 +1,228 @@
+definitions:
+ Error:
+ description: The Error contains error relevant information.
+ properties:
+ error:
+ description: The general error message according to HTTP specification.
+ example: Unauthorized
+ type: string
+ msg:
+ description: Concrete error message.
+ example: already running
+ type: string
+ status_code:
+ description: The http error code.
+ example: 500
+ format: int64
+ type: integer
+ required:
+ - error
+ - status_code
+ - msg
+ title: Error model
+ type: object
+ RaucStatus:
+ description: RaucStatus contains information about the current RAUC updater status.
+ properties:
+ installing:
+ description: True if the installer is running
+ type: boolean
+ last_error:
+ description: Installation error message
+ example: "Failed to check bundle identifier: Invalid identifier."
+ type: string
+ log:
+ description: Full command line output of the current installation
+ example: 0% Installing 0% Determining slot states 20% Determining slot states
+ done
+ type: string
+ message:
+ description: Current installation step
+ example: Copying image to rootfs.0
+ type: string
+ percent:
+ description: Installation progress
+ format: int64
+ maximum: 100
+ minimum: 0
+ type: integer
+ required:
+ - installing
+ - percent
+ - message
+ - last_error
+ - log
+ title: RaucStatus model
+ type: object
+ Rootfs:
+ properties:
+ bootable:
+ description: Is the filesystem bootable?
+ type: boolean
+ booted:
+ description: Is the filesystem booted?
+ type: boolean
+ device:
+ description: Block device
+ example: /dev/mmcblk0p2
+ type: string
+ mountpoint:
+ description: Mount path (null when not mounted)
+ example: /
+ type: string
+ primary:
+ description: Is the filesystem the next boot target?
+ type: boolean
+ type:
+ description: Filesystem
+ example: ext4
+ type: string
+ required:
+ - device
+ - type
+ - mountpoint
+ - bootable
+ - booted
+ - primary
+ type: object
+ StatusMessage:
+ description: StatusMessage contains the status of an operation.
+ properties:
+ msg:
+ description: Status message text
+ example: Update started
+ type: string
+ success:
+ description: Is operation successful?
+ type: boolean
+ required:
+ - success
+ - msg
+ title: StatusMessage model
+ type: object
+ SystemInfo:
+ description: SystemInfo contains information about the running system.
+ properties:
+ hostname:
+ description: Hostname of the system
+ example: raspberrypi3
+ type: string
+ os_name:
+ description: Name of the os distribution
+ example: Poky
+ type: string
+ os_version:
+ description: Operating system version
+ example: 1.0.2
+ type: string
+ rauc_compatible:
+ description: Compatible firmware name
+ example: Poky
+ type: string
+ rauc_rootfs:
+ additionalProperties:
+ $ref: "#/definitions/Rootfs"
+ description: List of RAUC root filesystems
+ type: object
+ rauc_variant:
+ description: Compatible firmware variant
+ example: rpi-prod
+ type: string
+ uptime:
+ description: System uptime in seconds
+ example: 5832
+ format: int64
+ type: integer
+ required:
+ - hostname
+ - os_name
+ - os_version
+ - uptime
+ - rauc_compatible
+ - rauc_variant
+ - rauc_rootfs
+ title: SystemInfo model
+ type: object
+info:
+ description: REST API for the SEBRAUC firmware updater
+ license:
+ name: MIT
+ title: SEBRAUC
+ version: 0.2.0
+paths:
+ /info:
+ get:
+ description: Get the current system info
+ operationId: getInfo
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Ok
+ schema:
+ $ref: "#/definitions/SystemInfo"
+ "500":
+ description: Server Error
+ schema:
+ $ref: "#/definitions/Error"
+ /reboot:
+ post:
+ description: Reboot the system
+ operationId: startReboot
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Ok
+ schema:
+ $ref: "#/definitions/StatusMessage"
+ "500":
+ description: Server Error
+ schema:
+ $ref: "#/definitions/Error"
+ /status:
+ get:
+ description: Get the current status of the RAUC updater
+ operationId: getStatus
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Ok
+ schema:
+ $ref: "#/definitions/RaucStatus"
+ "500":
+ description: Server Error
+ schema:
+ $ref: "#/definitions/Error"
+ /update:
+ post:
+ consumes:
+ - multipart/form-data
+ description: Start the update process
+ operationId: startUpdate
+ parameters:
+ - description: RAUC firmware image file (*.raucb)
+ in: formData
+ name: updateFile
+ required: true
+ type: file
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Ok
+ schema:
+ $ref: "#/definitions/StatusMessage"
+ "409":
+ description: already running
+ schema:
+ $ref: "#/definitions/Error"
+ "500":
+ description: Server Error
+ schema:
+ $ref: "#/definitions/Error"
+schemes:
+ - http
+ - https
+swagger: "2.0"
diff --git a/src/server/swagger/swagger_test.go b/src/server/swagger/swagger_test.go
new file mode 100644
index 0000000..9fd3c03
--- /dev/null
+++ b/src/server/swagger/swagger_test.go
@@ -0,0 +1,44 @@
+package swagger
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSwagger(t *testing.T) {
+ router := gin.New()
+ Register(router)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/swagger/", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, swaggerHtml, w.Body.Bytes())
+ assert.NotEmpty(t, w.Header().Get("Cache-Control"))
+
+ w = httptest.NewRecorder()
+ req, _ = http.NewRequest("GET", "/api/swagger/swagger.yaml", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, swaggerYaml, w.Body.Bytes())
+ assert.NotEmpty(t, w.Header().Get("Cache-Control"))
+}
+
+func TestSwaggerData(t *testing.T) {
+ assert.True(t, bytes.Contains(swaggerHtml,
+ []byte("https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js")),
+ "HTML data missing",
+ )
+
+ assert.True(t, bytes.Contains(swaggerYaml,
+ []byte("REST API for the SEBRAUC firmware updater")),
+ "YAML data missing",
+ )
+}
diff --git a/src/sysinfo/sysinfo.go b/src/sysinfo/sysinfo.go
new file mode 100644
index 0000000..f2aabc0
--- /dev/null
+++ b/src/sysinfo/sysinfo.go
@@ -0,0 +1,188 @@
+package sysinfo
+
+import (
+ "encoding/json"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/model"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/util"
+)
+
+type Sysinfo struct {
+ cmdRaucStatus string
+ releaseFile string
+ hostnameFile string
+ uptimeFile string
+ rexpName *regexp.Regexp
+ rexpVersion *regexp.Regexp
+}
+
+type raucInfo struct {
+ Compatible string `json:"compatible"`
+ Variant string `json:"variant"`
+ Booted string `json:"booted"`
+ BootPrimary string `json:"boot_primary"`
+ Slots []map[string]raucFS `json:"slots"`
+}
+
+type raucFS struct {
+ Class string `json:"class"`
+ Device string `json:"device"`
+ Type string `json:"type"`
+ Bootname string `json:"bootname"`
+ State string `json:"state"`
+ Mountpoint *string `json:"mountpoint"`
+ BootStatus string `json:"boot_status"`
+}
+
+type osRelease struct {
+ OsName string `json:"os_name"`
+ OsVersion string `json:"os_version"`
+}
+
+var rexpUptime = regexp.MustCompile(`^\d+`)
+
+func New(cmdRaucStatus string, releaseFile string, nameKey string, versionKey string,
+ hostnameFile string, uptimeFile string,
+) *Sysinfo {
+ return &Sysinfo{
+ cmdRaucStatus: cmdRaucStatus,
+ releaseFile: releaseFile,
+ hostnameFile: hostnameFile,
+ uptimeFile: uptimeFile,
+ rexpName: regexp.MustCompile(
+ `(?m)^` + regexp.QuoteMeta(nameKey) + `="(.+)"`),
+ rexpVersion: regexp.MustCompile(
+ `(?m)^` + regexp.QuoteMeta(versionKey) + `="(.+)"`),
+ }
+}
+
+func Default(cmdRaucStatus string) *Sysinfo {
+ return New(cmdRaucStatus, "/etc/os-release", "NAME", "VERSION",
+ "/etc/hostname", "/proc/uptime")
+}
+
+func parseRaucInfo(raucInfoJson []byte) (raucInfo, error) {
+ res := raucInfo{}
+ err := json.Unmarshal(raucInfoJson, &res)
+ return res, err
+}
+
+func (s *Sysinfo) parseOsRelease() (osRelease, error) {
+ osReleaseTxt, err := os.ReadFile(s.releaseFile)
+ if err != nil {
+ return osRelease{}, err
+ }
+
+ nameMatch := s.rexpName.FindSubmatch(osReleaseTxt)
+ versionMatch := s.rexpVersion.FindSubmatch(osReleaseTxt)
+
+ name := ""
+ if nameMatch != nil {
+ name = string(nameMatch[1])
+ }
+
+ version := ""
+ if versionMatch != nil {
+ version = string(versionMatch[1])
+ }
+
+ return osRelease{
+ OsName: name,
+ OsVersion: version,
+ }, nil
+}
+
+func mapRootfs(rinf raucInfo) map[string]model.Rootfs {
+ res := make(map[string]model.Rootfs)
+
+ for _, slot := range rinf.Slots {
+ for name, fs := range slot {
+ if fs.Class == "rootfs" {
+ res[name] = model.Rootfs{
+ Device: fs.Device,
+ Type: fs.Type,
+ Bootname: fs.Bootname,
+ Mountpoint: fs.Mountpoint,
+ Bootable: fs.BootStatus == "good",
+ Booted: fs.State == "booted",
+ Primary: rinf.BootPrimary == name,
+ }
+ }
+ }
+ }
+ return res
+}
+
+func getFSNameFromBootname(rfslist map[string]model.Rootfs, bootname string) string {
+ for name, rfs := range rfslist {
+ if rfs.Bootname == bootname {
+ return name
+ }
+ }
+ return "n/a"
+}
+
+func mapSysinfo(rinf raucInfo, osr osRelease, uptime int,
+ hostname string,
+) model.SystemInfo {
+ rfslist := mapRootfs(rinf)
+
+ return model.SystemInfo{
+ Hostname: hostname,
+ OsName: osr.OsName,
+ OsVersion: osr.OsVersion,
+ Uptime: uptime,
+ RaucCompatible: rinf.Compatible,
+ RaucVariant: rinf.Variant,
+ RaucRootfs: rfslist,
+ }
+}
+
+func (s *Sysinfo) getUptime() (int, error) {
+ uptimeRaw, err := os.ReadFile(s.uptimeFile)
+ if err != nil {
+ return 0, err
+ }
+
+ uptimeChars := rexpUptime.Find(uptimeRaw)
+ return strconv.Atoi(string(uptimeChars))
+}
+
+func (s *Sysinfo) getHostname() string {
+ hostname, err := os.ReadFile(s.hostnameFile)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(hostname))
+}
+
+func (s *Sysinfo) GetSysinfo() (model.SystemInfo, error) {
+ cmd := util.CommandFromString(s.cmdRaucStatus)
+ rinfJson, err := cmd.Output()
+ if err != nil {
+ return model.SystemInfo{}, err
+ }
+
+ rinf, err := parseRaucInfo(rinfJson)
+ if err != nil {
+ return model.SystemInfo{}, err
+ }
+
+ osinf, err := s.parseOsRelease()
+ if err != nil {
+ return model.SystemInfo{}, err
+ }
+
+ uptime, err := s.getUptime()
+ if err != nil {
+ return model.SystemInfo{}, err
+ }
+
+ hostname := s.getHostname()
+
+ return mapSysinfo(rinf, osinf, uptime, hostname), nil
+}
diff --git a/src/sysinfo/sysinfo_test.go b/src/sysinfo/sysinfo_test.go
new file mode 100644
index 0000000..575502e
--- /dev/null
+++ b/src/sysinfo/sysinfo_test.go
@@ -0,0 +1,129 @@
+package sysinfo
+
+import (
+ "path/filepath"
+ "testing"
+
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/model"
+ "github.com/stretchr/testify/assert"
+)
+
+const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` +
+ `"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` +
+ `"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` +
+ `"parent":null,"mountpoint":null,"boot_status":"good"}},{"rootfs.0":` +
+ `{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` +
+ `"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}`
+
+var mountRoot = "/"
+
+var expectedRaucInfo = raucInfo{
+ Compatible: "TSGRain",
+ Variant: "dev",
+ Booted: "A",
+ BootPrimary: "rootfs.0",
+ Slots: []map[string]raucFS{
+ {
+ "rootfs.1": {
+ Class: "rootfs",
+ Device: "/dev/mmcblk0p3",
+ Type: "ext4",
+ Bootname: "B",
+ State: "inactive",
+ Mountpoint: nil,
+ BootStatus: "good",
+ },
+ },
+ {
+ "rootfs.0": {
+ Class: "rootfs",
+ Device: "/dev/mmcblk0p2",
+ Type: "ext4",
+ Bootname: "A",
+ State: "booted",
+ Mountpoint: &mountRoot,
+ BootStatus: "good",
+ },
+ },
+ },
+}
+
+var expectedRootfsList = map[string]model.Rootfs{
+ "rootfs.0": {
+ Device: "/dev/mmcblk0p2",
+ Type: "ext4",
+ Bootname: "A",
+ Mountpoint: &mountRoot,
+ Bootable: true,
+ Booted: true,
+ Primary: true,
+ },
+ "rootfs.1": {
+ Device: "/dev/mmcblk0p3",
+ Type: "ext4",
+ Bootname: "B",
+ Mountpoint: nil,
+ Bootable: true,
+ Booted: false,
+ Primary: false,
+ },
+}
+
+func TestParseRaucInfo(t *testing.T) {
+ info, err := parseRaucInfo([]byte(statusJson))
+ if err != nil {
+ panic(err)
+ }
+
+ assert.Equal(t, expectedRaucInfo, info)
+}
+
+func TestParseOsRelease(t *testing.T) {
+ testfiles := fixtures.GetTestfilesDir()
+ osReleaseFile := filepath.Join(testfiles, "os-release")
+
+ si := New(testcmd.RaucStatus, osReleaseFile, "NAME", "VERSION",
+ "/etc/hostname", "/proc/uptime")
+
+ osRel, err := si.parseOsRelease()
+ if err != nil {
+ panic(err)
+ }
+
+ expected := osRelease{
+ OsName: "TSGRain distro",
+ OsVersion: "0.0.1",
+ }
+
+ assert.Equal(t, expected, osRel)
+}
+
+func TestMapRootfsList(t *testing.T) {
+ rootfsList := mapRootfs(expectedRaucInfo)
+
+ assert.Equal(t, expectedRootfsList, rootfsList)
+}
+
+func TestGetFSNameFromBootname(t *testing.T) {
+ rootfsList := mapRootfs(expectedRaucInfo)
+
+ assert.Equal(t, "rootfs.0", getFSNameFromBootname(rootfsList, "A"))
+ assert.Equal(t, "rootfs.1", getFSNameFromBootname(rootfsList, "B"))
+ assert.Equal(t, "n/a", getFSNameFromBootname(rootfsList, "C"))
+}
+
+func TestGetSysinfo(t *testing.T) {
+ si := Default(testcmd.RaucStatus)
+
+ sysinfo, err := si.GetSysinfo()
+ if err != nil {
+ panic(err)
+ }
+
+ assert.Greater(t, sysinfo.Uptime, 0)
+ assert.Equal(t, "TSGRain", sysinfo.RaucCompatible)
+ assert.Equal(t, "dev", sysinfo.RaucVariant)
+ assert.Equal(t, expectedRootfsList, sysinfo.RaucRootfs)
+}
diff --git a/src/util/commands.go b/src/util/commands.go
deleted file mode 100644
index 5bb0604..0000000
--- a/src/util/commands.go
+++ /dev/null
@@ -1,10 +0,0 @@
-//go:build prod
-
-package util
-
-const (
- RebootCmd = "shutdown -r 0"
- UpdateCmd = "rauc install"
-
- TestMode = false
-)
diff --git a/src/util/commands_mock.go b/src/util/commands_mock.go
deleted file mode 100644
index b2a18f3..0000000
--- a/src/util/commands_mock.go
+++ /dev/null
@@ -1,10 +0,0 @@
-//go:build !prod
-
-package util
-
-const (
- RebootCmd = "touch /tmp/sebrauc_reboot_test"
- UpdateCmd = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock"
-
- TestMode = true
-)
diff --git a/src/util/counter.go b/src/util/counter.go
new file mode 100644
index 0000000..9e264ee
--- /dev/null
+++ b/src/util/counter.go
@@ -0,0 +1,30 @@
+package util
+
+import "sync"
+
+type Counter struct {
+ count uint
+ mutex sync.RWMutex
+}
+
+func (c *Counter) Get() uint {
+ c.mutex.RLock()
+ defer c.mutex.RUnlock()
+
+ return c.count
+}
+
+func (c *Counter) Reset() {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ c.count = 0
+}
+
+func (c *Counter) Increment() uint {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ c.count++
+ return c.count
+}
diff --git a/src/util/counter_test.go b/src/util/counter_test.go
new file mode 100644
index 0000000..bfc3e9d
--- /dev/null
+++ b/src/util/counter_test.go
@@ -0,0 +1,30 @@
+package util
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCounter(t *testing.T) {
+ counter := Counter{}
+
+ var wg sync.WaitGroup
+
+ incrementer := func() {
+ for i := 0; i < 50; i++ {
+ counter.Increment()
+ }
+ wg.Done()
+ }
+
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go incrementer()
+ }
+
+ wg.Wait()
+
+ assert.EqualValues(t, 5000, counter.Get())
+}
diff --git a/src/util/errors.go b/src/util/errors.go
index 20ee9e4..a229d66 100644
--- a/src/util/errors.go
+++ b/src/util/errors.go
@@ -1,8 +1,13 @@
package util
-import "errors"
+import (
+ "errors"
+ "net/http"
+)
var (
- ErrAlreadyRunning = errors.New("rauc already running")
+ ErrAlreadyRunning = HttpErrNew("rauc already running", http.StatusConflict)
ErrFileDoesNotExist = errors.New("file does not exist")
+ ErrPageNotFound = HttpErrNew("page not found", http.StatusNotFound)
+ ErrUnauthorized = HttpErrNew("unauthorized", http.StatusUnauthorized)
)
diff --git a/src/util/http_error.go b/src/util/http_error.go
new file mode 100644
index 0000000..c5d7cf8
--- /dev/null
+++ b/src/util/http_error.go
@@ -0,0 +1,39 @@
+package util
+
+import "errors"
+
+type HttpError interface {
+ error
+ StatusCode() int
+}
+
+type httpErr struct {
+ err error
+ statusCode int
+}
+
+func HttpErrWrap(e error, statusCode int) HttpError {
+ return &httpErr{
+ err: e,
+ statusCode: statusCode,
+ }
+}
+
+func HttpErrNew(msg string, statusCode int) HttpError {
+ return HttpErrWrap(errors.New(msg), statusCode)
+}
+
+func (e *httpErr) Error() string {
+ if e.err == nil {
+ return ""
+ }
+ return e.err.Error()
+}
+
+func (e *httpErr) Unwrap() error {
+ return e.err
+}
+
+func (e *httpErr) StatusCode() int {
+ return e.statusCode
+}
diff --git a/src/util/mode/mode.go b/src/util/mode/mode.go
new file mode 100644
index 0000000..6b0a3c8
--- /dev/null
+++ b/src/util/mode/mode.go
@@ -0,0 +1,46 @@
+package mode
+
+import "github.com/gin-gonic/gin"
+
+const (
+ // Dev for development mode.
+ Dev = "dev"
+ // Prod for production mode.
+ Prod = "prod"
+ // TestDev used for tests.
+ TestDev = "testdev"
+)
+
+var currentMode = Dev
+
+func init() {
+ Set(appmode)
+}
+
+func Set(newMode string) {
+ currentMode = newMode
+ updateGinMode()
+}
+
+// Get returns the current mode.
+func Get() string {
+ return currentMode
+}
+
+// IsDev returns true if the current mode is dev mode.
+func IsDev() bool {
+ return Get() == Dev || Get() == TestDev
+}
+
+func updateGinMode() {
+ switch Get() {
+ case Dev:
+ gin.SetMode(gin.DebugMode)
+ case TestDev:
+ gin.SetMode(gin.TestMode)
+ case Prod:
+ gin.SetMode(gin.ReleaseMode)
+ default:
+ panic("unknown mode")
+ }
+}
diff --git a/src/util/mode/mode_dev.go b/src/util/mode/mode_dev.go
new file mode 100644
index 0000000..71cb6cd
--- /dev/null
+++ b/src/util/mode/mode_dev.go
@@ -0,0 +1,6 @@
+//go:build !prod
+// +build !prod
+
+package mode
+
+const appmode = Dev
diff --git a/src/util/mode/mode_prod.go b/src/util/mode/mode_prod.go
new file mode 100644
index 0000000..571398d
--- /dev/null
+++ b/src/util/mode/mode_prod.go
@@ -0,0 +1,6 @@
+//go:build prod
+// +build prod
+
+package mode
+
+const appmode = Prod
diff --git a/src/util/mode/mode_test.go b/src/util/mode/mode_test.go
new file mode 100644
index 0000000..8b06166
--- /dev/null
+++ b/src/util/mode/mode_test.go
@@ -0,0 +1,35 @@
+package mode
+
+import (
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDevMode(t *testing.T) {
+ Set(Dev)
+ assert.Equal(t, Get(), Dev)
+ assert.True(t, IsDev())
+ assert.Equal(t, gin.Mode(), gin.DebugMode)
+}
+
+func TestTestDevMode(t *testing.T) {
+ Set(TestDev)
+ assert.Equal(t, Get(), TestDev)
+ assert.True(t, IsDev())
+ assert.Equal(t, gin.Mode(), gin.TestMode)
+}
+
+func TestProdMode(t *testing.T) {
+ Set(Prod)
+ assert.Equal(t, Get(), Prod)
+ assert.False(t, IsDev())
+ assert.Equal(t, gin.Mode(), gin.ReleaseMode)
+}
+
+func TestInvalidMode(t *testing.T) {
+ assert.Panics(t, func() {
+ Set("asdasda")
+ })
+}
diff --git a/src/util/types.go b/src/util/types.go
new file mode 100644
index 0000000..afd106b
--- /dev/null
+++ b/src/util/types.go
@@ -0,0 +1,5 @@
+package util
+
+type Broadcaster interface {
+ Broadcast(msg []byte)
+}
diff --git a/src/util/util.go b/src/util/util.go
index a64d409..e826e45 100644
--- a/src/util/util.go
+++ b/src/util/util.go
@@ -1,6 +1,7 @@
package util
import (
+ "fmt"
"os"
"os/exec"
"path/filepath"
@@ -25,10 +26,24 @@ func CreateDirIfNotExists(dirpath string) error {
return nil
}
-func GetTmpdir() (string, error) {
- tmpdir := filepath.Join(os.TempDir(), tmpdirName)
+func GetTmpdir(tmpdirPath string) string {
+ tmpdir := tmpdirPath
+ // Default temporary directory
+ if tmpdirPath == "" {
+ tmpdir = filepath.Join(os.TempDir(), tmpdirName)
+ }
+
err := CreateDirIfNotExists(tmpdir)
- return tmpdir, err
+ if err != nil {
+ panic(fmt.Sprintf("could not create tmpdir %s: %s", tmpdir, err))
+ }
+
+ return tmpdir
+}
+
+func RemoveTmpdir(tmpdirPath string) {
+ tmpdir := GetTmpdir(tmpdirPath)
+ _ = os.RemoveAll(tmpdir)
}
func CommandFromString(cmdString string) *exec.Cmd {
@@ -41,8 +56,41 @@ func CommandFromString(cmdString string) *exec.Cmd {
return exec.Command(parts[0], parts[1:]...)
}
-func Reboot(t time.Duration) {
+func Reboot(rebootCmd string, t time.Duration) {
time.Sleep(t)
- cmd := CommandFromString(RebootCmd)
+ cmd := CommandFromString(rebootCmd)
_ = cmd.Run()
}
+
+func FindFile(explicitPath string, locations, endings []string) (string, error) {
+ if explicitPath != "" {
+ if !DoesFileExist(explicitPath) {
+ return "", fmt.Errorf("file %s not found", explicitPath)
+ }
+ return explicitPath, nil
+ }
+
+ notFound := []string{}
+
+ for _, f := range locations {
+ if endings != nil {
+ for _, t := range endings {
+ fpath := f + "." + t
+
+ if DoesFileExist(fpath) {
+ return fpath, nil
+ } else {
+ notFound = append(notFound, fpath)
+ }
+ }
+ } else {
+ if DoesFileExist(f) {
+ return f, nil
+ } else {
+ notFound = append(notFound, f)
+ }
+ }
+ }
+ return "", fmt.Errorf("none of the following files found: %s",
+ strings.Join(notFound, "; "))
+}
diff --git a/src/util/util_test.go b/src/util/util_test.go
index eb28203..0ef6a32 100644
--- a/src/util/util_test.go
+++ b/src/util/util_test.go
@@ -1,11 +1,13 @@
package util
import (
+ "errors"
"os"
"path/filepath"
"testing"
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
+ "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
"github.com/stretchr/testify/assert"
)
@@ -41,27 +43,46 @@ func TestDoesFileExist(t *testing.T) {
}
func TestTmpdir(t *testing.T) {
- td, err := GetTmpdir()
- if err != nil {
- panic(err)
+ tests := []struct {
+ name string
+ path string
+ }{
+ {
+ name: "default",
+ path: "",
+ },
+ {
+ name: "custom",
+ path: filepath.Join(os.TempDir(), "customTmpdir"),
+ },
}
- tfile := filepath.Join(td, "test.txt")
- f, err := os.Create(tfile)
- if err != nil {
- panic(err)
- }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ td := GetTmpdir(tt.path)
+ assert.DirExists(t, td)
- _, err = f.WriteString("Hello")
- if err != nil {
- panic(err)
- }
- err = f.Close()
- if err != nil {
- panic(err)
- }
+ tfile := filepath.Join(td, "test.txt")
+ f, err := os.Create(tfile)
+ if err != nil {
+ panic(err)
+ }
- assert.FileExists(t, tfile)
+ _, err = f.WriteString("Hello")
+ if err != nil {
+ panic(err)
+ }
+ err = f.Close()
+ if err != nil {
+ panic(err)
+ }
+
+ assert.FileExists(t, tfile)
+
+ RemoveTmpdir(tt.path)
+ assert.NoDirExists(t, td)
+ })
+ }
}
func TestCommandFromString(t *testing.T) {
@@ -103,7 +124,78 @@ func TestReboot(t *testing.T) {
testfile := "/tmp/sebrauc_reboot_test"
_ = os.Remove(testfile)
- Reboot(0)
+ Reboot(testcmd.Reboot, 0)
assert.FileExists(t, testfile)
}
+
+func TestFindFile(t *testing.T) {
+ testfiles := fixtures.GetTestfilesDir()
+
+ //nolint:lll
+ tests := []struct {
+ name string
+ explicitPath string
+ locations []string
+ endings []string
+ expect string
+ expectErr error
+ }{
+ {
+ name: "locations",
+ explicitPath: "",
+ locations: []string{filepath.Join(testfiles, "sebrauc")},
+ endings: []string{"yml", "toml"},
+ expect: filepath.Join(testfiles, "sebrauc.toml"),
+ expectErr: nil,
+ },
+ {
+ name: "locations_nf",
+ explicitPath: "",
+ locations: []string{filepath.Join(testfiles, "banana")},
+ endings: []string{"yml", "toml"},
+ expect: "",
+ expectErr: errors.New("none of the following files found: src/fixtures/testfiles/banana.yml; src/fixtures/testfiles/banana.toml"),
+ },
+ {
+ name: "no_endings",
+ explicitPath: "",
+ locations: []string{filepath.Join(testfiles, "banana"), filepath.Join(testfiles, "os-release")},
+ endings: nil,
+ expect: filepath.Join(testfiles, "os-release"),
+ expectErr: nil,
+ },
+ {
+ name: "no_endings_nf",
+ explicitPath: "",
+ locations: []string{filepath.Join(testfiles, "banana"), filepath.Join(testfiles, "apple")},
+ endings: nil,
+ expect: "",
+ expectErr: errors.New("none of the following files found: src/fixtures/testfiles/banana; src/fixtures/testfiles/apple"),
+ },
+ {
+ name: "explicit",
+ explicitPath: filepath.Join(testfiles, "sebrauc.toml"),
+ locations: []string{filepath.Join(testfiles, "banana")},
+ endings: []string{"yml", "toml"},
+ expect: filepath.Join(testfiles, "sebrauc.toml"),
+ expectErr: nil,
+ },
+ {
+ name: "explicit_nf",
+ explicitPath: filepath.Join(testfiles, "banana.toml"),
+ locations: []string{filepath.Join(testfiles, "banana")},
+ endings: []string{"yml", "toml"},
+ expect: "",
+ expectErr: errors.New("file src/fixtures/testfiles/banana.toml not found"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fpath, err := FindFile(tt.explicitPath, tt.locations, tt.endings)
+ assert.Equal(t, tt.expectErr, err)
+ assert.Equal(t, tt.expect, fpath)
+ })
+ }
+}
diff --git a/ui/.env.development b/ui/.env.development
index 3eba399..029df76 100644
--- a/ui/.env.development
+++ b/ui/.env.development
@@ -1,2 +1 @@
-VITE_VERSION=dev
VITE_API_HOST=127.0.0.1:8080
diff --git a/.prettierignore b/ui/.prettierignore
similarity index 66%
rename from .prettierignore
rename to ui/.prettierignore
index f06235c..d638edf 100644
--- a/.prettierignore
+++ b/ui/.prettierignore
@@ -1,2 +1,4 @@
node_modules
dist
+tmp
+.tmp
diff --git a/ui/index.html b/ui/index.html
index cf876c6..426ad0f 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -7,7 +7,11 @@
SEBRAUC
+
+