Compare commits

..

26 commits
v0.1.1 ... main

Author SHA1 Message Date
28e51df5b8 fix: lint errors
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-01-03 21:47:22 +01:00
7620f0d9f2 ci: add woodpecker.yml
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-01-03 21:22:19 +01:00
4d7c4646bb add woodpecker ci config
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2022-05-08 02:21:13 +02:00
e98b29d666 add woodpecker ci 2022-05-08 02:19:13 +02:00
e2c3c2ce6b Add README
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is failing
stream: send last broadcast on connect
2022-02-21 20:14:31 +01:00
69318d96d2 add HTTP basic auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-25 20:52:04 +01:00
df98c40853 increase update test delay
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-25 18:19:41 +01:00
df78f37d86 add server_test
Some checks failed
continuous-integration/drone/push Build is failing
2021-12-25 18:03:43 +01:00
92bed651b7 add cmdline flags
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-24 02:14:13 +01:00
acd1db7363 integrated config
All checks were successful
continuous-integration/drone/push Build is passing
fixed method type for /reboot in apidoc
2021-12-24 01:57:33 +01:00
312de77236 add configfile 2021-12-23 23:45:59 +01:00
3c9867b75b fix apidoc
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-19 22:44:30 +01:00
e3cb739db5 fix SysinfoCard: reloader not cleared 2021-12-19 22:37:43 +01:00
4cc757d550 add tests for rauc, ui and middleware 2021-12-19 22:29:12 +01:00
71764dd6fa add caching/error handling middleware
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-19 17:05:58 +01:00
e1c4c58684 add swagger page 2021-12-18 00:39:08 +01:00
8714ad966b auto-generate swagger documentation
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-18 00:05:57 +01:00
c8e2d2a216 migrated to gin-gonic
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-17 19:14:59 +01:00
0428ad3ebc add central websocket client 2021-12-12 22:41:02 +01:00
0125f4e3fe fix rauc status command
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-06 01:32:03 +01:00
44001bb7e7 add hostname to sysinfo
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-06 00:59:23 +01:00
7465ef3380 add system info, openapi-generated client 2021-12-06 00:52:14 +01:00
85c0073651 Add sysinfo api endpoint 2021-12-03 00:49:20 +01:00
3e29e04ac3 Add system info api definition
Some checks reported errors
continuous-integration/drone/push Build was killed
2021-12-02 20:29:47 +01:00
8c1fd2a6ab Fix makefile 2021-12-02 20:29:47 +01:00
25cf158c3a a11y fixes
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-23 18:12:49 +01:00
100 changed files with 5642 additions and 612 deletions

View file

@ -1,11 +1,11 @@
root = "./src" root = "."
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./src/." cmd = "go build -o ./tmp/main ./src/."
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor"] exclude_dir = ["tmp", "vendor", "ui/dist", "ui/node_modules", "ui/src"]
exclude_file = [] exclude_file = []
exclude_regex = [] exclude_regex = []
exclude_unchanged = false exclude_unchanged = false

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
build build
tmp tmp
/sebrauc.toml
/htpasswd

View file

@ -1,7 +1,6 @@
linters: linters:
presets: presets:
- bugs - bugs
- unused
- import - import
- module - module
@ -14,11 +13,17 @@ linters:
disable: disable:
- scopelint - scopelint
- noctx
linters-settings: linters-settings:
lll: lll:
line-length: 88 line-length: 88
tab-width: 4
gocognit: gocognit:
min-complexity: 10 min-complexity: 10
nestif: nestif:
min-complexity: 3 min-complexity: 3
errcheck:
exclude-functions:
- "(*github.com/gin-gonic/gin.Context).Error"
- "(*github.com/gin-gonic/gin.Context).AbortWithError"

View file

@ -6,10 +6,7 @@ repos:
name: GolangCI Lint name: GolangCI Lint
- id: go-test-repo-mod - id: go-test-repo-mod
name: Backend tests name: Backend tests
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.1
hooks:
- id: prettier
- repo: local - repo: local
hooks: hooks:
- id: tsc - id: tsc
@ -20,3 +17,8 @@ repos:
args: ["-p", "./ui/tsconfig.json"] args: ["-p", "./ui/tsconfig.json"]
additional_dependencies: ["typescript@4.5.2"] additional_dependencies: ["typescript@4.5.2"]
pass_filenames: false pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.1
hooks:
- id: prettier

14
.woodpecker.yml Normal file
View file

@ -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/...

View file

@ -1,19 +1,35 @@
SRC_DIR=./src SRC_DIR=./src
UI_DIR=./ui 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: setup:
go get -t ./src/...
cd ${UI_DIR} && pnpm install cd ${UI_DIR} && pnpm install
test: test:
go test -v ./src/... go test -v ./src/...
lint:
golangci-lint run
cd ${UI_DIR} && npm run format && npm run lint
build-ui: build-ui:
cd ${UI_DIR} && VITE_VERSION=${VERSION} pnpm run build cd ${UI_DIR} && pnpm run build
build-server: 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 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/**

View file

@ -1 +0,0 @@
![SEBRAUC](ui/src/assets/logo_border.svg)

460
README.rst Normal file
View file

@ -0,0 +1,460 @@
.. image:: ui/src/assets/logo_border.svg
SEBRAUC ist eine einfach zu bedienende Weboberfläche für den
`RAUC <https://github.com/rauc/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 <https://github.com/rauc/rauc-hawkbit-updater>`__
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 <https://github.com/gin-gonic/gin>`_ 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 <https://preactjs.com/>`_
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 <https://dev.to/this-is-learning/javascript-framework-todomvc-size-comparison-504f>`_
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 <https://code.thetadev.de/TSGRain/SEBRAUC/src/branch/main/sebrauc.example.toml>`__.
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 <https://golangci-lint.run/>`_ (Überprüft Go-Code auf
Formatierung, Code Style und bestimmete Fehler)
- `air <https://github.com/cosmtrek/air>`_ (Kompiliert und startet
Go-Anwendungen bei Codeänderungen neu)
- `go-swagger <https://github.com/go-swagger/go-swagger>`_
(Generierung einer OpenAPI-Dokumentation aus Go-Servercode, der nach
einem bestimmten Schema kommentiert wurde)
- `openapi-generator <https://openapi-generator.tech/docs/installation>`_
(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.

BIN
_screenshots/sysinfo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

BIN
_screenshots/update0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
_screenshots/update1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
_screenshots/update2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
_screenshots/update3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

25
go.mod
View file

@ -3,10 +3,29 @@ module code.thetadev.de/TSGRain/SEBRAUC
go 1.16 go 1.16
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect code.thetadev.de/TSGRain/ginzip v0.1.1
github.com/gofiber/fiber/v2 v2.21.0 github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db
github.com/gofiber/websocket/v2 v2.0.12 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/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/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 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
) )

138
go.sum
View file

@ -1,48 +1,128 @@
github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E= code.thetadev.de/TSGRain/ginzip v0.1.1 h1:+X0L6qumEZiKYSLmM+Q0LqKVHsKvdcg4CVzsEpvM7fk=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db h1:oZ4U9IqO8NS+61OmGTBi8vopzqTRxwQeogyBHdrhjbc=
github.com/fasthttp/websocket v1.4.3-rc.9/go.mod h1:eXL2zqDbexYJxaCw8/PQlm7VcMK6uoGvwbYbTdt4dFo= github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db/go.mod h1:Pk7/9x6tyChFTkahDvLBQMlvdsWvfC+yU8HTT5VD314=
github.com/gofiber/fiber/v2 v2.20.1/go.mod h1:/LdZHMUXZvTTo7gU4+b1hclqCAdoQphNQ9bi9gutPyI= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/gofiber/fiber/v2 v2.21.0 h1:tdRNrgqWqcHWBwE3o51oAleEVsil4Ro02zd2vMEuP4Q= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/gofiber/fiber/v2 v2.21.0/go.mod h1:MR1usVH3JHYRyQwMe2eZXRSZHRX38fkV+A7CPB+DlDQ= github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA=
github.com/gofiber/websocket/v2 v2.0.12 h1:jKwTrXiOut9UGOGEzFTAD6gq+/78mM3NcrI05VbxjAU= github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk=
github.com/gofiber/websocket/v2 v2.0.12/go.mod h1:lQRy0u5ACJfiez/e/bhGeYvM0/M940Y3NFw14U3/otI= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
github.com/valyala/fasthttp v1.29.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/valyala/fasthttp v1.31.0 h1:lrauRLII19afgCs2fnWRJ4M5IkV0lo2FqA61uGkNBfE= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 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-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-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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/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/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.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= 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 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-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 h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -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

44
sebrauc.example.toml Normal file
View file

@ -0,0 +1,44 @@
# SEBRAUC config file example
# Temporary directory
# Update packages are stored temporarily under Tmpdir/update-<UUID>.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"

View file

@ -1,8 +0,0 @@
package assets
import (
"embed"
)
//go:embed files/**
var Assets embed.FS

View file

@ -1,88 +0,0 @@
<!-- See https://github.com/gorilla/websocket/blob/master/examples/chat/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Chat Example</title>
<script type="text/javascript">
window.onload = function () {
var conn
var msg = document.getElementById("msg")
var log = document.getElementById("log")
function appendLog(item) {
var doScroll =
log.scrollTop > log.scrollHeight - log.clientHeight - 1
log.appendChild(item)
if (doScroll) {
log.scrollTop = log.scrollHeight - log.clientHeight
}
}
if (window["WebSocket"]) {
conn = new WebSocket("ws://" + document.location.host + "/api/ws")
conn.onclose = function (evt) {
var item = document.createElement("div")
item.innerHTML = "<b>Connection closed.</b>"
appendLog(item)
}
conn.onmessage = function (evt) {
var messages = evt.data.split("\n")
for (var i = 0; i < messages.length; i++) {
var item = document.createElement("div")
item.innerText = messages[i]
appendLog(item)
}
}
} else {
var item = document.createElement("div")
item.innerHTML = "<b>Your browser does not support WebSockets.</b>"
appendLog(item)
}
}
</script>
<style type="text/css">
html {
overflow: hidden;
}
body {
overflow: hidden;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
background: gray;
}
#log {
background: white;
margin: 0;
padding: 0.5em 0.5em 0.5em 0.5em;
position: absolute;
top: 4em;
left: 0.5em;
right: 0.5em;
bottom: 3em;
overflow: auto;
}
#form {
padding: 0 0.5em 0 0.5em;
margin: 0;
position: absolute;
bottom: 1em;
left: 0px;
width: 100%;
overflow: hidden;
}
</style>
</head>
<body>
<div id="log"></div>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="updateFile" id="file" />
<input type="submit" name="submit" />
</form>
</body>
</html>

130
src/config/config.go Normal file
View file

@ -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-<UUID>.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("")
}

161
src/config/config_test.go Normal file
View file

@ -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)
})
}
}

View file

@ -41,22 +41,54 @@ LastError: Failed to check bundle identifier: Invalid identifier. ` +
idle idle
Installing ` + "/app/demo` failed" Installing ` + "/app/demo` failed"
func main() { const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` +
arg := "" `"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` +
if len(os.Args) > 1 { `"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` +
arg = os.Args[1] `"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 lines string
switch arg {
case "fail":
lines = outputFailure
default:
lines = outputSuccess
}
func printLinesWithDelay(lines string, delay time.Duration) {
for _, line := range strings.Split(lines, "\n") { for _, line := range strings.Split(lines, "\n") {
fmt.Println(line) 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)
} }
} }

View file

@ -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"
)

View file

@ -0,0 +1,3 @@
plain:1234
md5:$apr1$V2wxHBfb$gBU2yIYjTIeciKapglql6/
bcrypt:$2y$05$f9rV6uTQEEnNR1saPksExOR31LauUZzpLDhpCrodAvxX3zZ6nLy12

View file

@ -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"

View file

@ -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"

View file

@ -3,8 +3,11 @@ package fixtures
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
var envPrefixes = []string{"SEBRAUC", "RAUC_MOCK"}
func doesFileExist(filepath string) bool { func doesFileExist(filepath string) bool {
_, err := os.Stat(filepath) _, err := os.Stat(filepath)
return !os.IsNotExist(err) return !os.IsNotExist(err)
@ -38,3 +41,20 @@ func GetTestfilesDir() string {
CdProjectRoot() CdProjectRoot()
return filepath.Join("src", "fixtures", "testfiles") 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)
}
}
}
}

View file

@ -35,3 +35,15 @@ func TestCdProjectRoot(t *testing.T) {
CdProjectRoot() CdProjectRoot()
assert.True(t, doesFileExist("go.sum")) 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)
}

View file

@ -1,22 +1,45 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"log" "log"
"os"
"code.thetadev.de/TSGRain/SEBRAUC/src/config"
"code.thetadev.de/TSGRain/SEBRAUC/src/server" "code.thetadev.de/TSGRain/SEBRAUC/src/server"
"code.thetadev.de/TSGRain/SEBRAUC/src/util" "code.thetadev.de/TSGRain/SEBRAUC/src/util"
"code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
) )
func main() { const titleArt = ` _____ __________ ____ ___ __ ________
fmt.Println("SEBRAUC " + util.Version()) / ___// ____/ __ )/ __ \/ | / / / / ____/
\__ \/ __/ / __ / /_/ / /| |/ / / / /
___/ / /___/ /_/ / _, _/ ___ / /_/ / /___
/____/_____/_____/_/ |_/_/ |_\____/\____/ `
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("Test mode active - no update operations are executed.")
fmt.Println("Build with -tags prod to enable live mode.") 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() err := srv.Run()
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)

24
src/model/error.go Normal file
View file

@ -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"`
}

34
src/model/rauc_status.go Normal file
View file

@ -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"`
}

View file

@ -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"`
}

75
src/model/system_info.go Normal file
View file

@ -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:"-"`
}

View file

@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"sync" "sync"
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
"code.thetadev.de/TSGRain/SEBRAUC/src/util" "code.thetadev.de/TSGRain/SEBRAUC/src/util"
) )
@ -18,32 +19,26 @@ var (
) )
type Rauc struct { type Rauc struct {
broadcast chan string cmdRaucInstall string
status RaucStatus bc util.Broadcaster
status model.RaucStatus
runningMtx sync.Mutex runningMtx sync.Mutex
} }
type RaucStatus struct { func New(cmdRaucInstall string) *Rauc {
Installing bool `json:"installing"` return &Rauc{
Percent int `json:"percent"` cmdRaucInstall: cmdRaucInstall,
Message string `json:"message"` }
LastError string `json:"last_error"`
Log string `json:"log"`
} }
func NewRauc(broadcast chan string) *Rauc { func (r *Rauc) SetBroadcaster(bc util.Broadcaster) {
r := &Rauc{ r.bc = bc
broadcast: broadcast, r.bcStatus()
}
r.broadcast <- r.GetStatusJson()
return r
} }
func (r *Rauc) completed(updateFile string) { func (r *Rauc) completed(updateFile string) {
r.status.Installing = false r.status.Installing = false
r.broadcast <- r.GetStatusJson() r.bcStatus()
_ = os.Remove(updateFile) _ = os.Remove(updateFile)
} }
@ -65,12 +60,12 @@ func (r *Rauc) RunRauc(updateFile string) error {
} }
// Reset installer // Reset installer
r.status = RaucStatus{ r.status = model.RaucStatus{
Installing: true, 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() readPipe, _ := cmd.StdoutPipe()
cmd.Stderr = cmd.Stdout cmd.Stderr = cmd.Stdout
@ -100,7 +95,7 @@ func (r *Rauc) RunRauc(updateFile string) error {
} }
if hasUpdate { if hasUpdate {
r.broadcast <- r.GetStatusJson() r.bcStatus()
} }
} }
}() }()
@ -122,11 +117,19 @@ func (r *Rauc) RunRauc(updateFile string) error {
return nil return nil
} }
func (r *Rauc) GetStatus() RaucStatus { func (r *Rauc) GetStatus() model.RaucStatus {
return r.status return r.status
} }
func (r *Rauc) GetStatusJson() string { func (r *Rauc) GetStatusJson() []byte {
statusJson, _ := json.Marshal(r.status) statusJson, err := json.Marshal(r.status)
return string(statusJson) if err != nil {
return []byte{}
}
return statusJson
}
func (r *Rauc) bcStatus() {
r.bc.Broadcast(r.GetStatusJson())
} }

118
src/rauc/rauc_test.go Normal file
View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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"))
})
}

View file

@ -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")
}

View file

@ -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"))
}

View file

@ -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)
}

View file

@ -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())
}
}

View file

@ -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"))
}

View file

@ -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 package server
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "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/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"
"code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
"code.thetadev.de/TSGRain/SEBRAUC/ui" "code.thetadev.de/TSGRain/SEBRAUC/ui"
"github.com/gofiber/fiber/v2" "github.com/gin-contrib/cors"
"github.com/gofiber/fiber/v2/middleware/compress" "github.com/gin-gonic/gin"
"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/google/uuid" "github.com/google/uuid"
) )
type SEBRAUCServer struct { type SEBRAUCServer struct {
address string config *config.Config
raucUpdater *rauc.Rauc streamer *stream.API
hub *MessageHub updater *rauc.Rauc
sysinfo *sysinfo.Sysinfo
tmpdir string tmpdir string
} }
type statusMessage struct { func NewServer(config *config.Config) *SEBRAUCServer {
Success bool `json:"success"` updater := rauc.New(config.Commands.RaucInstall)
Msg string `json:"msg"` 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 { updater.SetBroadcaster(streamer)
hub := NewHub()
raucUpdater := rauc.NewRauc(hub.Broadcast) tmpdir := util.GetTmpdir(config.Tmpdir)
tmpdir, err := util.GetTmpdir()
if err != nil {
panic(err)
}
return &SEBRAUCServer{ return &SEBRAUCServer{
address: address, config: config,
raucUpdater: raucUpdater, updater: updater,
hub: hub, streamer: streamer,
sysinfo: sysinfo,
tmpdir: tmpdir, tmpdir: tmpdir,
} }
} }
func (srv *SEBRAUCServer) Run() error { func (srv *SEBRAUCServer) getRouter() *gin.Engine {
app := fiber.New(fiber.Config{ router := gin.New()
AppName: "SEBRAUC", router.Use(gin.Logger())
BodyLimit: 1024 * 1024 * 1024, _ = router.SetTrustedProxies(nil)
ErrorHandler: errorHandler,
DisableStartupMessage: true,
})
app.Use(logger.New()) if mode.IsDev() {
router.Use(cors.Default())
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{ router.Use(middleware.ErrorHandler(false), middleware.PanicHandler(false))
Root: http.FS(ui.Assets), router.NoRoute(func(c *gin.Context) { c.Error(util.ErrPageNotFound) })
PathPrefix: ui.AssetsDir,
MaxAge: 7200,
}))
// ROUTES if srv.config.Authentication.Enable {
app.Get("/api/ws", websocket.New(srv.hub.Handler)) router.Use(middleware.Authentication(srv.config.Authentication.PasswdFile))
app.Post("/api/update", srv.controllerUpdate) }
app.Get("/api/status", srv.controllerStatus)
app.Post("/api/reboot", srv.controllerReboot)
// Start messaging hub api := router.Group("/api",
go srv.hub.Run() middleware.ErrorHandler(true), middleware.PanicHandler(true))
return app.Listen(srv.address) // 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) controllerUpdate(c *fiber.Ctx) error { func (srv *SEBRAUCServer) Run() error {
router := srv.getRouter()
return router.Run(fmt.Sprintf("%s:%d",
srv.config.Server.Address, srv.config.Server.Port))
}
// 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") file, err := c.FormFile("updateFile")
if err != nil { if err != nil {
return err c.Error(err)
return
} }
uid, err := uuid.NewRandom() uid, err := uuid.NewRandom()
if err != nil { if err != nil {
return err c.Error(err)
return
} }
updateFile := fmt.Sprintf("%s/update_%s.raucb", srv.tmpdir, uid.String()) updateFile := fmt.Sprintf("%s/update_%s.raucb", srv.tmpdir, uid.String())
err = c.SaveFile(file, updateFile) err = c.SaveUploadedFile(file, updateFile)
if err != nil { if err != nil {
return err c.Error(err)
return
} }
err = srv.raucUpdater.RunRauc(updateFile) err = srv.updater.RunRauc(updateFile)
if err == nil { if err == nil {
writeStatus(c, true, "Update started") writeStatus(c, true, "Update started")
} else if errors.Is(err, util.ErrAlreadyRunning) {
return fiber.NewError(fiber.StatusConflict, "already running")
} else { } else {
return err c.Error(err)
return
} }
return nil
} }
func (srv *SEBRAUCServer) controllerStatus(c *fiber.Ctx) error { // swagger:operation GET /status getStatus
c.Context().SetStatusCode(200) //
_ = c.JSON(srv.raucUpdater.GetStatus()) // # Get the current status of the RAUC updater
return nil //
// ---
// 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 { // swagger:operation GET /info getInfo
go util.Reboot(5 * time.Second) //
// # 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") writeStatus(c, true, "System is rebooting")
return nil
} }
func errorHandler(c *fiber.Ctx, err error) error { // controllerError throws an error for testing
// API error handling func (srv *SEBRAUCServer) controllerError(c *gin.Context) {
if strings.HasPrefix(c.Path(), "/api") { c.Error(util.HttpErrNew("error test", http.StatusBadRequest))
writeStatus(c, false, err.Error())
}
return err
} }
func writeStatus(c *fiber.Ctx, success bool, msg string) { // controllerPanic panics for testing
_ = c.JSON(statusMessage{ 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, Success: success,
Msg: msg, Msg: msg,
}) })
if success {
c.Context().SetStatusCode(200)
}
} }

206
src/server/server_test.go Normal file
View file

@ -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"))
})
}

119
src/server/stream/client.go Normal file
View file

@ -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)
}

38
src/server/stream/once.go Normal file
View file

@ -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
}

View file

@ -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
}
}

161
src/server/stream/stream.go Normal file
View file

@ -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
}

View file

@ -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")
}

View file

@ -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)
})
}

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<title>SEBRAUC API documentation</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="swagger.yaml"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
</body>
</html>

View file

@ -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"

View file

@ -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",
)
}

188
src/sysinfo/sysinfo.go Normal file
View file

@ -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
}

129
src/sysinfo/sysinfo_test.go Normal file
View file

@ -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)
}

View file

@ -1,11 +0,0 @@
//go:build prod
// +build prod
package util
const (
RebootCmd = "shutdown -r 0"
UpdateCmd = "rauc install"
TestMode = false
)

View file

@ -1,11 +0,0 @@
//go:build !prod
// +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
)

30
src/util/counter.go Normal file
View file

@ -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
}

30
src/util/counter_test.go Normal file
View file

@ -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())
}

View file

@ -1,8 +1,13 @@
package util package util
import "errors" import (
"errors"
"net/http"
)
var ( var (
ErrAlreadyRunning = errors.New("rauc already running") ErrAlreadyRunning = HttpErrNew("rauc already running", http.StatusConflict)
ErrFileDoesNotExist = errors.New("file does not exist") ErrFileDoesNotExist = errors.New("file does not exist")
ErrPageNotFound = HttpErrNew("page not found", http.StatusNotFound)
ErrUnauthorized = HttpErrNew("unauthorized", http.StatusUnauthorized)
) )

39
src/util/http_error.go Normal file
View file

@ -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
}

46
src/util/mode/mode.go Normal file
View file

@ -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")
}
}

View file

@ -0,0 +1,6 @@
//go:build !prod
// +build !prod
package mode
const appmode = Dev

View file

@ -0,0 +1,6 @@
//go:build prod
// +build prod
package mode
const appmode = Prod

View file

@ -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")
})
}

5
src/util/types.go Normal file
View file

@ -0,0 +1,5 @@
package util
type Broadcaster interface {
Broadcast(msg []byte)
}

View file

@ -1,6 +1,7 @@
package util package util
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -25,10 +26,24 @@ func CreateDirIfNotExists(dirpath string) error {
return nil return nil
} }
func GetTmpdir() (string, error) { func GetTmpdir(tmpdirPath string) string {
tmpdir := filepath.Join(os.TempDir(), tmpdirName) tmpdir := tmpdirPath
// Default temporary directory
if tmpdirPath == "" {
tmpdir = filepath.Join(os.TempDir(), tmpdirName)
}
err := CreateDirIfNotExists(tmpdir) 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 { func CommandFromString(cmdString string) *exec.Cmd {
@ -41,8 +56,41 @@ func CommandFromString(cmdString string) *exec.Cmd {
return exec.Command(parts[0], parts[1:]...) return exec.Command(parts[0], parts[1:]...)
} }
func Reboot(t time.Duration) { func Reboot(rebootCmd string, t time.Duration) {
time.Sleep(t) time.Sleep(t)
cmd := CommandFromString(RebootCmd) cmd := CommandFromString(rebootCmd)
_ = cmd.Run() _ = 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, "; "))
}

View file

@ -1,11 +1,13 @@
package util package util
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures" "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -41,11 +43,25 @@ func TestDoesFileExist(t *testing.T) {
} }
func TestTmpdir(t *testing.T) { func TestTmpdir(t *testing.T) {
td, err := GetTmpdir() tests := []struct {
if err != nil { name string
panic(err) path string
}{
{
name: "default",
path: "",
},
{
name: "custom",
path: filepath.Join(os.TempDir(), "customTmpdir"),
},
} }
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
td := GetTmpdir(tt.path)
assert.DirExists(t, td)
tfile := filepath.Join(td, "test.txt") tfile := filepath.Join(td, "test.txt")
f, err := os.Create(tfile) f, err := os.Create(tfile)
if err != nil { if err != nil {
@ -62,6 +78,11 @@ func TestTmpdir(t *testing.T) {
} }
assert.FileExists(t, tfile) assert.FileExists(t, tfile)
RemoveTmpdir(tt.path)
assert.NoDirExists(t, td)
})
}
} }
func TestCommandFromString(t *testing.T) { func TestCommandFromString(t *testing.T) {
@ -103,7 +124,78 @@ func TestReboot(t *testing.T) {
testfile := "/tmp/sebrauc_reboot_test" testfile := "/tmp/sebrauc_reboot_test"
_ = os.Remove(testfile) _ = os.Remove(testfile)
Reboot(0) Reboot(testcmd.Reboot, 0)
assert.FileExists(t, testfile) 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)
})
}
}

View file

@ -1,2 +1 @@
VITE_VERSION=dev
VITE_API_HOST=127.0.0.1:8080 VITE_API_HOST=127.0.0.1:8080

View file

@ -1,2 +1,4 @@
node_modules node_modules
dist dist
tmp
.tmp

View file

@ -7,7 +7,11 @@
<title>SEBRAUC</title> <title>SEBRAUC</title>
</head> </head>
<body> <body>
<noscript>You have to enable JavaScript to use SEBRAUC.</noscript>
<div id="app"></div> <div id="app"></div>
<script>
window.config = "%CONFIG%"
</script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View file

@ -4,7 +4,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"serve": "vite preview" "serve": "vite preview",
"lint": "tsc",
"format": "prettier --write ../"
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^6.5.95", "@mdi/js": "^6.5.95",

4
ui/pnpm-lock.yaml generated
View file

@ -1,4 +1,4 @@
lockfileVersion: 5.3 lockfileVersion: 5.4
specifiers: specifiers:
"@mdi/js": ^6.5.95 "@mdi/js": ^6.5.95
@ -282,6 +282,8 @@ packages:
} }
engines: {node: ">=6.0.0"} engines: {node: ">=6.0.0"}
hasBin: true hasBin: true
dependencies:
"@babel/types": 7.16.0
dev: true dev: true
/@babel/plugin-syntax-jsx/7.16.0: /@babel/plugin-syntax-jsx/7.16.0:

View file

@ -1,6 +1,7 @@
import {Component} from "preact" import {Component} from "preact"
import {mdiTriangleOutline} from "@mdi/js" import {mdiTriangleOutline} from "@mdi/js"
import Icon from "../Icon/Icon" import Icon from "../Icon/Icon"
import colors from "../../util/colors"
type Props = { type Props = {
source?: string source?: string
@ -20,7 +21,7 @@ export default class Alert extends Component<Props> {
return ( return (
<div class="alert"> <div class="alert">
<span> <span>
<Icon icon={mdiTriangleOutline} color="#FF0039" /> <Icon icon={mdiTriangleOutline} color={colors.RED} />
{msg} {msg}
</span> </span>
</div> </div>

View file

@ -1,7 +1,6 @@
import {mdiCheckCircleOutline, mdiRestore} from "@mdi/js" import {mdiCheckCircleOutline, mdiRestore} from "@mdi/js"
import axios, {AxiosError, AxiosResponse} from "axios"
import {Component} from "preact" import {Component} from "preact"
import {apiUrl} from "../../util/apiUrls" import {sebraucApi} from "../../util/apiUrls"
import Icon from "../Icon/Icon" import Icon from "../Icon/Icon"
export default class Reboot extends Component { export default class Reboot extends Component {
@ -9,9 +8,9 @@ export default class Reboot extends Component {
const res = confirm("Reboot the system?") const res = confirm("Reboot the system?")
if (!res) return if (!res) return
axios sebraucApi
.post(apiUrl + "/reboot") .startReboot()
.then((response: AxiosResponse) => { .then((response) => {
const msg = response.data.msg const msg = response.data.msg
if (msg !== undefined) { if (msg !== undefined) {
@ -20,7 +19,7 @@ export default class Reboot extends Component {
alert("No response") alert("No response")
} }
}) })
.catch((error: AxiosError) => { .catch((error) => {
if (error.response) { if (error.response) {
const msg = error.response.data.msg const msg = error.response.data.msg

View file

@ -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<Props, State> {
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 (
<div>
<div className="card">
<p class="top">System information</p>
<table class="table no-bottom-border">
<tr>
<td>
<Icon icon={mdiMonitor} /> Hostname
</td>
<td>{this.state.sysinfo.hostname}</td>
</tr>
<tr>
<td>
<Icon icon={mdiPenguin} /> Operating system
</td>
<td>{this.state.sysinfo.os_name}</td>
</tr>
<tr>
<td>
<Icon icon={mdiAlphaVCircleOutline} /> OS version
</td>
<td>{this.state.sysinfo.os_version}</td>
</tr>
<tr>
<td>
<Icon icon={mdiClockOutline} /> Uptime
</td>
<td>{secondsToString(this.state.sysinfo.uptime)}</td>
</tr>
<tr>
<td>
<Icon icon={mdiTagOutline} /> Compatible FW
</td>
<td>{this.state.sysinfo.rauc_compatible}</td>
</tr>
<tr>
<td>
<Icon icon={mdiTagMultipleOutline} /> Compatible FW
variant
</td>
<td>{this.state.sysinfo.rauc_variant}</td>
</tr>
</table>
</div>
<div className="card">
<p class="top">Rootfs slots</p>
<div class="table-wrapper">
<table class="table no-bottom-border">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Device</th>
<th>Mountpoint</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={i}>
<td>
<Icon
icon={icon}
color={iconColor}
/>
</td>
<td>{k}</td>
<td>{rfs.device}</td>
<td>{rfs.mountpoint}</td>
</tr>
)
}
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
private renderLoadingAnimation() {
return (
<div className="card">
<p>loading sysinfo...</p>
</div>
)
}
componentWillUnmount() {
if (this.fetchTimeout !== undefined) {
window.clearTimeout(this.fetchTimeout)
}
}
render() {
if (this.state.sysinfo) {
return this.renderSysinfo()
}
return this.renderLoadingAnimation()
}
}

View file

@ -1,11 +1,11 @@
.uploader { .updater-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin: 0 auto; margin: 0 auto;
max-width: 500px; max-width: 600px;
width: 90%; width: 90%;
> * { > * {
@ -18,8 +18,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 15px 8px; margin-top: 25px;
margin: 8px 0; margin-bottom: 8px;
text-align: center; text-align: center;
@ -29,6 +29,14 @@
.top { .top {
font-size: 1.5em; font-size: 1.5em;
} }
&.pad {
padding: 15px 0;
}
&:first-of-type {
margin-top: 8px;
}
} }
.alert { .alert {
@ -42,3 +50,9 @@
} }
} }
} }
.button-top-right {
position: absolute;
top: 20px;
right: 20px;
}

View file

@ -5,10 +5,11 @@ import Dropzone from "../Dropzone/Dropzone"
import ProgressCircle from "../ProgressCircle/ProgressCircle" import ProgressCircle from "../ProgressCircle/ProgressCircle"
import Icon from "../Icon/Icon" import Icon from "../Icon/Icon"
import "./Updater.scss" import "./Updater.scss"
import axios from "axios"
import Alert from "./Alert" import Alert from "./Alert"
import Reboot from "./Reboot" 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 { class UploadStatus {
uploading = false uploading = false
@ -50,21 +51,21 @@ type State = {
wsConnected: boolean wsConnected: boolean
} }
export default class Updater extends Component<Props, State> { export default class UpdaterCard extends Component<Props, State> {
private dropzoneRef = createRef<Dropzone>() private dropzoneRef = createRef<Dropzone>()
private conn: WebSocket | undefined private ws: WebsocketClient
constructor(props?: Props | undefined, context?: any) { constructor(props?: Props | undefined, context?: any) {
super(props, context) super(props, context)
this.ws = new WebsocketClient(this.onWsStatusUpdate, this.onWsMessage)
this.state = { this.state = {
uploadStatus: new UploadStatus(false), uploadStatus: new UploadStatus(false),
uploadFilename: "", uploadFilename: "",
raucStatus: new RaucStatus(), raucStatus: new RaucStatus(),
wsConnected: false, wsConnected: this.ws.api().isConnected(),
} }
this.connectWebsocket()
} }
private buttonClick = () => { private buttonClick = () => {
@ -77,19 +78,13 @@ export default class Updater extends Component<Props, State> {
if (files.length === 0) return if (files.length === 0) return
const newFile = files[0] const newFile = files[0]
const formData = new FormData()
formData.append("updateFile", newFile)
this.setState({ this.setState({
uploadStatus: new UploadStatus(true, newFile.size, 0), uploadStatus: new UploadStatus(true, newFile.size, 0),
uploadFilename: newFile.name, uploadFilename: newFile.name,
}) })
axios sebraucApi
.post(apiUrl + "/update", formData, { .startUpdate(newFile, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent: {loaded: number; total: number}) => { onUploadProgress: (progressEvent: {loaded: number; total: number}) => {
this.setState({ this.setState({
uploadStatus: UploadStatus.fromProgressEvent(progressEvent), uploadStatus: UploadStatus.fromProgressEvent(progressEvent),
@ -111,33 +106,16 @@ export default class Updater extends Component<Props, State> {
this.dropzoneRef.current?.reset() this.dropzoneRef.current?.reset()
} }
private connectWebsocket = () => { private onWsStatusUpdate = (wsConnected: boolean) => {
if (window.WebSocket) { this.setState({wsConnected: wsConnected})
this.conn = new WebSocket(wsUrl)
this.conn.onopen = () => {
this.setState({wsConnected: true})
console.log("WS connected")
} }
this.conn.onclose = () => {
this.setState({wsConnected: false}) private onWsMessage = (evt: MessageEvent) => {
console.log("WS connection closed")
window.setTimeout(this.connectWebsocket, 3000)
}
this.conn.onmessage = (evt) => {
var messages = evt.data.split("\n") var messages = evt.data.split("\n")
for (var i = 0; i < messages.length; i++) { for (var i = 0; i < messages.length; i++) {
this.setState({ this.setState({
raucStatus: Object.assign( raucStatus: Object.assign(new RaucStatus(), JSON.parse(messages[i])),
new RaucStatus(),
JSON.parse(messages[i])
),
}) })
console.log(this.state.raucStatus)
}
}
} else {
console.log("Your browser does not support WebSockets")
} }
} }
@ -163,9 +141,9 @@ export default class Updater extends Component<Props, State> {
} }
private circleColor(): string { private circleColor(): string {
if (this.state.raucStatus.installing) return "#FF0039" if (this.state.raucStatus.installing) return colors.RED
if (this.state.uploadStatus.uploading) return "#148420" if (this.state.uploadStatus.uploading) return colors.GREEN
return "#1f85de" return colors.BLUE
} }
private circlePercentage(): number { private circlePercentage(): number {
@ -175,6 +153,10 @@ export default class Updater extends Component<Props, State> {
return 0 return 0
} }
componentWillUnmount() {
this.ws.destroy()
}
render() { render() {
const acceptUploads = this.acceptUploads() const acceptUploads = this.acceptUploads()
const circleColor = this.circleColor() const circleColor = this.circleColor()
@ -195,12 +177,13 @@ export default class Updater extends Component<Props, State> {
topText = "Updating firmware" topText = "Updating firmware"
bottomText = this.state.raucStatus.message bottomText = this.state.raucStatus.message
} else { } else {
topText = "Upload firmware package" topText = "Firmware update"
bottomText = "Upload *.raucb FW package"
} }
return ( return (
<div class="uploader"> <div>
<div class="card upload"> <div class="card pad">
<div> <div>
<p class="top">{topText}</p> <p class="top">{topText}</p>
</div> </div>
@ -215,7 +198,7 @@ export default class Updater extends Component<Props, State> {
progress={circlePercentage} progress={circlePercentage}
color={circleColor} color={circleColor}
> >
<button onClick={this.buttonClick}> <button onClick={this.buttonClick} aria-label="Upload">
<Icon icon={mdiUpload} size={50} /> <Icon icon={mdiUpload} size={50} />
</button> </button>
</ProgressCircle> </ProgressCircle>

View file

@ -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<Props, State> {
constructor(props?: Props | undefined, context?: any) {
super(props, context)
this.state = {
flipped: false,
}
}
private flipCard = () => {
this.setState({flipped: !this.state.flipped})
}
render() {
return (
<div>
<button
class="iconButton button-top-right"
onClick={this.flipCard}
aria-label={
this.state.flipped
? "Switch to updater"
: "Switch to system info"
}
>
<Icon icon={this.state.flipped ? mdiUpload : mdiInformation} />
</button>
<div className="updater-view">
{!this.state.flipped ? <UpdaterCard /> : <SysinfoCard />}
</div>
</div>
)
}
}

View file

@ -1,15 +1,15 @@
import {Component} from "preact" import {Component} from "preact"
import Updater from "./Upload/Updater" import UpdaterView from "./Updater/UpdaterView"
import logo from "../assets/logo.svg" import logo from "../assets/logo.svg"
import {version} from "../util/version" import {getConfig} from "../util/config"
export default class App extends Component { export default class App extends Component {
render() { render() {
return ( return (
<div> <div>
<img src={logo} height="64" /> <img src={logo} alt="SEBRAUC" height="64" />
{version} {getConfig().version}
<Updater /> <UpdaterView />
</div> </div>
) )
} }

View file

@ -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

View file

@ -0,0 +1,5 @@
api.ts
base.ts
common.ts
configuration.ts
index.ts

View file

@ -0,0 +1 @@
5.3.0

View file

@ -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<RequestArgs> => {
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<RequestArgs> => {
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<RequestArgs> => {
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<RequestArgs> => {
// 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<SystemInfo>
> {
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<RaucStatus>
> {
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<StatusMessage>
> {
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<StatusMessage>
> {
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<SystemInfo> {
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<RaucStatus> {
return localVarFp
.getStatus(options)
.then((request) => request(axios, basePath))
},
/**
* Reboot the system
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
startReboot(options?: any): AxiosPromise<StatusMessage> {
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<StatusMessage> {
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))
}
}

View file

@ -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)
}
}

View file

@ -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 <T = unknown, R = AxiosResponse<T>>(
axios: AxiosInstance = globalAxios,
basePath: string = BASE_PATH
) => {
const axiosRequestArgs = {
...axiosArgs.options,
url: (configuration?.basePath || basePath) + axiosArgs.url,
}
return axios.request<T, R>(axiosRequestArgs)
}
}

View file

@ -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<string>
| ((name: string) => string)
| ((name: string) => Promise<string>)
username?: string
password?: string
accessToken?:
| string
| Promise<string>
| ((name?: string, scopes?: string[]) => string)
| ((name?: string, scopes?: string[]) => Promise<string>)
basePath?: string
baseOptions?: any
formDataCtor?: new () => any
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?:
| string
| Promise<string>
| ((name: string) => string)
| ((name: string) => Promise<string>)
/**
* 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<string>
| ((name?: string, scopes?: string[]) => string)
| ((name?: string, scopes?: string[]) => Promise<string>)
/**
* 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")
)
}
}

View file

@ -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"

View file

@ -1,3 +1,5 @@
@use "table";
html, html,
body { body {
height: 100%; height: 100%;

47
ui/src/style/table.scss Normal file
View file

@ -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;
}

View file

@ -1,3 +1,5 @@
import {Configuration, DefaultApi} from "../sebrauc-client"
let apiHost = document.location.host let apiHost = document.location.host
const httpProto = document.location.protocol const httpProto = document.location.protocol
const wsProto = httpProto === "https:" ? "wss:" : "ws:" const wsProto = httpProto === "https:" ? "wss:" : "ws:"
@ -9,4 +11,10 @@ if (import.meta.env.VITE_API_HOST !== undefined) {
const apiUrl = `${httpProto}//${apiHost}/api` const apiUrl = `${httpProto}//${apiHost}/api`
const wsUrl = `${wsProto}//${apiHost}/api/ws` const wsUrl = `${wsProto}//${apiHost}/api/ws`
export {apiUrl, wsUrl} let apicfg = new Configuration({
basePath: apiUrl,
})
const sebraucApi = new DefaultApi(apicfg)
export {apiUrl, wsUrl, sebraucApi}

7
ui/src/util/colors.ts Normal file
View file

@ -0,0 +1,7 @@
class colors {
static readonly RED = "#FF0039"
static readonly GREEN = "#148420"
static readonly BLUE = "#1f85de"
}
export default colors

23
ui/src/util/config.ts Normal file
View file

@ -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",
}
}

18
ui/src/util/functions.ts Normal file
View file

@ -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}

View file

@ -1,7 +0,0 @@
let version = import.meta.env.VITE_VERSION
if (version === undefined) {
version = "unknown"
}
export {version}

92
ui/src/util/websocket.ts Normal file
View file

@ -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<WebsocketClient>
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)
}
}

View file

@ -1,10 +1,64 @@
package ui package ui
import ( import (
"bytes"
"embed" "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/** //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)
}
}

87
ui/ui_test.go Normal file
View file

@ -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")
}

14
woodpecker.yml Normal file
View file

@ -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/...