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 @@ -![SEBRAUC](ui/src/assets/logo_border.svg) 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 +
+ diff --git a/ui/package.json b/ui/package.json index 5efdbac..1d1def8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,7 +4,9 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "serve": "vite preview" + "serve": "vite preview", + "lint": "tsc", + "format": "prettier --write ../" }, "dependencies": { "@mdi/js": "^6.5.95", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 1110f3a..fe7d84e 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: 5.3 +lockfileVersion: 5.4 specifiers: "@mdi/js": ^6.5.95 @@ -282,6 +282,8 @@ packages: } engines: {node: ">=6.0.0"} hasBin: true + dependencies: + "@babel/types": 7.16.0 dev: true /@babel/plugin-syntax-jsx/7.16.0: diff --git a/ui/src/components/Upload/Alert.tsx b/ui/src/components/Updater/Alert.tsx similarity index 84% rename from ui/src/components/Upload/Alert.tsx rename to ui/src/components/Updater/Alert.tsx index 6eb16a9..204a691 100644 --- a/ui/src/components/Upload/Alert.tsx +++ b/ui/src/components/Updater/Alert.tsx @@ -1,6 +1,7 @@ import {Component} from "preact" import {mdiTriangleOutline} from "@mdi/js" import Icon from "../Icon/Icon" +import colors from "../../util/colors" type Props = { source?: string @@ -20,7 +21,7 @@ export default class Alert extends Component { return (
- + {msg}
diff --git a/ui/src/components/Upload/Reboot.tsx b/ui/src/components/Updater/Reboot.tsx similarity index 81% rename from ui/src/components/Upload/Reboot.tsx rename to ui/src/components/Updater/Reboot.tsx index c53666e..49a0232 100644 --- a/ui/src/components/Upload/Reboot.tsx +++ b/ui/src/components/Updater/Reboot.tsx @@ -1,7 +1,6 @@ import {mdiCheckCircleOutline, mdiRestore} from "@mdi/js" -import axios, {AxiosError, AxiosResponse} from "axios" import {Component} from "preact" -import {apiUrl} from "../../util/apiUrls" +import {sebraucApi} from "../../util/apiUrls" import Icon from "../Icon/Icon" export default class Reboot extends Component { @@ -9,9 +8,9 @@ export default class Reboot extends Component { const res = confirm("Reboot the system?") if (!res) return - axios - .post(apiUrl + "/reboot") - .then((response: AxiosResponse) => { + sebraucApi + .startReboot() + .then((response) => { const msg = response.data.msg if (msg !== undefined) { @@ -20,7 +19,7 @@ export default class Reboot extends Component { alert("No response") } }) - .catch((error: AxiosError) => { + .catch((error) => { if (error.response) { const msg = error.response.data.msg diff --git a/ui/src/components/Updater/SysinfoCard.tsx b/ui/src/components/Updater/SysinfoCard.tsx new file mode 100644 index 0000000..625ed7c --- /dev/null +++ b/ui/src/components/Updater/SysinfoCard.tsx @@ -0,0 +1,166 @@ +import {Component} from "preact" +import {SystemInfo} from "../../sebrauc-client" +import {sebraucApi} from "../../util/apiUrls" +import {secondsToString} from "../../util/functions" +import Icon from "../Icon/Icon" +import { + mdiAlphaVCircleOutline, + mdiCheckCircleOutline, + mdiCircleOutline, + mdiClockOutline, + mdiCloseCircleOutline, + mdiMonitor, + mdiPenguin, + mdiTagMultipleOutline, + mdiTagOutline, +} from "@mdi/js" +import colors from "../../util/colors" + +type Props = {} + +type State = { + sysinfo: SystemInfo +} + +export default class SysinfoCard extends Component { + private fetchTimeout: number | undefined + + constructor(props?: Props | undefined, context?: any) { + super(props, context) + this.fetchInfo() + } + + private fetchInfo = () => { + sebraucApi + .getInfo() + .then((response) => { + if (response.status == 200) { + this.setState({sysinfo: response.data}) + } else { + console.log("error fetching info", response.data) + this.fetchTimeout = window.setTimeout(this.fetchInfo, 3000) + } + }) + .catch((reason) => { + console.log("error fetching info", reason) + this.fetchTimeout = window.setTimeout(this.fetchInfo, 3000) + }) + } + + private renderSysinfo() { + return ( +
+
+

System information

+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Hostname + {this.state.sysinfo.hostname}
+ Operating system + {this.state.sysinfo.os_name}
+ OS version + {this.state.sysinfo.os_version}
+ Uptime + {secondsToString(this.state.sysinfo.uptime)}
+ Compatible FW + {this.state.sysinfo.rauc_compatible}
+ Compatible FW + variant + {this.state.sysinfo.rauc_variant}
+
+ +
+

Rootfs slots

+
+ + + + + + + + + + + {Object.keys(this.state.sysinfo.rauc_rootfs).map( + (k, i) => { + const rfs = this.state.sysinfo.rauc_rootfs[k] + let icon = mdiCircleOutline + let iconColor = colors.BLUE + + if (!rfs.bootable) { + icon = mdiCloseCircleOutline + iconColor = colors.RED + } else if (rfs.primary) { + icon = mdiCheckCircleOutline + iconColor = colors.GREEN + } + + return ( + + + + + + + ) + } + )} + +
NameDeviceMountpoint
+ + {k}{rfs.device}{rfs.mountpoint}
+
+
+
+ ) + } + + private renderLoadingAnimation() { + return ( +
+

loading sysinfo...

+
+ ) + } + + componentWillUnmount() { + if (this.fetchTimeout !== undefined) { + window.clearTimeout(this.fetchTimeout) + } + } + + render() { + if (this.state.sysinfo) { + return this.renderSysinfo() + } + return this.renderLoadingAnimation() + } +} diff --git a/ui/src/components/Upload/Updater.scss b/ui/src/components/Updater/Updater.scss similarity index 69% rename from ui/src/components/Upload/Updater.scss rename to ui/src/components/Updater/Updater.scss index 90b798e..fe3ace9 100644 --- a/ui/src/components/Upload/Updater.scss +++ b/ui/src/components/Updater/Updater.scss @@ -1,11 +1,11 @@ -.uploader { +.updater-view { display: flex; flex-direction: column; justify-content: center; align-items: center; margin: 0 auto; - max-width: 500px; + max-width: 600px; width: 90%; > * { @@ -18,8 +18,8 @@ justify-content: center; align-items: center; - padding: 15px 8px; - margin: 8px 0; + margin-top: 25px; + margin-bottom: 8px; text-align: center; @@ -29,6 +29,14 @@ .top { font-size: 1.5em; } + + &.pad { + padding: 15px 0; + } + + &:first-of-type { + margin-top: 8px; + } } .alert { @@ -42,3 +50,9 @@ } } } + +.button-top-right { + position: absolute; + top: 20px; + right: 20px; +} diff --git a/ui/src/components/Upload/Updater.tsx b/ui/src/components/Updater/UpdaterCard.tsx similarity index 76% rename from ui/src/components/Upload/Updater.tsx rename to ui/src/components/Updater/UpdaterCard.tsx index 49057b1..9a38b95 100644 --- a/ui/src/components/Upload/Updater.tsx +++ b/ui/src/components/Updater/UpdaterCard.tsx @@ -5,10 +5,11 @@ import Dropzone from "../Dropzone/Dropzone" import ProgressCircle from "../ProgressCircle/ProgressCircle" import Icon from "../Icon/Icon" import "./Updater.scss" -import axios from "axios" import Alert from "./Alert" import Reboot from "./Reboot" -import {apiUrl, wsUrl} from "../../util/apiUrls" +import {sebraucApi} from "../../util/apiUrls" +import colors from "../../util/colors" +import WebsocketClient from "../../util/websocket" class UploadStatus { uploading = false @@ -50,21 +51,21 @@ type State = { wsConnected: boolean } -export default class Updater extends Component { +export default class UpdaterCard extends Component { private dropzoneRef = createRef() - private conn: WebSocket | undefined + private ws: WebsocketClient constructor(props?: Props | undefined, context?: any) { super(props, context) + this.ws = new WebsocketClient(this.onWsStatusUpdate, this.onWsMessage) + this.state = { uploadStatus: new UploadStatus(false), uploadFilename: "", raucStatus: new RaucStatus(), - wsConnected: false, + wsConnected: this.ws.api().isConnected(), } - - this.connectWebsocket() } private buttonClick = () => { @@ -77,19 +78,13 @@ export default class Updater extends Component { if (files.length === 0) return const newFile = files[0] - const formData = new FormData() - formData.append("updateFile", newFile) - this.setState({ uploadStatus: new UploadStatus(true, newFile.size, 0), uploadFilename: newFile.name, }) - axios - .post(apiUrl + "/update", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, + sebraucApi + .startUpdate(newFile, { onUploadProgress: (progressEvent: {loaded: number; total: number}) => { this.setState({ uploadStatus: UploadStatus.fromProgressEvent(progressEvent), @@ -111,33 +106,16 @@ export default class Updater extends Component { this.dropzoneRef.current?.reset() } - private connectWebsocket = () => { - if (window.WebSocket) { - this.conn = new WebSocket(wsUrl) - this.conn.onopen = () => { - this.setState({wsConnected: true}) - console.log("WS connected") - } - this.conn.onclose = () => { - this.setState({wsConnected: false}) - console.log("WS connection closed") - window.setTimeout(this.connectWebsocket, 3000) - } - this.conn.onmessage = (evt) => { - var messages = evt.data.split("\n") - for (var i = 0; i < messages.length; i++) { - this.setState({ - raucStatus: Object.assign( - new RaucStatus(), - JSON.parse(messages[i]) - ), - }) + private onWsStatusUpdate = (wsConnected: boolean) => { + this.setState({wsConnected: wsConnected}) + } - console.log(this.state.raucStatus) - } - } - } else { - console.log("Your browser does not support WebSockets") + private onWsMessage = (evt: MessageEvent) => { + var messages = evt.data.split("\n") + for (var i = 0; i < messages.length; i++) { + this.setState({ + raucStatus: Object.assign(new RaucStatus(), JSON.parse(messages[i])), + }) } } @@ -163,9 +141,9 @@ export default class Updater extends Component { } private circleColor(): string { - if (this.state.raucStatus.installing) return "#FF0039" - if (this.state.uploadStatus.uploading) return "#148420" - return "#1f85de" + if (this.state.raucStatus.installing) return colors.RED + if (this.state.uploadStatus.uploading) return colors.GREEN + return colors.BLUE } private circlePercentage(): number { @@ -175,6 +153,10 @@ export default class Updater extends Component { return 0 } + componentWillUnmount() { + this.ws.destroy() + } + render() { const acceptUploads = this.acceptUploads() const circleColor = this.circleColor() @@ -195,12 +177,13 @@ export default class Updater extends Component { topText = "Updating firmware" bottomText = this.state.raucStatus.message } else { - topText = "Upload firmware package" + topText = "Firmware update" + bottomText = "Upload *.raucb FW package" } return ( -
-
+
+

{topText}

@@ -215,7 +198,7 @@ export default class Updater extends Component { progress={circlePercentage} color={circleColor} > - diff --git a/ui/src/components/Updater/UpdaterView.tsx b/ui/src/components/Updater/UpdaterView.tsx new file mode 100644 index 0000000..faf96c4 --- /dev/null +++ b/ui/src/components/Updater/UpdaterView.tsx @@ -0,0 +1,48 @@ +import {mdiInformation, mdiUpload} from "@mdi/js" +import {Component} from "preact" +import Icon from "../Icon/Icon" +import SysinfoCard from "./SysinfoCard" +import UpdaterCard from "./UpdaterCard" +import "./Updater.scss" + +type Props = {} + +type State = { + flipped: boolean +} + +export default class UpdaterView extends Component { + constructor(props?: Props | undefined, context?: any) { + super(props, context) + + this.state = { + flipped: false, + } + } + + private flipCard = () => { + this.setState({flipped: !this.state.flipped}) + } + + render() { + return ( +
+ + +
+ {!this.state.flipped ? : } +
+
+ ) + } +} diff --git a/ui/src/components/app.tsx b/ui/src/components/app.tsx index 25d26db..c2bd70c 100644 --- a/ui/src/components/app.tsx +++ b/ui/src/components/app.tsx @@ -1,15 +1,15 @@ import {Component} from "preact" -import Updater from "./Upload/Updater" +import UpdaterView from "./Updater/UpdaterView" import logo from "../assets/logo.svg" -import {version} from "../util/version" +import {getConfig} from "../util/config" export default class App extends Component { render() { return (
- - {version} - + SEBRAUC + {getConfig().version} +
) } diff --git a/ui/src/sebrauc-client/.openapi-generator-ignore b/ui/src/sebrauc-client/.openapi-generator-ignore new file mode 100644 index 0000000..7e15242 --- /dev/null +++ b/ui/src/sebrauc-client/.openapi-generator-ignore @@ -0,0 +1,27 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md + +/.gitignore +/.npmignore +/git_push.sh diff --git a/ui/src/sebrauc-client/.openapi-generator/FILES b/ui/src/sebrauc-client/.openapi-generator/FILES new file mode 100644 index 0000000..53250c0 --- /dev/null +++ b/ui/src/sebrauc-client/.openapi-generator/FILES @@ -0,0 +1,5 @@ +api.ts +base.ts +common.ts +configuration.ts +index.ts diff --git a/ui/src/sebrauc-client/.openapi-generator/VERSION b/ui/src/sebrauc-client/.openapi-generator/VERSION new file mode 100644 index 0000000..e230c83 --- /dev/null +++ b/ui/src/sebrauc-client/.openapi-generator/VERSION @@ -0,0 +1 @@ +5.3.0 \ No newline at end of file diff --git a/ui/src/sebrauc-client/api.ts b/ui/src/sebrauc-client/api.ts new file mode 100644 index 0000000..b2106ea --- /dev/null +++ b/ui/src/sebrauc-client/api.ts @@ -0,0 +1,566 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import {Configuration} from "./configuration" +import globalAxios, {AxiosPromise, AxiosInstance, AxiosRequestConfig} from "axios" +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "./common" +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "./base" + +/** + * The Error contains error relevant information. + * @export + * @interface ModelError + */ +export interface ModelError { + /** + * The general error message according to HTTP specification. + * @type {string} + * @memberof ModelError + */ + error: string + /** + * Concrete error message. + * @type {string} + * @memberof ModelError + */ + msg: string + /** + * The http error code. + * @type {number} + * @memberof ModelError + */ + status_code: number +} +/** + * RaucStatus contains information about the current RAUC updater status. + * @export + * @interface RaucStatus + */ +export interface RaucStatus { + /** + * True if the installer is running + * @type {boolean} + * @memberof RaucStatus + */ + installing: boolean + /** + * Installation error message + * @type {string} + * @memberof RaucStatus + */ + last_error: string + /** + * Full command line output of the current installation + * @type {string} + * @memberof RaucStatus + */ + log: string + /** + * Current installation step + * @type {string} + * @memberof RaucStatus + */ + message: string + /** + * Installation progress + * @type {number} + * @memberof RaucStatus + */ + percent: number +} +/** + * + * @export + * @interface Rootfs + */ +export interface Rootfs { + /** + * Is the filesystem bootable? + * @type {boolean} + * @memberof Rootfs + */ + bootable: boolean + /** + * Is the filesystem booted? + * @type {boolean} + * @memberof Rootfs + */ + booted: boolean + /** + * Block device + * @type {string} + * @memberof Rootfs + */ + device: string + /** + * Mount path (null when not mounted) + * @type {string} + * @memberof Rootfs + */ + mountpoint: string + /** + * Is the filesystem the next boot target? + * @type {boolean} + * @memberof Rootfs + */ + primary: boolean + /** + * Filesystem + * @type {string} + * @memberof Rootfs + */ + type: string +} +/** + * StatusMessage contains the status of an operation. + * @export + * @interface StatusMessage + */ +export interface StatusMessage { + /** + * Status message text + * @type {string} + * @memberof StatusMessage + */ + msg: string + /** + * Is operation successful? + * @type {boolean} + * @memberof StatusMessage + */ + success: boolean +} +/** + * SystemInfo contains information about the running system. + * @export + * @interface SystemInfo + */ +export interface SystemInfo { + /** + * Hostname of the system + * @type {string} + * @memberof SystemInfo + */ + hostname: string + /** + * Name of the os distribution + * @type {string} + * @memberof SystemInfo + */ + os_name: string + /** + * Operating system version + * @type {string} + * @memberof SystemInfo + */ + os_version: string + /** + * Compatible firmware name + * @type {string} + * @memberof SystemInfo + */ + rauc_compatible: string + /** + * List of RAUC root filesystems + * @type {{ [key: string]: Rootfs; }} + * @memberof SystemInfo + */ + rauc_rootfs: {[key: string]: Rootfs} + /** + * Compatible firmware variant + * @type {string} + * @memberof SystemInfo + */ + rauc_variant: string + /** + * System uptime in seconds + * @type {number} + * @memberof SystemInfo + */ + uptime: number +} + +/** + * DefaultApi - axios parameter creator + * @export + */ +export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Get the current system info + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getInfo: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/info` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "GET", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Get the current status of the RAUC updater + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStatus: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/status` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "GET", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Reboot the system + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startReboot: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/reboot` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "POST", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Start the update process + * @param {any} updateFile RAUC firmware image file (*.raucb) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startUpdate: async ( + updateFile: any, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'updateFile' is not null or undefined + assertParamExists("startUpdate", "updateFile", updateFile) + const localVarPath = `/update` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "POST", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + const localVarFormParams = new ((configuration && + configuration.formDataCtor) || + FormData)() + + if (updateFile !== undefined) { + localVarFormParams.append("updateFile", updateFile as any) + } + + localVarHeaderParameter["Content-Type"] = "multipart/form-data" + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + localVarRequestOptions.data = localVarFormParams + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + } +} + +/** + * DefaultApi - functional programming interface + * @export + */ +export const DefaultApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration) + return { + /** + * Get the current system info + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getInfo( + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getInfo(options) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + /** + * Get the current status of the RAUC updater + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getStatus( + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getStatus(options) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + /** + * Reboot the system + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async startReboot( + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.startReboot( + options + ) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + /** + * Start the update process + * @param {any} updateFile RAUC firmware image file (*.raucb) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async startUpdate( + updateFile: any, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.startUpdate( + updateFile, + options + ) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + } +} + +/** + * DefaultApi - factory interface + * @export + */ +export const DefaultApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = DefaultApiFp(configuration) + return { + /** + * Get the current system info + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getInfo(options?: any): AxiosPromise { + return localVarFp + .getInfo(options) + .then((request) => request(axios, basePath)) + }, + /** + * Get the current status of the RAUC updater + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStatus(options?: any): AxiosPromise { + return localVarFp + .getStatus(options) + .then((request) => request(axios, basePath)) + }, + /** + * Reboot the system + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startReboot(options?: any): AxiosPromise { + return localVarFp + .startReboot(options) + .then((request) => request(axios, basePath)) + }, + /** + * Start the update process + * @param {any} updateFile RAUC firmware image file (*.raucb) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startUpdate(updateFile: any, options?: any): AxiosPromise { + return localVarFp + .startUpdate(updateFile, options) + .then((request) => request(axios, basePath)) + }, + } +} + +/** + * DefaultApi - object-oriented interface + * @export + * @class DefaultApi + * @extends {BaseAPI} + */ +export class DefaultApi extends BaseAPI { + /** + * Get the current system info + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public getInfo(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .getInfo(options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Get the current status of the RAUC updater + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public getStatus(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .getStatus(options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Reboot the system + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public startReboot(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .startReboot(options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Start the update process + * @param {any} updateFile RAUC firmware image file (*.raucb) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public startUpdate(updateFile: any, options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .startUpdate(updateFile, options) + .then((request) => request(this.axios, this.basePath)) + } +} diff --git a/ui/src/sebrauc-client/base.ts b/ui/src/sebrauc-client/base.ts new file mode 100644 index 0000000..cf13305 --- /dev/null +++ b/ui/src/sebrauc-client/base.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import {Configuration} from "./configuration" +// Some imports not used depending on template conditions +// @ts-ignore +import globalAxios, {AxiosPromise, AxiosInstance, AxiosRequestConfig} from "axios" + +export const BASE_PATH = "http://localhost".replace(/\/+$/, "") + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +} + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string + options: AxiosRequestConfig +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined + + constructor( + configuration?: Configuration, + protected basePath: string = BASE_PATH, + protected axios: AxiosInstance = globalAxios + ) { + if (configuration) { + this.configuration = configuration + this.basePath = configuration.basePath || this.basePath + } + } +} + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + name: "RequiredError" = "RequiredError" + constructor(public field: string, msg?: string) { + super(msg) + } +} diff --git a/ui/src/sebrauc-client/common.ts b/ui/src/sebrauc-client/common.ts new file mode 100644 index 0000000..c0ce169 --- /dev/null +++ b/ui/src/sebrauc-client/common.ts @@ -0,0 +1,181 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import {Configuration} from "./configuration" +import {RequiredError, RequestArgs} from "./base" +import {AxiosInstance, AxiosResponse} from "axios" + +/** + * + * @export + */ +export const DUMMY_BASE_URL = "https://example.com" + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function ( + functionName: string, + paramName: string, + paramValue: unknown +) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError( + paramName, + `Required parameter ${paramName} was null or undefined when calling ${functionName}.` + ) + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function ( + object: any, + keyParamName: string, + configuration?: Configuration +) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = + typeof configuration.apiKey === "function" + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey + object[keyParamName] = localVarApiKeyValue + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function ( + object: any, + configuration?: Configuration +) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { + username: configuration.username, + password: configuration.password, + } + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function ( + object: any, + configuration?: Configuration +) { + if (configuration && configuration.accessToken) { + const accessToken = + typeof configuration.accessToken === "function" + ? await configuration.accessToken() + : await configuration.accessToken + object["Authorization"] = "Bearer " + accessToken + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function ( + object: any, + name: string, + scopes: string[], + configuration?: Configuration +) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = + typeof configuration.accessToken === "function" + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken + object["Authorization"] = "Bearer " + localVarAccessTokenValue + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search) + for (const object of objects) { + for (const key in object) { + if (Array.isArray(object[key])) { + searchParams.delete(key) + for (const item of object[key]) { + searchParams.append(key, item) + } + } else { + searchParams.set(key, object[key]) + } + } + } + url.search = searchParams.toString() +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function ( + value: any, + requestOptions: any, + configuration?: Configuration +) { + const nonString = typeof value !== "string" + const needsSerialization = + nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers["Content-Type"]) + : nonString + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : value || "" +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function ( + axiosArgs: RequestArgs, + globalAxios: AxiosInstance, + BASE_PATH: string, + configuration?: Configuration +) { + return >( + axios: AxiosInstance = globalAxios, + basePath: string = BASE_PATH + ) => { + const axiosRequestArgs = { + ...axiosArgs.options, + url: (configuration?.basePath || basePath) + axiosArgs.url, + } + return axios.request(axiosRequestArgs) + } +} diff --git a/ui/src/sebrauc-client/configuration.ts b/ui/src/sebrauc-client/configuration.ts new file mode 100644 index 0000000..5e30cfa --- /dev/null +++ b/ui/src/sebrauc-client/configuration.ts @@ -0,0 +1,123 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +export interface ConfigurationParameters { + apiKey?: + | string + | Promise + | ((name: string) => string) + | ((name: string) => Promise) + username?: string + password?: string + accessToken?: + | string + | Promise + | ((name?: string, scopes?: string[]) => string) + | ((name?: string, scopes?: string[]) => Promise) + basePath?: string + baseOptions?: any + formDataCtor?: new () => any +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: + | string + | Promise + | ((name: string) => string) + | ((name: string) => Promise) + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: + | string + | Promise + | ((name?: string, scopes?: string[]) => string) + | ((name?: string, scopes?: string[]) => Promise) + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey + this.username = param.username + this.password = param.password + this.accessToken = param.accessToken + this.basePath = param.basePath + this.baseOptions = param.baseOptions + this.formDataCtor = param.formDataCtor + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp( + "^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$", + "i" + ) + return ( + mime !== null && + (jsonMime.test(mime) || + mime.toLowerCase() === "application/json-patch+json") + ) + } +} diff --git a/ui/src/sebrauc-client/index.ts b/ui/src/sebrauc-client/index.ts new file mode 100644 index 0000000..3b3f852 --- /dev/null +++ b/ui/src/sebrauc-client/index.ts @@ -0,0 +1,16 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +export * from "./api" +export * from "./configuration" diff --git a/ui/src/style/index.scss b/ui/src/style/index.scss index 5774a08..1f333d4 100644 --- a/ui/src/style/index.scss +++ b/ui/src/style/index.scss @@ -1,3 +1,5 @@ +@use "table"; + html, body { height: 100%; diff --git a/ui/src/style/table.scss b/ui/src/style/table.scss new file mode 100644 index 0000000..c522f5d --- /dev/null +++ b/ui/src/style/table.scss @@ -0,0 +1,47 @@ +.table-wrapper { + width: 100%; + overflow-x: auto; +} + +.table { + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; + border-collapse: collapse; + margin: 0; + padding: 0; + width: 100%; + + &.no-bottom-border { + &, + > tr:last-child, + :not(thead) tr:last-child { + border-bottom: none; + } + } +} + +.table caption { + font-size: 1.5em; + margin: 0.5em 0 0.75em; +} + +.table tr { + border-bottom: 1px solid #ddd; + padding: 0.35em; +} + +.table th, +.table td { + padding: 0.625em; + text-align: left; + + .icon { + color: #1f85de; + } +} + +.table th { + font-size: 0.85em; + letter-spacing: 0.085em; + text-transform: uppercase; +} diff --git a/ui/src/util/apiUrls.ts b/ui/src/util/apiUrls.ts index 4a9d51b..724d52b 100644 --- a/ui/src/util/apiUrls.ts +++ b/ui/src/util/apiUrls.ts @@ -1,3 +1,5 @@ +import {Configuration, DefaultApi} from "../sebrauc-client" + let apiHost = document.location.host const httpProto = document.location.protocol const wsProto = httpProto === "https:" ? "wss:" : "ws:" @@ -9,4 +11,10 @@ if (import.meta.env.VITE_API_HOST !== undefined) { const apiUrl = `${httpProto}//${apiHost}/api` const wsUrl = `${wsProto}//${apiHost}/api/ws` -export {apiUrl, wsUrl} +let apicfg = new Configuration({ + basePath: apiUrl, +}) + +const sebraucApi = new DefaultApi(apicfg) + +export {apiUrl, wsUrl, sebraucApi} diff --git a/ui/src/util/colors.ts b/ui/src/util/colors.ts new file mode 100644 index 0000000..3202366 --- /dev/null +++ b/ui/src/util/colors.ts @@ -0,0 +1,7 @@ +class colors { + static readonly RED = "#FF0039" + static readonly GREEN = "#148420" + static readonly BLUE = "#1f85de" +} + +export default colors diff --git a/ui/src/util/config.ts b/ui/src/util/config.ts new file mode 100644 index 0000000..1e0e124 --- /dev/null +++ b/ui/src/util/config.ts @@ -0,0 +1,23 @@ +export interface Config { + version: string +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +declare global { + interface Window { + config?: any + } +} + +function isConfig(object: any): object is Config { + return typeof object === "object" && "version" in object +} + +export function getConfig(): Config { + if (isConfig(window.config)) { + return window.config + } + return { + version: "dev", + } +} diff --git a/ui/src/util/functions.ts b/ui/src/util/functions.ts new file mode 100644 index 0000000..18179e2 --- /dev/null +++ b/ui/src/util/functions.ts @@ -0,0 +1,18 @@ +function secondsToString(seconds: number): string { + const numyears = Math.floor(seconds / 31536000) + const numdays = Math.floor((seconds % 31536000) / 86400) + const numhours = Math.floor(((seconds % 31536000) % 86400) / 3600) + const numminutes = Math.floor((((seconds % 31536000) % 86400) % 3600) / 60) + const numseconds = (((seconds % 31536000) % 86400) % 3600) % 60 + + let res = [] + if (numyears > 0) res.push(numyears + "yr") + if (numdays > 0) res.push(numdays + "d") + if (numhours > 0) res.push(numhours + "h") + if (numminutes > 0) res.push(numminutes + "m") + if (seconds < 60) res.push(numseconds + "s") + + return res.join(" ") +} + +export {secondsToString} diff --git a/ui/src/util/version.ts b/ui/src/util/version.ts deleted file mode 100644 index 9991968..0000000 --- a/ui/src/util/version.ts +++ /dev/null @@ -1,7 +0,0 @@ -let version = import.meta.env.VITE_VERSION - -if (version === undefined) { - version = "unknown" -} - -export {version} diff --git a/ui/src/util/websocket.ts b/ui/src/util/websocket.ts new file mode 100644 index 0000000..8d75673 --- /dev/null +++ b/ui/src/util/websocket.ts @@ -0,0 +1,92 @@ +import {wsUrl} from "./apiUrls" + +class WebsocketAPI { + private static ws: WebsocketAPI | undefined + + private conn: WebSocket | undefined + private wsConnected: boolean + + private clients: Set + + private constructor() { + this.clients = new Set() + this.wsConnected = false + + if (window.WebSocket) { + this.connect() + } else { + console.log("Your browser does not support WebSockets") + } + } + + private setStatus(wsConnected: boolean) { + if (wsConnected !== this.wsConnected) { + this.wsConnected = wsConnected + this.clients.forEach((client) => { + client.statusCallback(this.wsConnected) + }) + } + } + + private connect() { + this.conn = new WebSocket(wsUrl) + this.conn.onopen = () => { + this.setStatus(true) + console.log("WS connected") + } + this.conn.onclose = () => { + this.setStatus(false) + console.log("WS connection closed") + window.setTimeout(() => this.connect(), 3000) + } + this.conn.onmessage = (evt) => { + this.clients.forEach((client) => { + client.msgCallback(evt) + }) + } + } + + static Get(): WebsocketAPI { + if (this.ws === undefined) { + this.ws = new WebsocketAPI() + } + return this.ws + } + + isConnected(): boolean { + return this.wsConnected + } + + addClient(client: WebsocketClient) { + console.log("added client", client) + this.clients.add(client) + } + + removeClient(client: WebsocketClient) { + console.log("removed client", client) + this.clients.delete(client) + } +} + +export default class WebsocketClient { + statusCallback: (wsConnected: boolean) => void + msgCallback: (evt: MessageEvent) => void + + constructor( + statusCallback: (wsConnected: boolean) => void, + msgCallback: (evt: MessageEvent) => void + ) { + this.statusCallback = statusCallback + this.msgCallback = msgCallback + + this.api().addClient(this) + } + + api(): WebsocketAPI { + return WebsocketAPI.Get() + } + + destroy() { + this.api().removeClient(this) + } +} diff --git a/ui/ui.go b/ui/ui.go index 1d28f93..d66f422 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,10 +1,64 @@ package ui import ( + "bytes" "embed" + "encoding/json" + "io/fs" + "net/http" + + "code.thetadev.de/TSGRain/SEBRAUC/src/server/middleware" + "code.thetadev.de/TSGRain/SEBRAUC/src/util" + "github.com/gin-gonic/gin" ) -const AssetsDir = "dist" +const distDir = "dist" //go:embed dist/** -var Assets embed.FS +var assets embed.FS + +type uiConfig struct { + Version string `json:"version"` +} + +func subFS(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + return sub +} + +func distFS() fs.FS { + return subFS(assets, distDir) +} + +func Register(r gin.IRouter) { + indexHandler := getIndexHandler() + + uiAssets := r.Group("/assets", middleware.Cache) + + r.GET("/", indexHandler) + r.GET("/index.html", indexHandler) + + uiAssets.StaticFS("/", http.FS(subFS(distFS(), "assets"))) +} + +func getIndexHandler() gin.HandlerFunc { + content, err := fs.ReadFile(distFS(), "index.html") + if err != nil { + panic(err) + } + + uiConfigBytes, err := json.Marshal(uiConfig{ + Version: util.Version(), + }) + if err != nil { + panic(err) + } + content = bytes.ReplaceAll(content, []byte("\"%CONFIG%\""), uiConfigBytes) + + return func(c *gin.Context) { + c.Data(200, "text/html", content) + } +} diff --git a/ui/ui_test.go b/ui/ui_test.go new file mode 100644 index 0000000..6089bbd --- /dev/null +++ b/ui/ui_test.go @@ -0,0 +1,87 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "os" + "path" + "regexp" + "testing" + + "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestUI(t *testing.T) { + tests := []struct { + name string + path string + contains string + cached bool + }{ + { + name: "index_html", + path: "/", + contains: "SEBRAUC", + cached: false, + }, + { + name: "index_html2", + path: "/index.html", + contains: "SEBRAUC", + cached: false, + }, + { + name: "index_js", + path: path.Join("/assets", getIndexJS()), + contains: "SEBRAUC", + cached: true, + }, + } + + router := gin.New() + Register(router) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", tt.path, nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), tt.contains) + + ccHeader := w.Header().Get("Cache-Control") + + if tt.cached { + assert.Equal(t, "public, max-age=604800, immutable", ccHeader) + } else { + assert.Equal(t, "", ccHeader) + } + }) + } +} + +func getIndexJS() string { + baseDir := "ui/dist/assets" + indexExp := regexp.MustCompile(`index\.[0-9a-f]{8}\.js`) + + fixtures.CdProjectRoot() + distDir, err := os.Open(baseDir) + if err != nil { + panic(err) + } + + list, err := distDir.Readdir(-1) + if err != nil { + panic(err) + } + + for _, f := range list { + if indexExp.MatchString(f.Name()) { + return f.Name() + } + } + panic("no index.js found") +} diff --git a/woodpecker.yml b/woodpecker.yml new file mode 100644 index 0000000..2271c55 --- /dev/null +++ b/woodpecker.yml @@ -0,0 +1,14 @@ +pipeline: + Frontend build: + image: node:16-alpine + commands: + - cd ui + - npm install -g pnpm + - pnpm install + - pnpm run build + Backend test: + image: golangci/golangci-lint:latest + commands: + - go get -t ./src/... + - golangci-lint run --timeout 5m + - go test -v ./src/...