Compare commits
19 commits
Author | SHA1 | Date | |
---|---|---|---|
28e51df5b8 | |||
7620f0d9f2 | |||
4d7c4646bb | |||
e98b29d666 | |||
e2c3c2ce6b | |||
69318d96d2 | |||
df98c40853 | |||
df78f37d86 | |||
92bed651b7 | |||
acd1db7363 | |||
312de77236 | |||
3c9867b75b | |||
e3cb739db5 | |||
4cc757d550 | |||
71764dd6fa | |||
e1c4c58684 | |||
8714ad966b | |||
c8e2d2a216 | |||
0428ad3ebc |
87 changed files with 4151 additions and 824 deletions
|
@ -1,11 +1,11 @@
|
|||
root = "./src"
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ./src/."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor"]
|
||||
exclude_dir = ["tmp", "vendor", "ui/dist", "ui/node_modules", "ui/src"]
|
||||
exclude_file = []
|
||||
exclude_regex = []
|
||||
exclude_unchanged = false
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,2 +1,4 @@
|
|||
build
|
||||
tmp
|
||||
/sebrauc.toml
|
||||
/htpasswd
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
linters:
|
||||
presets:
|
||||
- bugs
|
||||
- unused
|
||||
- import
|
||||
- module
|
||||
|
||||
|
@ -14,11 +13,17 @@ linters:
|
|||
|
||||
disable:
|
||||
- scopelint
|
||||
- noctx
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
tab-width: 4
|
||||
gocognit:
|
||||
min-complexity: 10
|
||||
nestif:
|
||||
min-complexity: 3
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- "(*github.com/gin-gonic/gin.Context).Error"
|
||||
- "(*github.com/gin-gonic/gin.Context).AbortWithError"
|
||||
|
|
|
@ -6,14 +6,6 @@ repos:
|
|||
name: GolangCI Lint
|
||||
- id: go-test-repo-mod
|
||||
name: Backend tests
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
- repo: https://github.com/dudefellah/pre-commit-openapi
|
||||
rev: "v0.0.1"
|
||||
hooks:
|
||||
- id: check-openapi
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
|
@ -25,3 +17,8 @@ repos:
|
|||
args: ["-p", "./ui/tsconfig.json"]
|
||||
additional_dependencies: ["typescript@4.5.2"]
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
|
|
14
.woodpecker.yml
Normal file
14
.woodpecker.yml
Normal 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/...
|
18
Makefile
18
Makefile
|
@ -1,7 +1,9 @@
|
|||
SRC_DIR=./src
|
||||
UI_DIR=./ui
|
||||
|
||||
VERSION=$(shell git tag --sort=-version:refname | head -n 1)
|
||||
APIDOC_FILE=./src/server/swagger/swagger.yaml
|
||||
|
||||
VER=$(or ${VERSION},$(shell git tag --sort=-version:refname | head -n 1))
|
||||
|
||||
setup:
|
||||
cd ${UI_DIR} && pnpm install
|
||||
|
@ -9,16 +11,24 @@ setup:
|
|||
test:
|
||||
go test -v ./src/...
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
cd ${UI_DIR} && npm run format && npm run lint
|
||||
|
||||
build-ui:
|
||||
cd ${UI_DIR} && VITE_VERSION=${VERSION} pnpm run build
|
||||
cd ${UI_DIR} && pnpm run build
|
||||
|
||||
build-server:
|
||||
go build -tags prod -ldflags "-s -w -X code.thetadev.de/TSGRain/SEBRAUC/src/util.version=${VERSION}" -o build/sebrauc ./src/.
|
||||
go build -tags prod -ldflags "-s -w -X code.thetadev.de/TSGRain/SEBRAUC/src/util.version=${VER}" -o build/sebrauc ./src/.
|
||||
|
||||
build: build-ui build-server
|
||||
|
||||
generate-apidoc:
|
||||
SWAGGER_GENERATE_EXTENSION=false swagger generate spec --scan-models -o ${APIDOC_FILE}
|
||||
|
||||
generate-apiclient:
|
||||
openapi-generator generate -i openapi.yml -g typescript-axios -o ${UI_DIR}/src/sebrauc-client -p "supportsES6=true"
|
||||
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/*
|
||||
|
|
|
@ -1 +0,0 @@
|
|||

|
460
README.rst
Normal file
460
README.rst
Normal 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
BIN
_screenshots/sysinfo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 155 KiB |
BIN
_screenshots/update0.png
Normal file
BIN
_screenshots/update0.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 101 KiB |
BIN
_screenshots/update1.png
Normal file
BIN
_screenshots/update1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 127 KiB |
BIN
_screenshots/update2.png
Normal file
BIN
_screenshots/update2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 126 KiB |
BIN
_screenshots/update3.png
Normal file
BIN
_screenshots/update3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 119 KiB |
25
go.mod
25
go.mod
|
@ -3,10 +3,29 @@ module code.thetadev.de/TSGRain/SEBRAUC
|
|||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gofiber/fiber/v2 v2.21.0
|
||||
github.com/gofiber/websocket/v2 v2.0.12
|
||||
code.thetadev.de/TSGRain/ginzip v0.1.1
|
||||
github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db
|
||||
github.com/fortytw2/leaktest v1.3.0
|
||||
github.com/gin-contrib/cors v1.3.1
|
||||
github.com/gin-gonic/gin v1.7.7
|
||||
github.com/go-errors/errors v1.4.1 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.6 // indirect
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/websocket v1.4.1
|
||||
github.com/jinzhu/configor v1.2.1
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/tg123/go-htpasswd v1.2.0
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
|
||||
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
google.golang.org/protobuf v1.27.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
||||
|
|
139
go.sum
139
go.sum
|
@ -1,49 +1,128 @@
|
|||
github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E=
|
||||
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
code.thetadev.de/TSGRain/ginzip v0.1.1 h1:+X0L6qumEZiKYSLmM+Q0LqKVHsKvdcg4CVzsEpvM7fk=
|
||||
code.thetadev.de/TSGRain/ginzip v0.1.1/go.mod h1:BH7VkvpP83vPRyMQ8rLIjKycQwGzF+/mFV0BKzg+BuA=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fasthttp/websocket v1.4.3-rc.9 h1:CWJH0vONrOatdKXZgkgbFKWllijD9aY50C5KfbSDcWk=
|
||||
github.com/fasthttp/websocket v1.4.3-rc.9/go.mod h1:eXL2zqDbexYJxaCw8/PQlm7VcMK6uoGvwbYbTdt4dFo=
|
||||
github.com/gofiber/fiber/v2 v2.20.1/go.mod h1:/LdZHMUXZvTTo7gU4+b1hclqCAdoQphNQ9bi9gutPyI=
|
||||
github.com/gofiber/fiber/v2 v2.21.0 h1:tdRNrgqWqcHWBwE3o51oAleEVsil4Ro02zd2vMEuP4Q=
|
||||
github.com/gofiber/fiber/v2 v2.21.0/go.mod h1:MR1usVH3JHYRyQwMe2eZXRSZHRX38fkV+A7CPB+DlDQ=
|
||||
github.com/gofiber/websocket/v2 v2.0.12 h1:jKwTrXiOut9UGOGEzFTAD6gq+/78mM3NcrI05VbxjAU=
|
||||
github.com/gofiber/websocket/v2 v2.0.12/go.mod h1:lQRy0u5ACJfiez/e/bhGeYvM0/M940Y3NFw14U3/otI=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db h1:oZ4U9IqO8NS+61OmGTBi8vopzqTRxwQeogyBHdrhjbc=
|
||||
github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db/go.mod h1:Pk7/9x6tyChFTkahDvLBQMlvdsWvfC+yU8HTT5VD314=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA=
|
||||
github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
|
||||
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
|
||||
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
|
||||
github.com/go-errors/errors v1.4.1 h1:IvVlgbzSsaUNudsw5dcXSzF3EWyXTi5XrAdngnuhRyg=
|
||||
github.com/go-errors/errors v1.4.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
|
||||
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
||||
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
|
||||
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
|
||||
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
|
||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4 h1:ocK/D6lCgLji37Z2so4xhMl46se1ntReQQCUIU4BWI8=
|
||||
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
|
||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.29.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
||||
github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
||||
github.com/valyala/fasthttp v1.31.0 h1:lrauRLII19afgCs2fnWRJ4M5IkV0lo2FqA61uGkNBfE=
|
||||
github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
|
||||
github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
|
||||
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
|
||||
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
204
openapi.yml
204
openapi.yml
|
@ -1,204 +0,0 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: SEBRAUC
|
||||
version: 0.1.0
|
||||
description: REST API for the SEBRAUC firmware updater
|
||||
servers:
|
||||
- url: http://localhost:8080/api
|
||||
paths:
|
||||
/status:
|
||||
get:
|
||||
operationId: getStatus
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RaucStatus"
|
||||
default:
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusMessage"
|
||||
|
||||
/info:
|
||||
get:
|
||||
operationId: getInfo
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SystemInfo"
|
||||
default:
|
||||
description: Server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusMessage"
|
||||
|
||||
/update:
|
||||
post:
|
||||
operationId: startUpdate
|
||||
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:
|
||||
operationId: startReboot
|
||||
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
|
||||
|
||||
SystemInfo:
|
||||
type: object
|
||||
properties:
|
||||
hostname:
|
||||
description: Hostname of the system
|
||||
type: string
|
||||
example: "raspberrypi3"
|
||||
os_name:
|
||||
description: Name of the os distribution
|
||||
type: string
|
||||
example: "Poky"
|
||||
os_version:
|
||||
description: Operating system version
|
||||
type: string
|
||||
example: "1.0.2"
|
||||
uptime:
|
||||
description: System uptime in seconds
|
||||
type: integer
|
||||
example: 5832
|
||||
rauc_compatible:
|
||||
description: Compatible firmware name
|
||||
type: string
|
||||
example: "Poky"
|
||||
rauc_variant:
|
||||
description: Compatible firmware variant
|
||||
type: string
|
||||
example: "rpi-prod"
|
||||
rauc_rootfs:
|
||||
description: List of RAUC root filesystems
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/Rootfs"
|
||||
required:
|
||||
- hostname
|
||||
- os_name
|
||||
- os_version
|
||||
- uptime
|
||||
- rauc_compatible
|
||||
- rauc_variant
|
||||
- rauc_rootfs
|
||||
|
||||
Rootfs:
|
||||
type: object
|
||||
properties:
|
||||
device:
|
||||
description: Block device
|
||||
type: string
|
||||
example: "/dev/mmcblk0p2"
|
||||
type:
|
||||
description: Filesystem
|
||||
type: string
|
||||
example: ext4
|
||||
mountpoint:
|
||||
description: Mount path (null when not mounted)
|
||||
type: string
|
||||
nullable: true
|
||||
example: "/"
|
||||
bootable:
|
||||
description: "Is the filesystem bootable"
|
||||
type: boolean
|
||||
booted:
|
||||
description: "Is the filesystem booted"
|
||||
type: boolean
|
||||
primary:
|
||||
description: "Is the filesystem the next boot target"
|
||||
type: boolean
|
||||
required:
|
||||
- device
|
||||
- type
|
||||
- mountpoint
|
||||
- bootable
|
||||
- booted
|
||||
- primary
|
||||
|
||||
StatusMessage:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
description: Is operation successful
|
||||
type: boolean
|
||||
msg:
|
||||
description: Success message
|
||||
type: string
|
||||
example: Update started
|
||||
required:
|
||||
- success
|
||||
- msg
|
44
sebrauc.example.toml
Normal file
44
sebrauc.example.toml
Normal 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"
|
|
@ -1,8 +0,0 @@
|
|||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed files/**
|
||||
var Assets embed.FS
|
|
@ -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
130
src/config/config.go
Normal 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
161
src/config/config_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -48,27 +48,47 @@ const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` +
|
|||
`{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` +
|
||||
`"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}`
|
||||
|
||||
func printLinesWithDelay(lines string) {
|
||||
func printLinesWithDelay(lines string, delay time.Duration) {
|
||||
for _, line := range strings.Split(lines, "\n") {
|
||||
fmt.Println(line)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
func getBoolEnvvar(name string) bool {
|
||||
val := strings.ToLower(os.Getenv(name))
|
||||
return val != "" && val != "false" && val != "0"
|
||||
}
|
||||
|
||||
func main() {
|
||||
arg := ""
|
||||
method := ""
|
||||
if len(os.Args) > 1 {
|
||||
arg = os.Args[1]
|
||||
method = os.Args[1]
|
||||
}
|
||||
|
||||
switch arg {
|
||||
case "fail":
|
||||
printLinesWithDelay(outputFailure)
|
||||
test := getBoolEnvvar("RAUC_MOCK_TEST")
|
||||
failure := getBoolEnvvar("RAUC_MOCK_FAIL")
|
||||
|
||||
delay := 500 * time.Millisecond
|
||||
if test {
|
||||
delay = 10 * time.Millisecond
|
||||
}
|
||||
|
||||
switch method {
|
||||
case "install":
|
||||
printLinesWithDelay(outputSuccess)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
8
src/fixtures/testcmd/testcmd.go
Normal file
8
src/fixtures/testcmd/testcmd.go
Normal 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"
|
||||
)
|
3
src/fixtures/testfiles/htpasswd
Normal file
3
src/fixtures/testfiles/htpasswd
Normal file
|
@ -0,0 +1,3 @@
|
|||
plain:1234
|
||||
md5:$apr1$V2wxHBfb$gBU2yIYjTIeciKapglql6/
|
||||
bcrypt:$2y$05$f9rV6uTQEEnNR1saPksExOR31LauUZzpLDhpCrodAvxX3zZ6nLy12
|
32
src/fixtures/testfiles/sebrauc.toml
Normal file
32
src/fixtures/testfiles/sebrauc.toml
Normal 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"
|
|
@ -3,8 +3,11 @@ package fixtures
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var envPrefixes = []string{"SEBRAUC", "RAUC_MOCK"}
|
||||
|
||||
func doesFileExist(filepath string) bool {
|
||||
_, err := os.Stat(filepath)
|
||||
return !os.IsNotExist(err)
|
||||
|
@ -38,3 +41,20 @@ func GetTestfilesDir() string {
|
|||
CdProjectRoot()
|
||||
return filepath.Join("src", "fixtures", "testfiles")
|
||||
}
|
||||
|
||||
func ResetEnv() {
|
||||
for _, envvar := range os.Environ() {
|
||||
split := strings.SplitN(envvar, "=", 2)
|
||||
if len(split) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := split[0]
|
||||
|
||||
for _, prefix := range envPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,3 +35,15 @@ func TestCdProjectRoot(t *testing.T) {
|
|||
CdProjectRoot()
|
||||
assert.True(t, doesFileExist("go.sum"))
|
||||
}
|
||||
|
||||
func TestResetEnv(t *testing.T) {
|
||||
os.Setenv("RAUC_MOCK_TEST", "1")
|
||||
os.Setenv("SEBRAUC_PORT", "8001")
|
||||
|
||||
ResetEnv()
|
||||
|
||||
_, exists := os.LookupEnv("RAUC_MOCK_TEST")
|
||||
assert.False(t, exists)
|
||||
_, exists = os.LookupEnv("SEBRAUC_PORT")
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
|
31
src/main.go
31
src/main.go
|
@ -1,22 +1,45 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/config"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/server"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("SEBRAUC " + util.Version())
|
||||
const titleArt = ` _____ __________ ____ ___ __ ________
|
||||
/ ___// ____/ __ )/ __ \/ | / / / / ____/
|
||||
\__ \/ __/ / __ / /_/ / /| |/ / / / /
|
||||
___/ / /___/ /_/ / _, _/ ___ / /_/ / /___
|
||||
/____/_____/_____/_/ |_/_/ |_\____/\____/ `
|
||||
|
||||
if util.TestMode {
|
||||
func main() {
|
||||
run(os.Args[1:])
|
||||
}
|
||||
|
||||
func run(args []string) {
|
||||
fmt.Println(titleArt + util.Version() + "\n")
|
||||
|
||||
cmdFlags := flag.NewFlagSet("sebrauc", flag.ExitOnError)
|
||||
port := cmdFlags.Int("p", 0, "HTTP port")
|
||||
cfgPath := cmdFlags.String("c", "", "Config file path")
|
||||
_ = cmdFlags.Parse(args)
|
||||
|
||||
if mode.IsDev() {
|
||||
fmt.Println("Test mode active - no update operations are executed.")
|
||||
fmt.Println("Build with -tags prod to enable live mode.")
|
||||
}
|
||||
|
||||
srv := server.NewServer(":8080")
|
||||
cfg := config.GetWithFlags(*cfgPath, *port)
|
||||
|
||||
fmt.Printf("Starting server at %s:%d\n", cfg.Server.Address, cfg.Server.Port)
|
||||
|
||||
srv := server.NewServer(cfg)
|
||||
err := srv.Run()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
|
|
24
src/model/error.go
Normal file
24
src/model/error.go
Normal 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
34
src/model/rauc_status.go
Normal 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"`
|
||||
}
|
17
src/model/status_message.go
Normal file
17
src/model/status_message.go
Normal 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
75
src/model/system_info.go
Normal 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:"-"`
|
||||
}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strconv"
|
||||
"sync"
|
||||
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||
)
|
||||
|
||||
|
@ -18,32 +19,26 @@ var (
|
|||
)
|
||||
|
||||
type Rauc struct {
|
||||
broadcast chan string
|
||||
status RaucStatus
|
||||
cmdRaucInstall string
|
||||
bc util.Broadcaster
|
||||
status model.RaucStatus
|
||||
runningMtx sync.Mutex
|
||||
}
|
||||
|
||||
type RaucStatus struct {
|
||||
Installing bool `json:"installing"`
|
||||
Percent int `json:"percent"`
|
||||
Message string `json:"message"`
|
||||
LastError string `json:"last_error"`
|
||||
Log string `json:"log"`
|
||||
func New(cmdRaucInstall string) *Rauc {
|
||||
return &Rauc{
|
||||
cmdRaucInstall: cmdRaucInstall,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRauc(broadcast chan string) *Rauc {
|
||||
r := &Rauc{
|
||||
broadcast: broadcast,
|
||||
}
|
||||
|
||||
r.broadcast <- r.GetStatusJson()
|
||||
|
||||
return r
|
||||
func (r *Rauc) SetBroadcaster(bc util.Broadcaster) {
|
||||
r.bc = bc
|
||||
r.bcStatus()
|
||||
}
|
||||
|
||||
func (r *Rauc) completed(updateFile string) {
|
||||
r.status.Installing = false
|
||||
r.broadcast <- r.GetStatusJson()
|
||||
r.bcStatus()
|
||||
|
||||
_ = os.Remove(updateFile)
|
||||
}
|
||||
|
@ -65,12 +60,12 @@ func (r *Rauc) RunRauc(updateFile string) error {
|
|||
}
|
||||
|
||||
// Reset installer
|
||||
r.status = RaucStatus{
|
||||
r.status = model.RaucStatus{
|
||||
Installing: true,
|
||||
}
|
||||
r.broadcast <- r.GetStatusJson()
|
||||
r.bcStatus()
|
||||
|
||||
cmd := util.CommandFromString(fmt.Sprintf("%s install %s", util.RaucCmd, updateFile))
|
||||
cmd := util.CommandFromString(r.cmdRaucInstall + " " + updateFile)
|
||||
|
||||
readPipe, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = cmd.Stdout
|
||||
|
@ -100,7 +95,7 @@ func (r *Rauc) RunRauc(updateFile string) error {
|
|||
}
|
||||
|
||||
if hasUpdate {
|
||||
r.broadcast <- r.GetStatusJson()
|
||||
r.bcStatus()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
@ -122,11 +117,19 @@ func (r *Rauc) RunRauc(updateFile string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *Rauc) GetStatus() RaucStatus {
|
||||
func (r *Rauc) GetStatus() model.RaucStatus {
|
||||
return r.status
|
||||
}
|
||||
|
||||
func (r *Rauc) GetStatusJson() string {
|
||||
statusJson, _ := json.Marshal(r.status)
|
||||
return string(statusJson)
|
||||
func (r *Rauc) GetStatusJson() []byte {
|
||||
statusJson, err := json.Marshal(r.status)
|
||||
if err != nil {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
return statusJson
|
||||
}
|
||||
|
||||
func (r *Rauc) bcStatus() {
|
||||
r.bc.Broadcast(r.GetStatusJson())
|
||||
}
|
||||
|
|
118
src/rauc/rauc_test.go
Normal file
118
src/rauc/rauc_test.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
36
src/server/middleware/authentication.go
Normal file
36
src/server/middleware/authentication.go
Normal 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)
|
||||
}
|
||||
}
|
54
src/server/middleware/authentication_test.go
Normal file
54
src/server/middleware/authentication_test.go
Normal 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"))
|
||||
})
|
||||
}
|
7
src/server/middleware/cache.go
Normal file
7
src/server/middleware/cache.go
Normal 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")
|
||||
}
|
25
src/server/middleware/cache_test.go
Normal file
25
src/server/middleware/cache_test.go
Normal 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"))
|
||||
}
|
14
src/server/middleware/compression.go
Normal file
14
src/server/middleware/compression.go
Normal 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)
|
||||
}
|
58
src/server/middleware/error_handler.go
Normal file
58
src/server/middleware/error_handler.go
Normal 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())
|
||||
}
|
||||
}
|
140
src/server/middleware/error_handler_test.go
Normal file
140
src/server/middleware/error_handler_test.go
Normal 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"))
|
||||
}
|
|
@ -1,171 +1,266 @@
|
|||
// SEBRAUC
|
||||
//
|
||||
// # REST API for the SEBRAUC firmware updater
|
||||
//
|
||||
// ---
|
||||
// Schemes: http, https
|
||||
// Version: 0.2.0
|
||||
// License: MIT
|
||||
//
|
||||
// swagger:meta
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/config"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/rauc"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/server/middleware"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/server/stream"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/server/swagger"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/sysinfo"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/ui"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SEBRAUCServer struct {
|
||||
address string
|
||||
raucUpdater *rauc.Rauc
|
||||
hub *MessageHub
|
||||
config *config.Config
|
||||
streamer *stream.API
|
||||
updater *rauc.Rauc
|
||||
sysinfo *sysinfo.Sysinfo
|
||||
tmpdir string
|
||||
}
|
||||
|
||||
type statusMessage struct {
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
func NewServer(config *config.Config) *SEBRAUCServer {
|
||||
updater := rauc.New(config.Commands.RaucInstall)
|
||||
streamer := stream.New(
|
||||
time.Duration(config.Server.Websocket.Ping)*time.Second,
|
||||
time.Duration(config.Server.Websocket.Timeout)*time.Second,
|
||||
[]string{},
|
||||
)
|
||||
sysinfo := sysinfo.New(
|
||||
config.Commands.RaucStatus,
|
||||
config.Sysinfo.ReleaseFile,
|
||||
config.Sysinfo.NameKey,
|
||||
config.Sysinfo.VersionKey,
|
||||
config.Sysinfo.HostnameFile,
|
||||
config.Sysinfo.UptimeFile,
|
||||
)
|
||||
|
||||
func NewServer(address string) *SEBRAUCServer {
|
||||
hub := NewHub()
|
||||
updater.SetBroadcaster(streamer)
|
||||
|
||||
raucUpdater := rauc.NewRauc(hub.Broadcast)
|
||||
|
||||
tmpdir, err := util.GetTmpdir()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tmpdir := util.GetTmpdir(config.Tmpdir)
|
||||
|
||||
return &SEBRAUCServer{
|
||||
address: address,
|
||||
raucUpdater: raucUpdater,
|
||||
hub: hub,
|
||||
config: config,
|
||||
updater: updater,
|
||||
streamer: streamer,
|
||||
sysinfo: sysinfo,
|
||||
tmpdir: tmpdir,
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *SEBRAUCServer) getRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
router.Use(gin.Logger())
|
||||
_ = router.SetTrustedProxies(nil)
|
||||
|
||||
if mode.IsDev() {
|
||||
router.Use(cors.Default())
|
||||
}
|
||||
|
||||
router.Use(middleware.ErrorHandler(false), middleware.PanicHandler(false))
|
||||
router.NoRoute(func(c *gin.Context) { c.Error(util.ErrPageNotFound) })
|
||||
|
||||
if srv.config.Authentication.Enable {
|
||||
router.Use(middleware.Authentication(srv.config.Authentication.PasswdFile))
|
||||
}
|
||||
|
||||
api := router.Group("/api",
|
||||
middleware.ErrorHandler(true), middleware.PanicHandler(true))
|
||||
|
||||
// API ROUTES
|
||||
api.GET("/ws", srv.streamer.Handle)
|
||||
api.GET("/status", srv.controllerStatus)
|
||||
api.GET("/info", srv.controllerInfo)
|
||||
|
||||
api.POST("/update", srv.controllerUpdate)
|
||||
api.POST("/reboot", srv.controllerReboot)
|
||||
|
||||
// Error routes for testing
|
||||
if mode.IsDev() {
|
||||
router.GET("/error", srv.controllerError)
|
||||
router.GET("/panic", srv.controllerPanic)
|
||||
|
||||
api.GET("/error", srv.controllerError)
|
||||
api.GET("/panic", srv.controllerPanic)
|
||||
}
|
||||
|
||||
// UI
|
||||
uiGroup := router.Group("/", middleware.Compression(
|
||||
srv.config.Server.Compression.Gzip,
|
||||
srv.config.Server.Compression.Brotli),
|
||||
)
|
||||
ui.Register(uiGroup)
|
||||
swagger.Register(uiGroup)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func (srv *SEBRAUCServer) Run() error {
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "SEBRAUC",
|
||||
BodyLimit: 1024 * 1024 * 1024,
|
||||
ErrorHandler: errorHandler,
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
router := srv.getRouter()
|
||||
|
||||
app.Use(logger.New())
|
||||
|
||||
app.Use(compress.New(compress.Config{
|
||||
Next: func(c *fiber.Ctx) bool {
|
||||
return strings.HasPrefix(c.Path(), "/api")
|
||||
},
|
||||
}))
|
||||
|
||||
// just for testing
|
||||
app.Use("/api", cors.New())
|
||||
|
||||
app.Use("/api/ws", func(c *fiber.Ctx) error {
|
||||
// IsWebSocketUpgrade returns true if the client
|
||||
// requested upgrade to the WebSocket protocol.
|
||||
if websocket.IsWebSocketUpgrade(c) {
|
||||
c.Locals("allowed", true)
|
||||
return c.Next()
|
||||
}
|
||||
return fiber.ErrUpgradeRequired
|
||||
})
|
||||
|
||||
app.Use("/", filesystem.New(filesystem.Config{
|
||||
Root: http.FS(ui.Assets),
|
||||
PathPrefix: ui.AssetsDir,
|
||||
MaxAge: 7200,
|
||||
}))
|
||||
|
||||
// ROUTES
|
||||
app.Get("/api/ws", websocket.New(srv.hub.Handler))
|
||||
app.Post("/api/update", srv.controllerUpdate)
|
||||
app.Get("/api/status", srv.controllerStatus)
|
||||
app.Get("/api/info", srv.controllerInfo)
|
||||
app.Post("/api/reboot", srv.controllerReboot)
|
||||
|
||||
// Start messaging hub
|
||||
go srv.hub.Run()
|
||||
|
||||
return app.Listen(srv.address)
|
||||
return router.Run(fmt.Sprintf("%s:%d",
|
||||
srv.config.Server.Address, srv.config.Server.Port))
|
||||
}
|
||||
|
||||
func (srv *SEBRAUCServer) controllerUpdate(c *fiber.Ctx) error {
|
||||
// swagger:operation POST /update startUpdate
|
||||
//
|
||||
// # Start the update process
|
||||
//
|
||||
// ---
|
||||
// consumes:
|
||||
// - multipart/form-data
|
||||
// produces: [application/json]
|
||||
// parameters:
|
||||
// - name: updateFile
|
||||
// in: formData
|
||||
// description: RAUC firmware image file (*.raucb)
|
||||
// required: true
|
||||
// type: file
|
||||
//
|
||||
// responses:
|
||||
//
|
||||
// 200:
|
||||
// description: Ok
|
||||
// schema:
|
||||
// $ref: "#/definitions/StatusMessage"
|
||||
// 409:
|
||||
// description: already running
|
||||
// schema:
|
||||
// $ref: "#/definitions/Error"
|
||||
// 500:
|
||||
// description: Server Error
|
||||
// schema:
|
||||
// $ref: "#/definitions/Error"
|
||||
func (srv *SEBRAUCServer) controllerUpdate(c *gin.Context) {
|
||||
file, err := c.FormFile("updateFile")
|
||||
if err != nil {
|
||||
return err
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
uid, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return err
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
updateFile := fmt.Sprintf("%s/update_%s.raucb", srv.tmpdir, uid.String())
|
||||
|
||||
err = c.SaveFile(file, updateFile)
|
||||
err = c.SaveUploadedFile(file, updateFile)
|
||||
if err != nil {
|
||||
return err
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = srv.raucUpdater.RunRauc(updateFile)
|
||||
err = srv.updater.RunRauc(updateFile)
|
||||
if err == nil {
|
||||
writeStatus(c, true, "Update started")
|
||||
} else if errors.Is(err, util.ErrAlreadyRunning) {
|
||||
return fiber.NewError(fiber.StatusConflict, "already running")
|
||||
} else {
|
||||
return err
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *SEBRAUCServer) controllerStatus(c *fiber.Ctx) error {
|
||||
c.Context().SetStatusCode(200)
|
||||
_ = c.JSON(srv.raucUpdater.GetStatus())
|
||||
return nil
|
||||
// swagger:operation GET /status getStatus
|
||||
//
|
||||
// # Get the current status of the RAUC updater
|
||||
//
|
||||
// ---
|
||||
// produces: [application/json]
|
||||
// responses:
|
||||
//
|
||||
// 200:
|
||||
// description: Ok
|
||||
// schema:
|
||||
// $ref: "#/definitions/RaucStatus"
|
||||
// 500:
|
||||
// description: Server Error
|
||||
// schema:
|
||||
// $ref: "#/definitions/Error"
|
||||
func (srv *SEBRAUCServer) controllerStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, srv.updater.GetStatus())
|
||||
}
|
||||
|
||||
func (srv *SEBRAUCServer) controllerInfo(c *fiber.Ctx) error {
|
||||
info, err := sysinfo.GetSysinfo()
|
||||
// swagger:operation GET /info getInfo
|
||||
//
|
||||
// # Get the current system info
|
||||
//
|
||||
// ---
|
||||
// produces: [application/json]
|
||||
// responses:
|
||||
//
|
||||
// 200:
|
||||
// description: Ok
|
||||
// schema:
|
||||
// $ref: "#/definitions/SystemInfo"
|
||||
// 500:
|
||||
// description: Server Error
|
||||
// schema:
|
||||
// $ref: "#/definitions/Error"
|
||||
func (srv *SEBRAUCServer) controllerInfo(c *gin.Context) {
|
||||
info, err := srv.sysinfo.GetSysinfo()
|
||||
if err != nil {
|
||||
return err
|
||||
c.Error(err)
|
||||
} else {
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
c.Context().SetStatusCode(200)
|
||||
_ = c.JSON(info)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *SEBRAUCServer) controllerReboot(c *fiber.Ctx) error {
|
||||
go util.Reboot(5 * time.Second)
|
||||
// swagger:operation POST /reboot startReboot
|
||||
//
|
||||
// # Reboot the system
|
||||
//
|
||||
// ---
|
||||
// produces: [application/json]
|
||||
// responses:
|
||||
//
|
||||
// 200:
|
||||
// description: Ok
|
||||
// schema:
|
||||
// $ref: "#/definitions/StatusMessage"
|
||||
// 500:
|
||||
// description: Server Error
|
||||
// schema:
|
||||
// $ref: "#/definitions/Error"
|
||||
func (srv *SEBRAUCServer) controllerReboot(c *gin.Context) {
|
||||
go util.Reboot(srv.config.Commands.Reboot, 5*time.Second)
|
||||
|
||||
writeStatus(c, true, "System is rebooting")
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorHandler(c *fiber.Ctx, err error) error {
|
||||
// API error handling
|
||||
if strings.HasPrefix(c.Path(), "/api") {
|
||||
writeStatus(c, false, err.Error())
|
||||
}
|
||||
return err
|
||||
// controllerError throws an error for testing
|
||||
func (srv *SEBRAUCServer) controllerError(c *gin.Context) {
|
||||
c.Error(util.HttpErrNew("error test", http.StatusBadRequest))
|
||||
}
|
||||
|
||||
func writeStatus(c *fiber.Ctx, success bool, msg string) {
|
||||
_ = c.JSON(statusMessage{
|
||||
// controllerPanic panics for testing
|
||||
func (srv *SEBRAUCServer) controllerPanic(c *gin.Context) {
|
||||
panic(errors.New("panic message"))
|
||||
}
|
||||
|
||||
func writeStatus(c *gin.Context, success bool, msg string) {
|
||||
c.JSON(http.StatusOK, model.StatusMessage{
|
||||
Success: success,
|
||||
Msg: msg,
|
||||
})
|
||||
|
||||
if success {
|
||||
c.Context().SetStatusCode(200)
|
||||
}
|
||||
}
|
||||
|
|
206
src/server/server_test.go
Normal file
206
src/server/server_test.go
Normal 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
119
src/server/stream/client.go
Normal 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
38
src/server/stream/once.go
Normal 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
|
||||
}
|
43
src/server/stream/once_test.go
Normal file
43
src/server/stream/once_test.go
Normal 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
161
src/server/stream/stream.go
Normal 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
|
||||
}
|
472
src/server/stream/stream_test.go
Normal file
472
src/server/stream/stream_test.go
Normal 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")
|
||||
}
|
25
src/server/swagger/swagger.go
Normal file
25
src/server/swagger/swagger.go
Normal 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)
|
||||
})
|
||||
}
|
20
src/server/swagger/swagger.html
Normal file
20
src/server/swagger/swagger.html
Normal 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>
|
228
src/server/swagger/swagger.yaml
Normal file
228
src/server/swagger/swagger.yaml
Normal 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"
|
44
src/server/swagger/swagger_test.go
Normal file
44
src/server/swagger/swagger_test.go
Normal 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",
|
||||
)
|
||||
}
|
|
@ -7,27 +7,17 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||
)
|
||||
|
||||
type SystemInfo struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OsName string `json:"os_name"`
|
||||
OsVersion string `json:"os_version"`
|
||||
Uptime int `json:"uptime"`
|
||||
RaucCompatible string `json:"rauc_compatible"`
|
||||
RaucVariant string `json:"rauc_variant"`
|
||||
RaucRootfs map[string]Rootfs `json:"rauc_rootfs"`
|
||||
}
|
||||
|
||||
type Rootfs struct {
|
||||
Device string `json:"device"`
|
||||
Type string `json:"type"`
|
||||
bootname string
|
||||
Mountpoint *string `json:"mountpoint"`
|
||||
Bootable bool `json:"bootable"`
|
||||
Booted bool `json:"booted"`
|
||||
Primary bool `json:"primary"`
|
||||
type Sysinfo struct {
|
||||
cmdRaucStatus string
|
||||
releaseFile string
|
||||
hostnameFile string
|
||||
uptimeFile string
|
||||
rexpName *regexp.Regexp
|
||||
rexpVersion *regexp.Regexp
|
||||
}
|
||||
|
||||
type raucInfo struct {
|
||||
|
@ -53,11 +43,27 @@ type osRelease struct {
|
|||
OsVersion string `json:"os_version"`
|
||||
}
|
||||
|
||||
var (
|
||||
rexpOsName = regexp.MustCompile(`(?m)^NAME="(.+)"`)
|
||||
rexpOsVersion = regexp.MustCompile(`(?m)^VERSION="(.+)"`)
|
||||
rexpUptime = regexp.MustCompile(`^\d+`)
|
||||
)
|
||||
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{}
|
||||
|
@ -65,14 +71,14 @@ func parseRaucInfo(raucInfoJson []byte) (raucInfo, error) {
|
|||
return res, err
|
||||
}
|
||||
|
||||
func parseOsRelease(osReleaseFile string) (osRelease, error) {
|
||||
osReleaseTxt, err := os.ReadFile(osReleaseFile)
|
||||
func (s *Sysinfo) parseOsRelease() (osRelease, error) {
|
||||
osReleaseTxt, err := os.ReadFile(s.releaseFile)
|
||||
if err != nil {
|
||||
return osRelease{}, err
|
||||
}
|
||||
|
||||
nameMatch := rexpOsName.FindSubmatch(osReleaseTxt)
|
||||
versionMatch := rexpOsVersion.FindSubmatch(osReleaseTxt)
|
||||
nameMatch := s.rexpName.FindSubmatch(osReleaseTxt)
|
||||
versionMatch := s.rexpVersion.FindSubmatch(osReleaseTxt)
|
||||
|
||||
name := ""
|
||||
if nameMatch != nil {
|
||||
|
@ -90,16 +96,16 @@ func parseOsRelease(osReleaseFile string) (osRelease, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func mapRootfs(rinf raucInfo) map[string]Rootfs {
|
||||
res := make(map[string]Rootfs)
|
||||
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] = Rootfs{
|
||||
res[name] = model.Rootfs{
|
||||
Device: fs.Device,
|
||||
Type: fs.Type,
|
||||
bootname: fs.Bootname,
|
||||
Bootname: fs.Bootname,
|
||||
Mountpoint: fs.Mountpoint,
|
||||
Bootable: fs.BootStatus == "good",
|
||||
Booted: fs.State == "booted",
|
||||
|
@ -111,19 +117,21 @@ func mapRootfs(rinf raucInfo) map[string]Rootfs {
|
|||
return res
|
||||
}
|
||||
|
||||
func getFSNameFromBootname(rfslist map[string]Rootfs, bootname string) string {
|
||||
func getFSNameFromBootname(rfslist map[string]model.Rootfs, bootname string) string {
|
||||
for name, rfs := range rfslist {
|
||||
if rfs.bootname == bootname {
|
||||
if rfs.Bootname == bootname {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return "n/a"
|
||||
}
|
||||
|
||||
func mapSysinfo(rinf raucInfo, osr osRelease, uptime int, hostname string) SystemInfo {
|
||||
func mapSysinfo(rinf raucInfo, osr osRelease, uptime int,
|
||||
hostname string,
|
||||
) model.SystemInfo {
|
||||
rfslist := mapRootfs(rinf)
|
||||
|
||||
return SystemInfo{
|
||||
return model.SystemInfo{
|
||||
Hostname: hostname,
|
||||
OsName: osr.OsName,
|
||||
OsVersion: osr.OsVersion,
|
||||
|
@ -134,8 +142,8 @@ func mapSysinfo(rinf raucInfo, osr osRelease, uptime int, hostname string) Syste
|
|||
}
|
||||
}
|
||||
|
||||
func getUptime() (int, error) {
|
||||
uptimeRaw, err := os.ReadFile("/proc/uptime")
|
||||
func (s *Sysinfo) getUptime() (int, error) {
|
||||
uptimeRaw, err := os.ReadFile(s.uptimeFile)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
@ -144,37 +152,37 @@ func getUptime() (int, error) {
|
|||
return strconv.Atoi(string(uptimeChars))
|
||||
}
|
||||
|
||||
func getHostname() string {
|
||||
hostname, err := os.ReadFile("/etc/hostname")
|
||||
func (s *Sysinfo) getHostname() string {
|
||||
hostname, err := os.ReadFile(s.hostnameFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(hostname))
|
||||
}
|
||||
|
||||
func GetSysinfo() (SystemInfo, error) {
|
||||
cmd := util.CommandFromString(util.RaucCmd + " status --output-format=json")
|
||||
func (s *Sysinfo) GetSysinfo() (model.SystemInfo, error) {
|
||||
cmd := util.CommandFromString(s.cmdRaucStatus)
|
||||
rinfJson, err := cmd.Output()
|
||||
if err != nil {
|
||||
return SystemInfo{}, err
|
||||
return model.SystemInfo{}, err
|
||||
}
|
||||
|
||||
rinf, err := parseRaucInfo(rinfJson)
|
||||
if err != nil {
|
||||
return SystemInfo{}, err
|
||||
return model.SystemInfo{}, err
|
||||
}
|
||||
|
||||
osinf, err := parseOsRelease("/etc/os-release")
|
||||
osinf, err := s.parseOsRelease()
|
||||
if err != nil {
|
||||
return SystemInfo{}, err
|
||||
return model.SystemInfo{}, err
|
||||
}
|
||||
|
||||
uptime, err := getUptime()
|
||||
uptime, err := s.getUptime()
|
||||
if err != nil {
|
||||
return SystemInfo{}, err
|
||||
return model.SystemInfo{}, err
|
||||
}
|
||||
|
||||
hostname := getHostname()
|
||||
hostname := s.getHostname()
|
||||
|
||||
return mapSysinfo(rinf, osinf, uptime, hostname), nil
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import (
|
|||
"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"
|
||||
)
|
||||
|
||||
|
@ -48,11 +50,11 @@ var expectedRaucInfo = raucInfo{
|
|||
},
|
||||
}
|
||||
|
||||
var expectedRootfsList = map[string]Rootfs{
|
||||
var expectedRootfsList = map[string]model.Rootfs{
|
||||
"rootfs.0": {
|
||||
Device: "/dev/mmcblk0p2",
|
||||
Type: "ext4",
|
||||
bootname: "A",
|
||||
Bootname: "A",
|
||||
Mountpoint: &mountRoot,
|
||||
Bootable: true,
|
||||
Booted: true,
|
||||
|
@ -61,7 +63,7 @@ var expectedRootfsList = map[string]Rootfs{
|
|||
"rootfs.1": {
|
||||
Device: "/dev/mmcblk0p3",
|
||||
Type: "ext4",
|
||||
bootname: "B",
|
||||
Bootname: "B",
|
||||
Mountpoint: nil,
|
||||
Bootable: true,
|
||||
Booted: false,
|
||||
|
@ -82,7 +84,10 @@ func TestParseOsRelease(t *testing.T) {
|
|||
testfiles := fixtures.GetTestfilesDir()
|
||||
osReleaseFile := filepath.Join(testfiles, "os-release")
|
||||
|
||||
osRel, err := parseOsRelease(osReleaseFile)
|
||||
si := New(testcmd.RaucStatus, osReleaseFile, "NAME", "VERSION",
|
||||
"/etc/hostname", "/proc/uptime")
|
||||
|
||||
osRel, err := si.parseOsRelease()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -110,7 +115,9 @@ func TestGetFSNameFromBootname(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetSysinfo(t *testing.T) {
|
||||
sysinfo, err := GetSysinfo()
|
||||
si := Default(testcmd.RaucStatus)
|
||||
|
||||
sysinfo, err := si.GetSysinfo()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
//go:build prod
|
||||
// +build prod
|
||||
|
||||
package util
|
||||
|
||||
const (
|
||||
RebootCmd = "shutdown -r 0"
|
||||
RaucCmd = "rauc"
|
||||
|
||||
TestMode = false
|
||||
)
|
|
@ -1,11 +0,0 @@
|
|||
//go:build !prod
|
||||
// +build !prod
|
||||
|
||||
package util
|
||||
|
||||
const (
|
||||
RebootCmd = "touch /tmp/sebrauc_reboot_test"
|
||||
RaucCmd = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock"
|
||||
|
||||
TestMode = true
|
||||
)
|
30
src/util/counter.go
Normal file
30
src/util/counter.go
Normal 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
30
src/util/counter_test.go
Normal 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())
|
||||
}
|
|
@ -1,8 +1,13 @@
|
|||
package util
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlreadyRunning = errors.New("rauc already running")
|
||||
ErrAlreadyRunning = HttpErrNew("rauc already running", http.StatusConflict)
|
||||
ErrFileDoesNotExist = errors.New("file does not exist")
|
||||
ErrPageNotFound = HttpErrNew("page not found", http.StatusNotFound)
|
||||
ErrUnauthorized = HttpErrNew("unauthorized", http.StatusUnauthorized)
|
||||
)
|
||||
|
|
39
src/util/http_error.go
Normal file
39
src/util/http_error.go
Normal 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
46
src/util/mode/mode.go
Normal 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")
|
||||
}
|
||||
}
|
6
src/util/mode/mode_dev.go
Normal file
6
src/util/mode/mode_dev.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
//go:build !prod
|
||||
// +build !prod
|
||||
|
||||
package mode
|
||||
|
||||
const appmode = Dev
|
6
src/util/mode/mode_prod.go
Normal file
6
src/util/mode/mode_prod.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
//go:build prod
|
||||
// +build prod
|
||||
|
||||
package mode
|
||||
|
||||
const appmode = Prod
|
35
src/util/mode/mode_test.go
Normal file
35
src/util/mode/mode_test.go
Normal 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
5
src/util/types.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package util
|
||||
|
||||
type Broadcaster interface {
|
||||
Broadcast(msg []byte)
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
@ -25,10 +26,24 @@ func CreateDirIfNotExists(dirpath string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func GetTmpdir() (string, error) {
|
||||
tmpdir := filepath.Join(os.TempDir(), tmpdirName)
|
||||
func GetTmpdir(tmpdirPath string) string {
|
||||
tmpdir := tmpdirPath
|
||||
// Default temporary directory
|
||||
if tmpdirPath == "" {
|
||||
tmpdir = filepath.Join(os.TempDir(), tmpdirName)
|
||||
}
|
||||
|
||||
err := CreateDirIfNotExists(tmpdir)
|
||||
return tmpdir, err
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("could not create tmpdir %s: %s", tmpdir, err))
|
||||
}
|
||||
|
||||
return tmpdir
|
||||
}
|
||||
|
||||
func RemoveTmpdir(tmpdirPath string) {
|
||||
tmpdir := GetTmpdir(tmpdirPath)
|
||||
_ = os.RemoveAll(tmpdir)
|
||||
}
|
||||
|
||||
func CommandFromString(cmdString string) *exec.Cmd {
|
||||
|
@ -41,8 +56,41 @@ func CommandFromString(cmdString string) *exec.Cmd {
|
|||
return exec.Command(parts[0], parts[1:]...)
|
||||
}
|
||||
|
||||
func Reboot(t time.Duration) {
|
||||
func Reboot(rebootCmd string, t time.Duration) {
|
||||
time.Sleep(t)
|
||||
cmd := CommandFromString(RebootCmd)
|
||||
cmd := CommandFromString(rebootCmd)
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
func FindFile(explicitPath string, locations, endings []string) (string, error) {
|
||||
if explicitPath != "" {
|
||||
if !DoesFileExist(explicitPath) {
|
||||
return "", fmt.Errorf("file %s not found", explicitPath)
|
||||
}
|
||||
return explicitPath, nil
|
||||
}
|
||||
|
||||
notFound := []string{}
|
||||
|
||||
for _, f := range locations {
|
||||
if endings != nil {
|
||||
for _, t := range endings {
|
||||
fpath := f + "." + t
|
||||
|
||||
if DoesFileExist(fpath) {
|
||||
return fpath, nil
|
||||
} else {
|
||||
notFound = append(notFound, fpath)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if DoesFileExist(f) {
|
||||
return f, nil
|
||||
} else {
|
||||
notFound = append(notFound, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("none of the following files found: %s",
|
||||
strings.Join(notFound, "; "))
|
||||
}
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
@ -41,11 +43,25 @@ func TestDoesFileExist(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTmpdir(t *testing.T) {
|
||||
td, err := GetTmpdir()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
path: "",
|
||||
},
|
||||
{
|
||||
name: "custom",
|
||||
path: filepath.Join(os.TempDir(), "customTmpdir"),
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
f, err := os.Create(tfile)
|
||||
if err != nil {
|
||||
|
@ -62,6 +78,11 @@ func TestTmpdir(t *testing.T) {
|
|||
}
|
||||
|
||||
assert.FileExists(t, tfile)
|
||||
|
||||
RemoveTmpdir(tt.path)
|
||||
assert.NoDirExists(t, td)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFromString(t *testing.T) {
|
||||
|
@ -103,7 +124,78 @@ func TestReboot(t *testing.T) {
|
|||
testfile := "/tmp/sebrauc_reboot_test"
|
||||
_ = os.Remove(testfile)
|
||||
|
||||
Reboot(0)
|
||||
Reboot(testcmd.Reboot, 0)
|
||||
|
||||
assert.FileExists(t, testfile)
|
||||
}
|
||||
|
||||
func TestFindFile(t *testing.T) {
|
||||
testfiles := fixtures.GetTestfilesDir()
|
||||
|
||||
//nolint:lll
|
||||
tests := []struct {
|
||||
name string
|
||||
explicitPath string
|
||||
locations []string
|
||||
endings []string
|
||||
expect string
|
||||
expectErr error
|
||||
}{
|
||||
{
|
||||
name: "locations",
|
||||
explicitPath: "",
|
||||
locations: []string{filepath.Join(testfiles, "sebrauc")},
|
||||
endings: []string{"yml", "toml"},
|
||||
expect: filepath.Join(testfiles, "sebrauc.toml"),
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "locations_nf",
|
||||
explicitPath: "",
|
||||
locations: []string{filepath.Join(testfiles, "banana")},
|
||||
endings: []string{"yml", "toml"},
|
||||
expect: "",
|
||||
expectErr: errors.New("none of the following files found: src/fixtures/testfiles/banana.yml; src/fixtures/testfiles/banana.toml"),
|
||||
},
|
||||
{
|
||||
name: "no_endings",
|
||||
explicitPath: "",
|
||||
locations: []string{filepath.Join(testfiles, "banana"), filepath.Join(testfiles, "os-release")},
|
||||
endings: nil,
|
||||
expect: filepath.Join(testfiles, "os-release"),
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "no_endings_nf",
|
||||
explicitPath: "",
|
||||
locations: []string{filepath.Join(testfiles, "banana"), filepath.Join(testfiles, "apple")},
|
||||
endings: nil,
|
||||
expect: "",
|
||||
expectErr: errors.New("none of the following files found: src/fixtures/testfiles/banana; src/fixtures/testfiles/apple"),
|
||||
},
|
||||
{
|
||||
name: "explicit",
|
||||
explicitPath: filepath.Join(testfiles, "sebrauc.toml"),
|
||||
locations: []string{filepath.Join(testfiles, "banana")},
|
||||
endings: []string{"yml", "toml"},
|
||||
expect: filepath.Join(testfiles, "sebrauc.toml"),
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "explicit_nf",
|
||||
explicitPath: filepath.Join(testfiles, "banana.toml"),
|
||||
locations: []string{filepath.Join(testfiles, "banana")},
|
||||
endings: []string{"yml", "toml"},
|
||||
expect: "",
|
||||
expectErr: errors.New("file src/fixtures/testfiles/banana.toml not found"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fpath, err := FindFile(tt.explicitPath, tt.locations, tt.endings)
|
||||
assert.Equal(t, tt.expectErr, err)
|
||||
assert.Equal(t, tt.expect, fpath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
VITE_VERSION=dev
|
||||
VITE_API_HOST=127.0.0.1:8080
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
tmp
|
||||
.tmp
|
|
@ -7,7 +7,11 @@
|
|||
<title>SEBRAUC</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You have to enable JavaScript to use SEBRAUC.</noscript>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.config = "%CONFIG%"
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview"
|
||||
"serve": "vite preview",
|
||||
"lint": "tsc",
|
||||
"format": "prettier --write ../"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^6.5.95",
|
||||
|
|
4
ui/pnpm-lock.yaml
generated
4
ui/pnpm-lock.yaml
generated
|
@ -1,4 +1,4 @@
|
|||
lockfileVersion: 5.3
|
||||
lockfileVersion: 5.4
|
||||
|
||||
specifiers:
|
||||
"@mdi/js": ^6.5.95
|
||||
|
@ -282,6 +282,8 @@ packages:
|
|||
}
|
||||
engines: {node: ">=6.0.0"}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
"@babel/types": 7.16.0
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-jsx/7.16.0:
|
||||
|
|
|
@ -23,6 +23,8 @@ type State = {
|
|||
}
|
||||
|
||||
export default class SysinfoCard extends Component<Props, State> {
|
||||
private fetchTimeout: number | undefined
|
||||
|
||||
constructor(props?: Props | undefined, context?: any) {
|
||||
super(props, context)
|
||||
this.fetchInfo()
|
||||
|
@ -36,13 +38,12 @@ export default class SysinfoCard extends Component<Props, State> {
|
|||
this.setState({sysinfo: response.data})
|
||||
} else {
|
||||
console.log("error fetching info", response.data)
|
||||
console.log("error fetching info", response.data)
|
||||
window.setTimeout(this.fetchInfo, 3000)
|
||||
this.fetchTimeout = window.setTimeout(this.fetchInfo, 3000)
|
||||
}
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.log("error fetching info", reason)
|
||||
window.setTimeout(this.fetchInfo, 3000)
|
||||
this.fetchTimeout = window.setTimeout(this.fetchInfo, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -150,6 +151,12 @@ export default class SysinfoCard extends Component<Props, State> {
|
|||
)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.fetchTimeout !== undefined) {
|
||||
window.clearTimeout(this.fetchTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.sysinfo) {
|
||||
return this.renderSysinfo()
|
||||
|
|
|
@ -7,8 +7,9 @@ import Icon from "../Icon/Icon"
|
|||
import "./Updater.scss"
|
||||
import Alert from "./Alert"
|
||||
import Reboot from "./Reboot"
|
||||
import {sebraucApi, wsUrl} from "../../util/apiUrls"
|
||||
import {sebraucApi} from "../../util/apiUrls"
|
||||
import colors from "../../util/colors"
|
||||
import WebsocketClient from "../../util/websocket"
|
||||
|
||||
class UploadStatus {
|
||||
uploading = false
|
||||
|
@ -52,19 +53,19 @@ type State = {
|
|||
|
||||
export default class UpdaterCard extends Component<Props, State> {
|
||||
private dropzoneRef = createRef<Dropzone>()
|
||||
private conn: WebSocket | undefined
|
||||
private ws: WebsocketClient
|
||||
|
||||
constructor(props?: Props | undefined, context?: any) {
|
||||
super(props, context)
|
||||
|
||||
this.ws = new WebsocketClient(this.onWsStatusUpdate, this.onWsMessage)
|
||||
|
||||
this.state = {
|
||||
uploadStatus: new UploadStatus(false),
|
||||
uploadFilename: "",
|
||||
raucStatus: new RaucStatus(),
|
||||
wsConnected: false,
|
||||
wsConnected: this.ws.api().isConnected(),
|
||||
}
|
||||
|
||||
this.connectWebsocket()
|
||||
}
|
||||
|
||||
private buttonClick = () => {
|
||||
|
@ -105,33 +106,18 @@ export default class UpdaterCard extends Component<Props, State> {
|
|||
this.dropzoneRef.current?.reset()
|
||||
}
|
||||
|
||||
private connectWebsocket = () => {
|
||||
if (window.WebSocket) {
|
||||
this.conn = new WebSocket(wsUrl)
|
||||
this.conn.onopen = () => {
|
||||
this.setState({wsConnected: true})
|
||||
console.log("WS connected")
|
||||
private onWsStatusUpdate = (wsConnected: boolean) => {
|
||||
this.setState({wsConnected: wsConnected})
|
||||
}
|
||||
this.conn.onclose = () => {
|
||||
this.setState({wsConnected: false})
|
||||
console.log("WS connection closed")
|
||||
window.setTimeout(this.connectWebsocket, 3000)
|
||||
}
|
||||
this.conn.onmessage = (evt) => {
|
||||
|
||||
private onWsMessage = (evt: MessageEvent) => {
|
||||
var messages = evt.data.split("\n")
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
this.setState({
|
||||
raucStatus: Object.assign(
|
||||
new RaucStatus(),
|
||||
JSON.parse(messages[i])
|
||||
),
|
||||
raucStatus: Object.assign(new RaucStatus(), JSON.parse(messages[i])),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("Your browser does not support WebSockets")
|
||||
}
|
||||
}
|
||||
|
||||
private acceptUploads(): boolean {
|
||||
return !this.state.uploadStatus.uploading && !this.state.raucStatus.installing
|
||||
|
@ -167,6 +153,10 @@ export default class UpdaterCard extends Component<Props, State> {
|
|||
return 0
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.ws.destroy()
|
||||
}
|
||||
|
||||
render() {
|
||||
const acceptUploads = this.acceptUploads()
|
||||
const circleColor = this.circleColor()
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import {Component} from "preact"
|
||||
import UpdaterView from "./Updater/UpdaterView"
|
||||
import logo from "../assets/logo.svg"
|
||||
import {version} from "../util/version"
|
||||
import {getConfig} from "../util/config"
|
||||
|
||||
export default class App extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<img src={logo} alt="SEBRAUC" height="64" />
|
||||
{version}
|
||||
{getConfig().version}
|
||||
<UpdaterView />
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SEBRAUC
|
||||
* REST API for the SEBRAUC firmware updater
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
@ -38,7 +38,32 @@ import {
|
|||
} 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
|
||||
*/
|
||||
|
@ -49,18 +74,6 @@ export interface RaucStatus {
|
|||
* @memberof RaucStatus
|
||||
*/
|
||||
installing: boolean
|
||||
/**
|
||||
* Installation progress
|
||||
* @type {number}
|
||||
* @memberof RaucStatus
|
||||
*/
|
||||
percent: number
|
||||
/**
|
||||
* Current installation step
|
||||
* @type {string}
|
||||
* @memberof RaucStatus
|
||||
*/
|
||||
message: string
|
||||
/**
|
||||
* Installation error message
|
||||
* @type {string}
|
||||
|
@ -73,6 +86,18 @@ export interface RaucStatus {
|
|||
* @memberof RaucStatus
|
||||
*/
|
||||
log: string
|
||||
/**
|
||||
* Current installation step
|
||||
* @type {string}
|
||||
* @memberof RaucStatus
|
||||
*/
|
||||
message: string
|
||||
/**
|
||||
* Installation progress
|
||||
* @type {number}
|
||||
* @memberof RaucStatus
|
||||
*/
|
||||
percent: number
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
@ -80,64 +105,64 @@ export interface RaucStatus {
|
|||
* @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
|
||||
/**
|
||||
* Mount path (null when not mounted)
|
||||
* @type {string}
|
||||
* @memberof Rootfs
|
||||
*/
|
||||
mountpoint: string | null
|
||||
/**
|
||||
* Is the filesystem bootable
|
||||
* @type {boolean}
|
||||
* @memberof Rootfs
|
||||
*/
|
||||
bootable: boolean
|
||||
/**
|
||||
* Is the filesystem booted
|
||||
* @type {boolean}
|
||||
* @memberof Rootfs
|
||||
*/
|
||||
booted: boolean
|
||||
/**
|
||||
* Is the filesystem the next boot target
|
||||
* @type {boolean}
|
||||
* @memberof Rootfs
|
||||
*/
|
||||
primary: boolean
|
||||
}
|
||||
/**
|
||||
*
|
||||
* StatusMessage contains the status of an operation.
|
||||
* @export
|
||||
* @interface StatusMessage
|
||||
*/
|
||||
export interface StatusMessage {
|
||||
/**
|
||||
* Is operation successful
|
||||
* @type {boolean}
|
||||
* @memberof StatusMessage
|
||||
*/
|
||||
success: boolean
|
||||
/**
|
||||
* Success message
|
||||
* 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
|
||||
*/
|
||||
|
@ -160,18 +185,18 @@ export interface SystemInfo {
|
|||
* @memberof SystemInfo
|
||||
*/
|
||||
os_version: string
|
||||
/**
|
||||
* System uptime in seconds
|
||||
* @type {number}
|
||||
* @memberof SystemInfo
|
||||
*/
|
||||
uptime: number
|
||||
/**
|
||||
* 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}
|
||||
|
@ -179,11 +204,11 @@ export interface SystemInfo {
|
|||
*/
|
||||
rauc_variant: string
|
||||
/**
|
||||
* List of RAUC root filesystems
|
||||
* @type {{ [key: string]: Rootfs; }}
|
||||
* System uptime in seconds
|
||||
* @type {number}
|
||||
* @memberof SystemInfo
|
||||
*/
|
||||
rauc_rootfs: {[key: string]: Rootfs}
|
||||
uptime: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -193,7 +218,7 @@ export interface SystemInfo {
|
|||
export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* Get the current system info
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -225,7 +250,7 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati
|
|||
}
|
||||
},
|
||||
/**
|
||||
*
|
||||
* Get the current status of the RAUC updater
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -257,7 +282,7 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati
|
|||
}
|
||||
},
|
||||
/**
|
||||
*
|
||||
* Reboot the system
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -289,15 +314,17 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati
|
|||
}
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {any} [updateFile]
|
||||
* Start the update process
|
||||
* @param {any} updateFile RAUC firmware image file (*.raucb)
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
startUpdate: async (
|
||||
updateFile?: any,
|
||||
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)
|
||||
|
@ -345,7 +372,7 @@ export const DefaultApiFp = function (configuration?: Configuration) {
|
|||
const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* Get the current system info
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -363,7 +390,7 @@ export const DefaultApiFp = function (configuration?: Configuration) {
|
|||
)
|
||||
},
|
||||
/**
|
||||
*
|
||||
* Get the current status of the RAUC updater
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -381,7 +408,7 @@ export const DefaultApiFp = function (configuration?: Configuration) {
|
|||
)
|
||||
},
|
||||
/**
|
||||
*
|
||||
* Reboot the system
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -401,13 +428,13 @@ export const DefaultApiFp = function (configuration?: Configuration) {
|
|||
)
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {any} [updateFile]
|
||||
* Start the update process
|
||||
* @param {any} updateFile RAUC firmware image file (*.raucb)
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async startUpdate(
|
||||
updateFile?: any,
|
||||
updateFile: any,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<
|
||||
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<StatusMessage>
|
||||
|
@ -438,7 +465,7 @@ export const DefaultApiFactory = function (
|
|||
const localVarFp = DefaultApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* Get the current system info
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -448,7 +475,7 @@ export const DefaultApiFactory = function (
|
|||
.then((request) => request(axios, basePath))
|
||||
},
|
||||
/**
|
||||
*
|
||||
* Get the current status of the RAUC updater
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -458,7 +485,7 @@ export const DefaultApiFactory = function (
|
|||
.then((request) => request(axios, basePath))
|
||||
},
|
||||
/**
|
||||
*
|
||||
* Reboot the system
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
|
@ -468,12 +495,12 @@ export const DefaultApiFactory = function (
|
|||
.then((request) => request(axios, basePath))
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {any} [updateFile]
|
||||
* 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> {
|
||||
startUpdate(updateFile: any, options?: any): AxiosPromise<StatusMessage> {
|
||||
return localVarFp
|
||||
.startUpdate(updateFile, options)
|
||||
.then((request) => request(axios, basePath))
|
||||
|
@ -489,7 +516,7 @@ export const DefaultApiFactory = function (
|
|||
*/
|
||||
export class DefaultApi extends BaseAPI {
|
||||
/**
|
||||
*
|
||||
* Get the current system info
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof DefaultApi
|
||||
|
@ -501,7 +528,7 @@ export class DefaultApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Get the current status of the RAUC updater
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof DefaultApi
|
||||
|
@ -513,7 +540,7 @@ export class DefaultApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Reboot the system
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof DefaultApi
|
||||
|
@ -525,13 +552,13 @@ export class DefaultApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {any} [updateFile]
|
||||
* 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) {
|
||||
public startUpdate(updateFile: any, options?: AxiosRequestConfig) {
|
||||
return DefaultApiFp(this.configuration)
|
||||
.startUpdate(updateFile, options)
|
||||
.then((request) => request(this.axios, this.basePath))
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SEBRAUC
|
||||
* REST API for the SEBRAUC firmware updater
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
@ -17,7 +17,7 @@ import {Configuration} from "./configuration"
|
|||
// @ts-ignore
|
||||
import globalAxios, {AxiosPromise, AxiosInstance, AxiosRequestConfig} from "axios"
|
||||
|
||||
export const BASE_PATH = "http://localhost:8080/api".replace(/\/+$/, "")
|
||||
export const BASE_PATH = "http://localhost".replace(/\/+$/, "")
|
||||
|
||||
/**
|
||||
*
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SEBRAUC
|
||||
* REST API for the SEBRAUC firmware updater
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SEBRAUC
|
||||
* REST API for the SEBRAUC firmware updater
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* SEBRAUC
|
||||
* REST API for the SEBRAUC firmware updater
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
23
ui/src/util/config.ts
Normal file
23
ui/src/util/config.ts
Normal 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",
|
||||
}
|
||||
}
|
|
@ -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
92
ui/src/util/websocket.ts
Normal 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)
|
||||
}
|
||||
}
|
58
ui/ui.go
58
ui/ui.go
|
@ -1,10 +1,64 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/server/middleware"
|
||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const AssetsDir = "dist"
|
||||
const distDir = "dist"
|
||||
|
||||
//go:embed dist/**
|
||||
var Assets embed.FS
|
||||
var assets embed.FS
|
||||
|
||||
type uiConfig struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func subFS(fsys fs.FS, dir string) fs.FS {
|
||||
sub, err := fs.Sub(fsys, dir)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func distFS() fs.FS {
|
||||
return subFS(assets, distDir)
|
||||
}
|
||||
|
||||
func Register(r gin.IRouter) {
|
||||
indexHandler := getIndexHandler()
|
||||
|
||||
uiAssets := r.Group("/assets", middleware.Cache)
|
||||
|
||||
r.GET("/", indexHandler)
|
||||
r.GET("/index.html", indexHandler)
|
||||
|
||||
uiAssets.StaticFS("/", http.FS(subFS(distFS(), "assets")))
|
||||
}
|
||||
|
||||
func getIndexHandler() gin.HandlerFunc {
|
||||
content, err := fs.ReadFile(distFS(), "index.html")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
uiConfigBytes, err := json.Marshal(uiConfig{
|
||||
Version: util.Version(),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
content = bytes.ReplaceAll(content, []byte("\"%CONFIG%\""), uiConfigBytes)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
c.Data(200, "text/html", content)
|
||||
}
|
||||
}
|
||||
|
|
87
ui/ui_test.go
Normal file
87
ui/ui_test.go
Normal 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
14
woodpecker.yml
Normal 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/...
|
Loading…
Add table
Reference in a new issue