Compare commits
26 commits
Author | SHA1 | Date | |
---|---|---|---|
28e51df5b8 | |||
7620f0d9f2 | |||
4d7c4646bb | |||
e98b29d666 | |||
e2c3c2ce6b | |||
69318d96d2 | |||
df98c40853 | |||
df78f37d86 | |||
92bed651b7 | |||
acd1db7363 | |||
312de77236 | |||
3c9867b75b | |||
e3cb739db5 | |||
4cc757d550 | |||
71764dd6fa | |||
e1c4c58684 | |||
8714ad966b | |||
c8e2d2a216 | |||
0428ad3ebc | |||
0125f4e3fe | |||
44001bb7e7 | |||
7465ef3380 | |||
85c0073651 | |||
3e29e04ac3 | |||
8c1fd2a6ab | |||
25cf158c3a |
100 changed files with 5642 additions and 612 deletions
|
@ -1,11 +1,11 @@
|
||||||
root = "./src"
|
root = "."
|
||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "go build -o ./tmp/main ./src/."
|
cmd = "go build -o ./tmp/main ./src/."
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = ["assets", "tmp", "vendor"]
|
exclude_dir = ["tmp", "vendor", "ui/dist", "ui/node_modules", "ui/src"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
exclude_regex = []
|
exclude_regex = []
|
||||||
exclude_unchanged = false
|
exclude_unchanged = false
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,2 +1,4 @@
|
||||||
build
|
build
|
||||||
tmp
|
tmp
|
||||||
|
/sebrauc.toml
|
||||||
|
/htpasswd
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
linters:
|
linters:
|
||||||
presets:
|
presets:
|
||||||
- bugs
|
- bugs
|
||||||
- unused
|
|
||||||
- import
|
- import
|
||||||
- module
|
- module
|
||||||
|
|
||||||
|
@ -14,11 +13,17 @@ linters:
|
||||||
|
|
||||||
disable:
|
disable:
|
||||||
- scopelint
|
- scopelint
|
||||||
|
- noctx
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
|
tab-width: 4
|
||||||
gocognit:
|
gocognit:
|
||||||
min-complexity: 10
|
min-complexity: 10
|
||||||
nestif:
|
nestif:
|
||||||
min-complexity: 3
|
min-complexity: 3
|
||||||
|
errcheck:
|
||||||
|
exclude-functions:
|
||||||
|
- "(*github.com/gin-gonic/gin.Context).Error"
|
||||||
|
- "(*github.com/gin-gonic/gin.Context).AbortWithError"
|
||||||
|
|
|
@ -6,10 +6,7 @@ repos:
|
||||||
name: GolangCI Lint
|
name: GolangCI Lint
|
||||||
- id: go-test-repo-mod
|
- id: go-test-repo-mod
|
||||||
name: Backend tests
|
name: Backend tests
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v2.4.1
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: tsc
|
- id: tsc
|
||||||
|
@ -20,3 +17,8 @@ repos:
|
||||||
args: ["-p", "./ui/tsconfig.json"]
|
args: ["-p", "./ui/tsconfig.json"]
|
||||||
additional_dependencies: ["typescript@4.5.2"]
|
additional_dependencies: ["typescript@4.5.2"]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
|
rev: v2.4.1
|
||||||
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
|
14
.woodpecker.yml
Normal file
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/...
|
24
Makefile
24
Makefile
|
@ -1,19 +1,35 @@
|
||||||
SRC_DIR=./src
|
SRC_DIR=./src
|
||||||
UI_DIR=./ui
|
UI_DIR=./ui
|
||||||
|
|
||||||
VERSION=$(shell git tag --sort=-version:refname | head -n 1)
|
APIDOC_FILE=./src/server/swagger/swagger.yaml
|
||||||
|
|
||||||
|
VER=$(or ${VERSION},$(shell git tag --sort=-version:refname | head -n 1))
|
||||||
|
|
||||||
setup:
|
setup:
|
||||||
go get -t ./src/...
|
|
||||||
cd ${UI_DIR} && pnpm install
|
cd ${UI_DIR} && pnpm install
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v ./src/...
|
go test -v ./src/...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
cd ${UI_DIR} && npm run format && npm run lint
|
||||||
|
|
||||||
build-ui:
|
build-ui:
|
||||||
cd ${UI_DIR} && VITE_VERSION=${VERSION} pnpm run build
|
cd ${UI_DIR} && pnpm run build
|
||||||
|
|
||||||
build-server:
|
build-server:
|
||||||
go build -tags prod -ldflags "-s -w -X code.thetadev.de/TSGRain/SEBRAUC/src/util.version=${VERSION}" -o build/sebrauc ./src/.
|
go build -tags prod -ldflags "-s -w -X code.thetadev.de/TSGRain/SEBRAUC/src/util.version=${VER}" -o build/sebrauc ./src/.
|
||||||
|
|
||||||
build: build-ui build-server
|
build: build-ui build-server
|
||||||
|
|
||||||
|
generate-apidoc:
|
||||||
|
SWAGGER_GENERATE_EXTENSION=false swagger generate spec --scan-models -o ${APIDOC_FILE}
|
||||||
|
|
||||||
|
generate-apiclient:
|
||||||
|
openapi-generator generate -i ${APIDOC_FILE} -g typescript-axios -o ${UI_DIR}/src/sebrauc-client -p "supportsES6=true"
|
||||||
|
cd ${UI_DIR} && npm run format
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f build/*
|
||||||
|
rm -rf ${UI_DIR}/dist/**
|
||||||
|
|
|
@ -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
|
go 1.16
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
code.thetadev.de/TSGRain/ginzip v0.1.1
|
||||||
github.com/gofiber/fiber/v2 v2.21.0
|
github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db
|
||||||
github.com/gofiber/websocket/v2 v2.0.12
|
github.com/fortytw2/leaktest v1.3.0
|
||||||
|
github.com/gin-contrib/cors v1.3.1
|
||||||
|
github.com/gin-gonic/gin v1.7.7
|
||||||
|
github.com/go-errors/errors v1.4.1 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
|
github.com/google/go-cmp v0.5.6 // indirect
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
|
github.com/gorilla/websocket v1.4.1
|
||||||
|
github.com/jinzhu/configor v1.2.1
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
|
github.com/tg123/go-htpasswd v1.2.0
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
|
google.golang.org/protobuf v1.27.1 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||||
)
|
)
|
||||||
|
|
138
go.sum
138
go.sum
|
@ -1,48 +1,128 @@
|
||||||
github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E=
|
code.thetadev.de/TSGRain/ginzip v0.1.1 h1:+X0L6qumEZiKYSLmM+Q0LqKVHsKvdcg4CVzsEpvM7fk=
|
||||||
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
code.thetadev.de/TSGRain/ginzip v0.1.1/go.mod h1:BH7VkvpP83vPRyMQ8rLIjKycQwGzF+/mFV0BKzg+BuA=
|
||||||
|
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
|
||||||
|
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
|
||||||
|
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||||
|
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fasthttp/websocket v1.4.3-rc.9 h1:CWJH0vONrOatdKXZgkgbFKWllijD9aY50C5KfbSDcWk=
|
github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db h1:oZ4U9IqO8NS+61OmGTBi8vopzqTRxwQeogyBHdrhjbc=
|
||||||
github.com/fasthttp/websocket v1.4.3-rc.9/go.mod h1:eXL2zqDbexYJxaCw8/PQlm7VcMK6uoGvwbYbTdt4dFo=
|
github.com/ekyoung/gin-nice-recovery v0.0.0-20160510022553-1654dca486db/go.mod h1:Pk7/9x6tyChFTkahDvLBQMlvdsWvfC+yU8HTT5VD314=
|
||||||
github.com/gofiber/fiber/v2 v2.20.1/go.mod h1:/LdZHMUXZvTTo7gU4+b1hclqCAdoQphNQ9bi9gutPyI=
|
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||||
github.com/gofiber/fiber/v2 v2.21.0 h1:tdRNrgqWqcHWBwE3o51oAleEVsil4Ro02zd2vMEuP4Q=
|
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||||
github.com/gofiber/fiber/v2 v2.21.0/go.mod h1:MR1usVH3JHYRyQwMe2eZXRSZHRX38fkV+A7CPB+DlDQ=
|
github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA=
|
||||||
github.com/gofiber/websocket/v2 v2.0.12 h1:jKwTrXiOut9UGOGEzFTAD6gq+/78mM3NcrI05VbxjAU=
|
github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk=
|
||||||
github.com/gofiber/websocket/v2 v2.0.12/go.mod h1:lQRy0u5ACJfiez/e/bhGeYvM0/M940Y3NFw14U3/otI=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
|
||||||
|
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
|
||||||
|
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
|
||||||
|
github.com/go-errors/errors v1.4.1 h1:IvVlgbzSsaUNudsw5dcXSzF3EWyXTi5XrAdngnuhRyg=
|
||||||
|
github.com/go-errors/errors v1.4.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
|
||||||
|
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||||
|
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||||
|
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
||||||
|
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
|
||||||
|
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||||
|
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
|
||||||
|
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
|
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
|
||||||
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
|
||||||
|
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||||
|
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
|
||||||
|
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||||
|
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||||
|
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4 h1:ocK/D6lCgLji37Z2so4xhMl46se1ntReQQCUIU4BWI8=
|
|
||||||
github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
|
||||||
github.com/valyala/fasthttp v1.29.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
||||||
github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||||
github.com/valyala/fasthttp v1.31.0 h1:lrauRLII19afgCs2fnWRJ4M5IkV0lo2FqA61uGkNBfE=
|
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
||||||
github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
|
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
|
||||||
|
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||||
|
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
107
openapi.yml
107
openapi.yml
|
@ -1,107 +0,0 @@
|
||||||
openapi: "3.0.3"
|
|
||||||
info:
|
|
||||||
title: SEBRAUC
|
|
||||||
version: "0.0.1"
|
|
||||||
servers:
|
|
||||||
- url: http://localhost:8080/api
|
|
||||||
paths:
|
|
||||||
/status:
|
|
||||||
get:
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
"application/json":
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/RaucStatus"
|
|
||||||
default:
|
|
||||||
description: "Server error"
|
|
||||||
content:
|
|
||||||
"application/json":
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/StatusMessage"
|
|
||||||
/update:
|
|
||||||
post:
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
multipart/form-data:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
updateFile:
|
|
||||||
type: string
|
|
||||||
format: binary
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: "OK"
|
|
||||||
content:
|
|
||||||
"application/json":
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/StatusMessage"
|
|
||||||
default:
|
|
||||||
description: "Server error"
|
|
||||||
content:
|
|
||||||
"application/json":
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/StatusMessage"
|
|
||||||
|
|
||||||
/reboot:
|
|
||||||
post:
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: "OK"
|
|
||||||
content:
|
|
||||||
"application/json":
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/StatusMessage"
|
|
||||||
default:
|
|
||||||
description: "Server error"
|
|
||||||
content:
|
|
||||||
"application/json":
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/StatusMessage"
|
|
||||||
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
RaucStatus:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
installing:
|
|
||||||
description: "True if the installer is running"
|
|
||||||
type: boolean
|
|
||||||
percent:
|
|
||||||
description: "Installation progress"
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
maximum: 100
|
|
||||||
message:
|
|
||||||
description: "Current installation step"
|
|
||||||
type: string
|
|
||||||
example: "Copying image to rootfs.0"
|
|
||||||
last_error:
|
|
||||||
description: "Installation error message"
|
|
||||||
type: string
|
|
||||||
example: "Failed to check bundle identifier: Invalid identifier. Did you pass a valid RAUC bundle?"
|
|
||||||
log:
|
|
||||||
description: "Full command line output of the current installation"
|
|
||||||
type: string
|
|
||||||
example: "0% Installing\n0% Determining slot states\n20% Determining slot states done.\n"
|
|
||||||
required:
|
|
||||||
- installing
|
|
||||||
- percent
|
|
||||||
- message
|
|
||||||
- last_error
|
|
||||||
- log
|
|
||||||
|
|
||||||
StatusMessage:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
success:
|
|
||||||
description: "Is operation successful"
|
|
||||||
type: boolean
|
|
||||||
msg:
|
|
||||||
description: "Success message"
|
|
||||||
type: string
|
|
||||||
example: "Update started"
|
|
||||||
required:
|
|
||||||
- msg
|
|
44
sebrauc.example.toml
Normal file
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -41,22 +41,54 @@ LastError: Failed to check bundle identifier: Invalid identifier. ` +
|
||||||
idle
|
idle
|
||||||
Installing ` + "/app/demo` failed"
|
Installing ` + "/app/demo` failed"
|
||||||
|
|
||||||
func main() {
|
const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` +
|
||||||
arg := ""
|
`"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` +
|
||||||
if len(os.Args) > 1 {
|
`"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` +
|
||||||
arg = os.Args[1]
|
`"parent":null,"mountpoint":null,"boot_status":"good"}},{"rootfs.0":` +
|
||||||
}
|
`{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` +
|
||||||
|
`"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}`
|
||||||
var lines string
|
|
||||||
switch arg {
|
|
||||||
case "fail":
|
|
||||||
lines = outputFailure
|
|
||||||
default:
|
|
||||||
lines = outputSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func printLinesWithDelay(lines string, delay time.Duration) {
|
||||||
for _, line := range strings.Split(lines, "\n") {
|
for _, line := range strings.Split(lines, "\n") {
|
||||||
fmt.Println(line)
|
fmt.Println(line)
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBoolEnvvar(name string) bool {
|
||||||
|
val := strings.ToLower(os.Getenv(name))
|
||||||
|
return val != "" && val != "false" && val != "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
method := ""
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
method = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
test := getBoolEnvvar("RAUC_MOCK_TEST")
|
||||||
|
failure := getBoolEnvvar("RAUC_MOCK_FAIL")
|
||||||
|
|
||||||
|
delay := 500 * time.Millisecond
|
||||||
|
if test {
|
||||||
|
delay = 10 * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "install":
|
||||||
|
if failure {
|
||||||
|
printLinesWithDelay(outputFailure, delay)
|
||||||
|
} else {
|
||||||
|
printLinesWithDelay(outputSuccess, delay)
|
||||||
|
}
|
||||||
|
case "status":
|
||||||
|
if os.Args[2] != "--output-format=json" {
|
||||||
|
fmt.Println("output format must be json")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(statusJson)
|
||||||
|
default:
|
||||||
|
fmt.Println("invalid method")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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
|
5
src/fixtures/testfiles/os-release
Normal file
5
src/fixtures/testfiles/os-release
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
ID=tsgrain
|
||||||
|
NAME="TSGRain distro"
|
||||||
|
VERSION="0.0.1"
|
||||||
|
VERSION_ID=0.0.1
|
||||||
|
PRETTY_NAME="TSGRain distro 0.0.1"
|
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 (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var envPrefixes = []string{"SEBRAUC", "RAUC_MOCK"}
|
||||||
|
|
||||||
func doesFileExist(filepath string) bool {
|
func doesFileExist(filepath string) bool {
|
||||||
_, err := os.Stat(filepath)
|
_, err := os.Stat(filepath)
|
||||||
return !os.IsNotExist(err)
|
return !os.IsNotExist(err)
|
||||||
|
@ -38,3 +41,20 @@ func GetTestfilesDir() string {
|
||||||
CdProjectRoot()
|
CdProjectRoot()
|
||||||
return filepath.Join("src", "fixtures", "testfiles")
|
return filepath.Join("src", "fixtures", "testfiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ResetEnv() {
|
||||||
|
for _, envvar := range os.Environ() {
|
||||||
|
split := strings.SplitN(envvar, "=", 2)
|
||||||
|
if len(split) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := split[0]
|
||||||
|
|
||||||
|
for _, prefix := range envPrefixes {
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
_ = os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -35,3 +35,15 @@ func TestCdProjectRoot(t *testing.T) {
|
||||||
CdProjectRoot()
|
CdProjectRoot()
|
||||||
assert.True(t, doesFileExist("go.sum"))
|
assert.True(t, doesFileExist("go.sum"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResetEnv(t *testing.T) {
|
||||||
|
os.Setenv("RAUC_MOCK_TEST", "1")
|
||||||
|
os.Setenv("SEBRAUC_PORT", "8001")
|
||||||
|
|
||||||
|
ResetEnv()
|
||||||
|
|
||||||
|
_, exists := os.LookupEnv("RAUC_MOCK_TEST")
|
||||||
|
assert.False(t, exists)
|
||||||
|
_, exists = os.LookupEnv("SEBRAUC_PORT")
|
||||||
|
assert.False(t, exists)
|
||||||
|
}
|
||||||
|
|
31
src/main.go
31
src/main.go
|
@ -1,22 +1,45 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/config"
|
||||||
"code.thetadev.de/TSGRain/SEBRAUC/src/server"
|
"code.thetadev.de/TSGRain/SEBRAUC/src/server"
|
||||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
const titleArt = ` _____ __________ ____ ___ __ ________
|
||||||
fmt.Println("SEBRAUC " + util.Version())
|
/ ___// ____/ __ )/ __ \/ | / / / / ____/
|
||||||
|
\__ \/ __/ / __ / /_/ / /| |/ / / / /
|
||||||
|
___/ / /___/ /_/ / _, _/ ___ / /_/ / /___
|
||||||
|
/____/_____/_____/_/ |_/_/ |_\____/\____/ `
|
||||||
|
|
||||||
if util.TestMode {
|
func main() {
|
||||||
|
run(os.Args[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) {
|
||||||
|
fmt.Println(titleArt + util.Version() + "\n")
|
||||||
|
|
||||||
|
cmdFlags := flag.NewFlagSet("sebrauc", flag.ExitOnError)
|
||||||
|
port := cmdFlags.Int("p", 0, "HTTP port")
|
||||||
|
cfgPath := cmdFlags.String("c", "", "Config file path")
|
||||||
|
_ = cmdFlags.Parse(args)
|
||||||
|
|
||||||
|
if mode.IsDev() {
|
||||||
fmt.Println("Test mode active - no update operations are executed.")
|
fmt.Println("Test mode active - no update operations are executed.")
|
||||||
fmt.Println("Build with -tags prod to enable live mode.")
|
fmt.Println("Build with -tags prod to enable live mode.")
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := server.NewServer(":8080")
|
cfg := config.GetWithFlags(*cfgPath, *port)
|
||||||
|
|
||||||
|
fmt.Printf("Starting server at %s:%d\n", cfg.Server.Address, cfg.Server.Port)
|
||||||
|
|
||||||
|
srv := server.NewServer(cfg)
|
||||||
err := srv.Run()
|
err := srv.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
|
|
24
src/model/error.go
Normal file
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"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
|
||||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,32 +19,26 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Rauc struct {
|
type Rauc struct {
|
||||||
broadcast chan string
|
cmdRaucInstall string
|
||||||
status RaucStatus
|
bc util.Broadcaster
|
||||||
runningMtx sync.Mutex
|
status model.RaucStatus
|
||||||
|
runningMtx sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type RaucStatus struct {
|
func New(cmdRaucInstall string) *Rauc {
|
||||||
Installing bool `json:"installing"`
|
return &Rauc{
|
||||||
Percent int `json:"percent"`
|
cmdRaucInstall: cmdRaucInstall,
|
||||||
Message string `json:"message"`
|
|
||||||
LastError string `json:"last_error"`
|
|
||||||
Log string `json:"log"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRauc(broadcast chan string) *Rauc {
|
|
||||||
r := &Rauc{
|
|
||||||
broadcast: broadcast,
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
r.broadcast <- r.GetStatusJson()
|
func (r *Rauc) SetBroadcaster(bc util.Broadcaster) {
|
||||||
|
r.bc = bc
|
||||||
return r
|
r.bcStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Rauc) completed(updateFile string) {
|
func (r *Rauc) completed(updateFile string) {
|
||||||
r.status.Installing = false
|
r.status.Installing = false
|
||||||
r.broadcast <- r.GetStatusJson()
|
r.bcStatus()
|
||||||
|
|
||||||
_ = os.Remove(updateFile)
|
_ = os.Remove(updateFile)
|
||||||
}
|
}
|
||||||
|
@ -65,12 +60,12 @@ func (r *Rauc) RunRauc(updateFile string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset installer
|
// Reset installer
|
||||||
r.status = RaucStatus{
|
r.status = model.RaucStatus{
|
||||||
Installing: true,
|
Installing: true,
|
||||||
}
|
}
|
||||||
r.broadcast <- r.GetStatusJson()
|
r.bcStatus()
|
||||||
|
|
||||||
cmd := util.CommandFromString(fmt.Sprintf("%s %s", util.UpdateCmd, updateFile))
|
cmd := util.CommandFromString(r.cmdRaucInstall + " " + updateFile)
|
||||||
|
|
||||||
readPipe, _ := cmd.StdoutPipe()
|
readPipe, _ := cmd.StdoutPipe()
|
||||||
cmd.Stderr = cmd.Stdout
|
cmd.Stderr = cmd.Stdout
|
||||||
|
@ -100,7 +95,7 @@ func (r *Rauc) RunRauc(updateFile string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasUpdate {
|
if hasUpdate {
|
||||||
r.broadcast <- r.GetStatusJson()
|
r.bcStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
@ -122,11 +117,19 @@ func (r *Rauc) RunRauc(updateFile string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Rauc) GetStatus() RaucStatus {
|
func (r *Rauc) GetStatus() model.RaucStatus {
|
||||||
return r.status
|
return r.status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Rauc) GetStatusJson() string {
|
func (r *Rauc) GetStatusJson() []byte {
|
||||||
statusJson, _ := json.Marshal(r.status)
|
statusJson, err := json.Marshal(r.status)
|
||||||
return string(statusJson)
|
if err != nil {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusJson
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rauc) bcStatus() {
|
||||||
|
r.bc.Broadcast(r.GetStatusJson())
|
||||||
}
|
}
|
||||||
|
|
118
src/rauc/rauc_test.go
Normal file
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,158 +1,266 @@
|
||||||
|
// SEBRAUC
|
||||||
|
//
|
||||||
|
// # REST API for the SEBRAUC firmware updater
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
// Schemes: http, https
|
||||||
|
// Version: 0.2.0
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// swagger:meta
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/config"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
|
||||||
"code.thetadev.de/TSGRain/SEBRAUC/src/rauc"
|
"code.thetadev.de/TSGRain/SEBRAUC/src/rauc"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/server/middleware"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/server/stream"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/server/swagger"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/sysinfo"
|
||||||
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/util/mode"
|
||||||
"code.thetadev.de/TSGRain/SEBRAUC/ui"
|
"code.thetadev.de/TSGRain/SEBRAUC/ui"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
|
||||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
|
||||||
"github.com/gofiber/websocket/v2"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SEBRAUCServer struct {
|
type SEBRAUCServer struct {
|
||||||
address string
|
config *config.Config
|
||||||
raucUpdater *rauc.Rauc
|
streamer *stream.API
|
||||||
hub *MessageHub
|
updater *rauc.Rauc
|
||||||
tmpdir string
|
sysinfo *sysinfo.Sysinfo
|
||||||
|
tmpdir string
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusMessage struct {
|
func NewServer(config *config.Config) *SEBRAUCServer {
|
||||||
Success bool `json:"success"`
|
updater := rauc.New(config.Commands.RaucInstall)
|
||||||
Msg string `json:"msg"`
|
streamer := stream.New(
|
||||||
}
|
time.Duration(config.Server.Websocket.Ping)*time.Second,
|
||||||
|
time.Duration(config.Server.Websocket.Timeout)*time.Second,
|
||||||
|
[]string{},
|
||||||
|
)
|
||||||
|
sysinfo := sysinfo.New(
|
||||||
|
config.Commands.RaucStatus,
|
||||||
|
config.Sysinfo.ReleaseFile,
|
||||||
|
config.Sysinfo.NameKey,
|
||||||
|
config.Sysinfo.VersionKey,
|
||||||
|
config.Sysinfo.HostnameFile,
|
||||||
|
config.Sysinfo.UptimeFile,
|
||||||
|
)
|
||||||
|
|
||||||
func NewServer(address string) *SEBRAUCServer {
|
updater.SetBroadcaster(streamer)
|
||||||
hub := NewHub()
|
|
||||||
|
|
||||||
raucUpdater := rauc.NewRauc(hub.Broadcast)
|
tmpdir := util.GetTmpdir(config.Tmpdir)
|
||||||
|
|
||||||
tmpdir, err := util.GetTmpdir()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SEBRAUCServer{
|
return &SEBRAUCServer{
|
||||||
address: address,
|
config: config,
|
||||||
raucUpdater: raucUpdater,
|
updater: updater,
|
||||||
hub: hub,
|
streamer: streamer,
|
||||||
tmpdir: tmpdir,
|
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 {
|
func (srv *SEBRAUCServer) Run() error {
|
||||||
app := fiber.New(fiber.Config{
|
router := srv.getRouter()
|
||||||
AppName: "SEBRAUC",
|
|
||||||
BodyLimit: 1024 * 1024 * 1024,
|
|
||||||
ErrorHandler: errorHandler,
|
|
||||||
DisableStartupMessage: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
app.Use(logger.New())
|
return router.Run(fmt.Sprintf("%s:%d",
|
||||||
|
srv.config.Server.Address, srv.config.Server.Port))
|
||||||
app.Use(compress.New(compress.Config{
|
|
||||||
Next: func(c *fiber.Ctx) bool {
|
|
||||||
return strings.HasPrefix(c.Path(), "/api")
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// just for testing
|
|
||||||
app.Use("/api", cors.New())
|
|
||||||
|
|
||||||
app.Use("/api/ws", func(c *fiber.Ctx) error {
|
|
||||||
// IsWebSocketUpgrade returns true if the client
|
|
||||||
// requested upgrade to the WebSocket protocol.
|
|
||||||
if websocket.IsWebSocketUpgrade(c) {
|
|
||||||
c.Locals("allowed", true)
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
return fiber.ErrUpgradeRequired
|
|
||||||
})
|
|
||||||
|
|
||||||
app.Use("/", filesystem.New(filesystem.Config{
|
|
||||||
Root: http.FS(ui.Assets),
|
|
||||||
PathPrefix: ui.AssetsDir,
|
|
||||||
MaxAge: 7200,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ROUTES
|
|
||||||
app.Get("/api/ws", websocket.New(srv.hub.Handler))
|
|
||||||
app.Post("/api/update", srv.controllerUpdate)
|
|
||||||
app.Get("/api/status", srv.controllerStatus)
|
|
||||||
app.Post("/api/reboot", srv.controllerReboot)
|
|
||||||
|
|
||||||
// Start messaging hub
|
|
||||||
go srv.hub.Run()
|
|
||||||
|
|
||||||
return app.Listen(srv.address)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
file, err := c.FormFile("updateFile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
c.Error(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uid, err := uuid.NewRandom()
|
uid, err := uuid.NewRandom()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
c.Error(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFile := fmt.Sprintf("%s/update_%s.raucb", srv.tmpdir, uid.String())
|
updateFile := fmt.Sprintf("%s/update_%s.raucb", srv.tmpdir, uid.String())
|
||||||
|
|
||||||
err = c.SaveFile(file, updateFile)
|
err = c.SaveUploadedFile(file, updateFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
c.Error(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = srv.raucUpdater.RunRauc(updateFile)
|
err = srv.updater.RunRauc(updateFile)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
writeStatus(c, true, "Update started")
|
writeStatus(c, true, "Update started")
|
||||||
} else if errors.Is(err, util.ErrAlreadyRunning) {
|
|
||||||
return fiber.NewError(fiber.StatusConflict, "already running")
|
|
||||||
} else {
|
} else {
|
||||||
return err
|
c.Error(err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SEBRAUCServer) controllerStatus(c *fiber.Ctx) error {
|
// swagger:operation GET /status getStatus
|
||||||
c.Context().SetStatusCode(200)
|
//
|
||||||
_ = c.JSON(srv.raucUpdater.GetStatus())
|
// # Get the current status of the RAUC updater
|
||||||
return nil
|
//
|
||||||
|
// ---
|
||||||
|
// produces: [application/json]
|
||||||
|
// responses:
|
||||||
|
//
|
||||||
|
// 200:
|
||||||
|
// description: Ok
|
||||||
|
// schema:
|
||||||
|
// $ref: "#/definitions/RaucStatus"
|
||||||
|
// 500:
|
||||||
|
// description: Server Error
|
||||||
|
// schema:
|
||||||
|
// $ref: "#/definitions/Error"
|
||||||
|
func (srv *SEBRAUCServer) controllerStatus(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, srv.updater.GetStatus())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SEBRAUCServer) controllerReboot(c *fiber.Ctx) error {
|
// swagger:operation GET /info getInfo
|
||||||
go util.Reboot(5 * time.Second)
|
//
|
||||||
|
// # Get the current system info
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
// produces: [application/json]
|
||||||
|
// responses:
|
||||||
|
//
|
||||||
|
// 200:
|
||||||
|
// description: Ok
|
||||||
|
// schema:
|
||||||
|
// $ref: "#/definitions/SystemInfo"
|
||||||
|
// 500:
|
||||||
|
// description: Server Error
|
||||||
|
// schema:
|
||||||
|
// $ref: "#/definitions/Error"
|
||||||
|
func (srv *SEBRAUCServer) controllerInfo(c *gin.Context) {
|
||||||
|
info, err := srv.sysinfo.GetSysinfo()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:operation POST /reboot startReboot
|
||||||
|
//
|
||||||
|
// # Reboot the system
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
// produces: [application/json]
|
||||||
|
// responses:
|
||||||
|
//
|
||||||
|
// 200:
|
||||||
|
// description: Ok
|
||||||
|
// schema:
|
||||||
|
// $ref: "#/definitions/StatusMessage"
|
||||||
|
// 500:
|
||||||
|
// description: Server Error
|
||||||
|
// schema:
|
||||||
|
// $ref: "#/definitions/Error"
|
||||||
|
func (srv *SEBRAUCServer) controllerReboot(c *gin.Context) {
|
||||||
|
go util.Reboot(srv.config.Commands.Reboot, 5*time.Second)
|
||||||
|
|
||||||
writeStatus(c, true, "System is rebooting")
|
writeStatus(c, true, "System is rebooting")
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func errorHandler(c *fiber.Ctx, err error) error {
|
// controllerError throws an error for testing
|
||||||
// API error handling
|
func (srv *SEBRAUCServer) controllerError(c *gin.Context) {
|
||||||
if strings.HasPrefix(c.Path(), "/api") {
|
c.Error(util.HttpErrNew("error test", http.StatusBadRequest))
|
||||||
writeStatus(c, false, err.Error())
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeStatus(c *fiber.Ctx, success bool, msg string) {
|
// controllerPanic panics for testing
|
||||||
_ = c.JSON(statusMessage{
|
func (srv *SEBRAUCServer) controllerPanic(c *gin.Context) {
|
||||||
|
panic(errors.New("panic message"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStatus(c *gin.Context, success bool, msg string) {
|
||||||
|
c.JSON(http.StatusOK, model.StatusMessage{
|
||||||
Success: success,
|
Success: success,
|
||||||
Msg: msg,
|
Msg: msg,
|
||||||
})
|
})
|
||||||
|
|
||||||
if success {
|
|
||||||
c.Context().SetStatusCode(200)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
206
src/server/server_test.go
Normal file
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",
|
||||||
|
)
|
||||||
|
}
|
188
src/sysinfo/sysinfo.go
Normal file
188
src/sysinfo/sysinfo.go
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
package sysinfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Sysinfo struct {
|
||||||
|
cmdRaucStatus string
|
||||||
|
releaseFile string
|
||||||
|
hostnameFile string
|
||||||
|
uptimeFile string
|
||||||
|
rexpName *regexp.Regexp
|
||||||
|
rexpVersion *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
type raucInfo struct {
|
||||||
|
Compatible string `json:"compatible"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Booted string `json:"booted"`
|
||||||
|
BootPrimary string `json:"boot_primary"`
|
||||||
|
Slots []map[string]raucFS `json:"slots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type raucFS struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Bootname string `json:"bootname"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Mountpoint *string `json:"mountpoint"`
|
||||||
|
BootStatus string `json:"boot_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type osRelease struct {
|
||||||
|
OsName string `json:"os_name"`
|
||||||
|
OsVersion string `json:"os_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var rexpUptime = regexp.MustCompile(`^\d+`)
|
||||||
|
|
||||||
|
func New(cmdRaucStatus string, releaseFile string, nameKey string, versionKey string,
|
||||||
|
hostnameFile string, uptimeFile string,
|
||||||
|
) *Sysinfo {
|
||||||
|
return &Sysinfo{
|
||||||
|
cmdRaucStatus: cmdRaucStatus,
|
||||||
|
releaseFile: releaseFile,
|
||||||
|
hostnameFile: hostnameFile,
|
||||||
|
uptimeFile: uptimeFile,
|
||||||
|
rexpName: regexp.MustCompile(
|
||||||
|
`(?m)^` + regexp.QuoteMeta(nameKey) + `="(.+)"`),
|
||||||
|
rexpVersion: regexp.MustCompile(
|
||||||
|
`(?m)^` + regexp.QuoteMeta(versionKey) + `="(.+)"`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Default(cmdRaucStatus string) *Sysinfo {
|
||||||
|
return New(cmdRaucStatus, "/etc/os-release", "NAME", "VERSION",
|
||||||
|
"/etc/hostname", "/proc/uptime")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRaucInfo(raucInfoJson []byte) (raucInfo, error) {
|
||||||
|
res := raucInfo{}
|
||||||
|
err := json.Unmarshal(raucInfoJson, &res)
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sysinfo) parseOsRelease() (osRelease, error) {
|
||||||
|
osReleaseTxt, err := os.ReadFile(s.releaseFile)
|
||||||
|
if err != nil {
|
||||||
|
return osRelease{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nameMatch := s.rexpName.FindSubmatch(osReleaseTxt)
|
||||||
|
versionMatch := s.rexpVersion.FindSubmatch(osReleaseTxt)
|
||||||
|
|
||||||
|
name := ""
|
||||||
|
if nameMatch != nil {
|
||||||
|
name = string(nameMatch[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
version := ""
|
||||||
|
if versionMatch != nil {
|
||||||
|
version = string(versionMatch[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return osRelease{
|
||||||
|
OsName: name,
|
||||||
|
OsVersion: version,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRootfs(rinf raucInfo) map[string]model.Rootfs {
|
||||||
|
res := make(map[string]model.Rootfs)
|
||||||
|
|
||||||
|
for _, slot := range rinf.Slots {
|
||||||
|
for name, fs := range slot {
|
||||||
|
if fs.Class == "rootfs" {
|
||||||
|
res[name] = model.Rootfs{
|
||||||
|
Device: fs.Device,
|
||||||
|
Type: fs.Type,
|
||||||
|
Bootname: fs.Bootname,
|
||||||
|
Mountpoint: fs.Mountpoint,
|
||||||
|
Bootable: fs.BootStatus == "good",
|
||||||
|
Booted: fs.State == "booted",
|
||||||
|
Primary: rinf.BootPrimary == name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFSNameFromBootname(rfslist map[string]model.Rootfs, bootname string) string {
|
||||||
|
for name, rfs := range rfslist {
|
||||||
|
if rfs.Bootname == bootname {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "n/a"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapSysinfo(rinf raucInfo, osr osRelease, uptime int,
|
||||||
|
hostname string,
|
||||||
|
) model.SystemInfo {
|
||||||
|
rfslist := mapRootfs(rinf)
|
||||||
|
|
||||||
|
return model.SystemInfo{
|
||||||
|
Hostname: hostname,
|
||||||
|
OsName: osr.OsName,
|
||||||
|
OsVersion: osr.OsVersion,
|
||||||
|
Uptime: uptime,
|
||||||
|
RaucCompatible: rinf.Compatible,
|
||||||
|
RaucVariant: rinf.Variant,
|
||||||
|
RaucRootfs: rfslist,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sysinfo) getUptime() (int, error) {
|
||||||
|
uptimeRaw, err := os.ReadFile(s.uptimeFile)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
uptimeChars := rexpUptime.Find(uptimeRaw)
|
||||||
|
return strconv.Atoi(string(uptimeChars))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sysinfo) getHostname() string {
|
||||||
|
hostname, err := os.ReadFile(s.hostnameFile)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(hostname))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sysinfo) GetSysinfo() (model.SystemInfo, error) {
|
||||||
|
cmd := util.CommandFromString(s.cmdRaucStatus)
|
||||||
|
rinfJson, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return model.SystemInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rinf, err := parseRaucInfo(rinfJson)
|
||||||
|
if err != nil {
|
||||||
|
return model.SystemInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
osinf, err := s.parseOsRelease()
|
||||||
|
if err != nil {
|
||||||
|
return model.SystemInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
uptime, err := s.getUptime()
|
||||||
|
if err != nil {
|
||||||
|
return model.SystemInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := s.getHostname()
|
||||||
|
|
||||||
|
return mapSysinfo(rinf, osinf, uptime, hostname), nil
|
||||||
|
}
|
129
src/sysinfo/sysinfo_test.go
Normal file
129
src/sysinfo/sysinfo_test.go
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
package sysinfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` +
|
||||||
|
`"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` +
|
||||||
|
`"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` +
|
||||||
|
`"parent":null,"mountpoint":null,"boot_status":"good"}},{"rootfs.0":` +
|
||||||
|
`{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` +
|
||||||
|
`"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}`
|
||||||
|
|
||||||
|
var mountRoot = "/"
|
||||||
|
|
||||||
|
var expectedRaucInfo = raucInfo{
|
||||||
|
Compatible: "TSGRain",
|
||||||
|
Variant: "dev",
|
||||||
|
Booted: "A",
|
||||||
|
BootPrimary: "rootfs.0",
|
||||||
|
Slots: []map[string]raucFS{
|
||||||
|
{
|
||||||
|
"rootfs.1": {
|
||||||
|
Class: "rootfs",
|
||||||
|
Device: "/dev/mmcblk0p3",
|
||||||
|
Type: "ext4",
|
||||||
|
Bootname: "B",
|
||||||
|
State: "inactive",
|
||||||
|
Mountpoint: nil,
|
||||||
|
BootStatus: "good",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rootfs.0": {
|
||||||
|
Class: "rootfs",
|
||||||
|
Device: "/dev/mmcblk0p2",
|
||||||
|
Type: "ext4",
|
||||||
|
Bootname: "A",
|
||||||
|
State: "booted",
|
||||||
|
Mountpoint: &mountRoot,
|
||||||
|
BootStatus: "good",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedRootfsList = map[string]model.Rootfs{
|
||||||
|
"rootfs.0": {
|
||||||
|
Device: "/dev/mmcblk0p2",
|
||||||
|
Type: "ext4",
|
||||||
|
Bootname: "A",
|
||||||
|
Mountpoint: &mountRoot,
|
||||||
|
Bootable: true,
|
||||||
|
Booted: true,
|
||||||
|
Primary: true,
|
||||||
|
},
|
||||||
|
"rootfs.1": {
|
||||||
|
Device: "/dev/mmcblk0p3",
|
||||||
|
Type: "ext4",
|
||||||
|
Bootname: "B",
|
||||||
|
Mountpoint: nil,
|
||||||
|
Bootable: true,
|
||||||
|
Booted: false,
|
||||||
|
Primary: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRaucInfo(t *testing.T) {
|
||||||
|
info, err := parseRaucInfo([]byte(statusJson))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expectedRaucInfo, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOsRelease(t *testing.T) {
|
||||||
|
testfiles := fixtures.GetTestfilesDir()
|
||||||
|
osReleaseFile := filepath.Join(testfiles, "os-release")
|
||||||
|
|
||||||
|
si := New(testcmd.RaucStatus, osReleaseFile, "NAME", "VERSION",
|
||||||
|
"/etc/hostname", "/proc/uptime")
|
||||||
|
|
||||||
|
osRel, err := si.parseOsRelease()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := osRelease{
|
||||||
|
OsName: "TSGRain distro",
|
||||||
|
OsVersion: "0.0.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expected, osRel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapRootfsList(t *testing.T) {
|
||||||
|
rootfsList := mapRootfs(expectedRaucInfo)
|
||||||
|
|
||||||
|
assert.Equal(t, expectedRootfsList, rootfsList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFSNameFromBootname(t *testing.T) {
|
||||||
|
rootfsList := mapRootfs(expectedRaucInfo)
|
||||||
|
|
||||||
|
assert.Equal(t, "rootfs.0", getFSNameFromBootname(rootfsList, "A"))
|
||||||
|
assert.Equal(t, "rootfs.1", getFSNameFromBootname(rootfsList, "B"))
|
||||||
|
assert.Equal(t, "n/a", getFSNameFromBootname(rootfsList, "C"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSysinfo(t *testing.T) {
|
||||||
|
si := Default(testcmd.RaucStatus)
|
||||||
|
|
||||||
|
sysinfo, err := si.GetSysinfo()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Greater(t, sysinfo.Uptime, 0)
|
||||||
|
assert.Equal(t, "TSGRain", sysinfo.RaucCompatible)
|
||||||
|
assert.Equal(t, "dev", sysinfo.RaucVariant)
|
||||||
|
assert.Equal(t, expectedRootfsList, sysinfo.RaucRootfs)
|
||||||
|
}
|
|
@ -1,11 +0,0 @@
|
||||||
//go:build prod
|
|
||||||
// +build prod
|
|
||||||
|
|
||||||
package util
|
|
||||||
|
|
||||||
const (
|
|
||||||
RebootCmd = "shutdown -r 0"
|
|
||||||
UpdateCmd = "rauc install"
|
|
||||||
|
|
||||||
TestMode = false
|
|
||||||
)
|
|
|
@ -1,11 +0,0 @@
|
||||||
//go:build !prod
|
|
||||||
// +build !prod
|
|
||||||
|
|
||||||
package util
|
|
||||||
|
|
||||||
const (
|
|
||||||
RebootCmd = "touch /tmp/sebrauc_reboot_test"
|
|
||||||
UpdateCmd = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock"
|
|
||||||
|
|
||||||
TestMode = true
|
|
||||||
)
|
|
30
src/util/counter.go
Normal file
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
|
package util
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrAlreadyRunning = errors.New("rauc already running")
|
ErrAlreadyRunning = HttpErrNew("rauc already running", http.StatusConflict)
|
||||||
ErrFileDoesNotExist = errors.New("file does not exist")
|
ErrFileDoesNotExist = errors.New("file does not exist")
|
||||||
|
ErrPageNotFound = HttpErrNew("page not found", http.StatusNotFound)
|
||||||
|
ErrUnauthorized = HttpErrNew("unauthorized", http.StatusUnauthorized)
|
||||||
)
|
)
|
||||||
|
|
39
src/util/http_error.go
Normal file
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
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -25,10 +26,24 @@ func CreateDirIfNotExists(dirpath string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTmpdir() (string, error) {
|
func GetTmpdir(tmpdirPath string) string {
|
||||||
tmpdir := filepath.Join(os.TempDir(), tmpdirName)
|
tmpdir := tmpdirPath
|
||||||
|
// Default temporary directory
|
||||||
|
if tmpdirPath == "" {
|
||||||
|
tmpdir = filepath.Join(os.TempDir(), tmpdirName)
|
||||||
|
}
|
||||||
|
|
||||||
err := CreateDirIfNotExists(tmpdir)
|
err := CreateDirIfNotExists(tmpdir)
|
||||||
return tmpdir, err
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("could not create tmpdir %s: %s", tmpdir, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpdir
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveTmpdir(tmpdirPath string) {
|
||||||
|
tmpdir := GetTmpdir(tmpdirPath)
|
||||||
|
_ = os.RemoveAll(tmpdir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CommandFromString(cmdString string) *exec.Cmd {
|
func CommandFromString(cmdString string) *exec.Cmd {
|
||||||
|
@ -41,8 +56,41 @@ func CommandFromString(cmdString string) *exec.Cmd {
|
||||||
return exec.Command(parts[0], parts[1:]...)
|
return exec.Command(parts[0], parts[1:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Reboot(t time.Duration) {
|
func Reboot(rebootCmd string, t time.Duration) {
|
||||||
time.Sleep(t)
|
time.Sleep(t)
|
||||||
cmd := CommandFromString(RebootCmd)
|
cmd := CommandFromString(rebootCmd)
|
||||||
_ = cmd.Run()
|
_ = cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FindFile(explicitPath string, locations, endings []string) (string, error) {
|
||||||
|
if explicitPath != "" {
|
||||||
|
if !DoesFileExist(explicitPath) {
|
||||||
|
return "", fmt.Errorf("file %s not found", explicitPath)
|
||||||
|
}
|
||||||
|
return explicitPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
notFound := []string{}
|
||||||
|
|
||||||
|
for _, f := range locations {
|
||||||
|
if endings != nil {
|
||||||
|
for _, t := range endings {
|
||||||
|
fpath := f + "." + t
|
||||||
|
|
||||||
|
if DoesFileExist(fpath) {
|
||||||
|
return fpath, nil
|
||||||
|
} else {
|
||||||
|
notFound = append(notFound, fpath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if DoesFileExist(f) {
|
||||||
|
return f, nil
|
||||||
|
} else {
|
||||||
|
notFound = append(notFound, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("none of the following files found: %s",
|
||||||
|
strings.Join(notFound, "; "))
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
|
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/testcmd"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -41,27 +43,46 @@ func TestDoesFileExist(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTmpdir(t *testing.T) {
|
func TestTmpdir(t *testing.T) {
|
||||||
td, err := GetTmpdir()
|
tests := []struct {
|
||||||
if err != nil {
|
name string
|
||||||
panic(err)
|
path string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
path: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom",
|
||||||
|
path: filepath.Join(os.TempDir(), "customTmpdir"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tfile := filepath.Join(td, "test.txt")
|
for _, tt := range tests {
|
||||||
f, err := os.Create(tfile)
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err != nil {
|
td := GetTmpdir(tt.path)
|
||||||
panic(err)
|
assert.DirExists(t, td)
|
||||||
}
|
|
||||||
|
|
||||||
_, err = f.WriteString("Hello")
|
tfile := filepath.Join(td, "test.txt")
|
||||||
if err != nil {
|
f, err := os.Create(tfile)
|
||||||
panic(err)
|
if err != nil {
|
||||||
}
|
panic(err)
|
||||||
err = f.Close()
|
}
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.FileExists(t, tfile)
|
_, err = f.WriteString("Hello")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.FileExists(t, tfile)
|
||||||
|
|
||||||
|
RemoveTmpdir(tt.path)
|
||||||
|
assert.NoDirExists(t, td)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommandFromString(t *testing.T) {
|
func TestCommandFromString(t *testing.T) {
|
||||||
|
@ -103,7 +124,78 @@ func TestReboot(t *testing.T) {
|
||||||
testfile := "/tmp/sebrauc_reboot_test"
|
testfile := "/tmp/sebrauc_reboot_test"
|
||||||
_ = os.Remove(testfile)
|
_ = os.Remove(testfile)
|
||||||
|
|
||||||
Reboot(0)
|
Reboot(testcmd.Reboot, 0)
|
||||||
|
|
||||||
assert.FileExists(t, testfile)
|
assert.FileExists(t, testfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindFile(t *testing.T) {
|
||||||
|
testfiles := fixtures.GetTestfilesDir()
|
||||||
|
|
||||||
|
//nolint:lll
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
explicitPath string
|
||||||
|
locations []string
|
||||||
|
endings []string
|
||||||
|
expect string
|
||||||
|
expectErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "locations",
|
||||||
|
explicitPath: "",
|
||||||
|
locations: []string{filepath.Join(testfiles, "sebrauc")},
|
||||||
|
endings: []string{"yml", "toml"},
|
||||||
|
expect: filepath.Join(testfiles, "sebrauc.toml"),
|
||||||
|
expectErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "locations_nf",
|
||||||
|
explicitPath: "",
|
||||||
|
locations: []string{filepath.Join(testfiles, "banana")},
|
||||||
|
endings: []string{"yml", "toml"},
|
||||||
|
expect: "",
|
||||||
|
expectErr: errors.New("none of the following files found: src/fixtures/testfiles/banana.yml; src/fixtures/testfiles/banana.toml"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_endings",
|
||||||
|
explicitPath: "",
|
||||||
|
locations: []string{filepath.Join(testfiles, "banana"), filepath.Join(testfiles, "os-release")},
|
||||||
|
endings: nil,
|
||||||
|
expect: filepath.Join(testfiles, "os-release"),
|
||||||
|
expectErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_endings_nf",
|
||||||
|
explicitPath: "",
|
||||||
|
locations: []string{filepath.Join(testfiles, "banana"), filepath.Join(testfiles, "apple")},
|
||||||
|
endings: nil,
|
||||||
|
expect: "",
|
||||||
|
expectErr: errors.New("none of the following files found: src/fixtures/testfiles/banana; src/fixtures/testfiles/apple"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit",
|
||||||
|
explicitPath: filepath.Join(testfiles, "sebrauc.toml"),
|
||||||
|
locations: []string{filepath.Join(testfiles, "banana")},
|
||||||
|
endings: []string{"yml", "toml"},
|
||||||
|
expect: filepath.Join(testfiles, "sebrauc.toml"),
|
||||||
|
expectErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit_nf",
|
||||||
|
explicitPath: filepath.Join(testfiles, "banana.toml"),
|
||||||
|
locations: []string{filepath.Join(testfiles, "banana")},
|
||||||
|
endings: []string{"yml", "toml"},
|
||||||
|
expect: "",
|
||||||
|
expectErr: errors.New("file src/fixtures/testfiles/banana.toml not found"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fpath, err := FindFile(tt.explicitPath, tt.locations, tt.endings)
|
||||||
|
assert.Equal(t, tt.expectErr, err)
|
||||||
|
assert.Equal(t, tt.expect, fpath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
VITE_VERSION=dev
|
|
||||||
VITE_API_HOST=127.0.0.1:8080
|
VITE_API_HOST=127.0.0.1:8080
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
tmp
|
||||||
|
.tmp
|
|
@ -7,7 +7,11 @@
|
||||||
<title>SEBRAUC</title>
|
<title>SEBRAUC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<noscript>You have to enable JavaScript to use SEBRAUC.</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script>
|
||||||
|
window.config = "%CONFIG%"
|
||||||
|
</script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -4,7 +4,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"serve": "vite preview"
|
"serve": "vite preview",
|
||||||
|
"lint": "tsc",
|
||||||
|
"format": "prettier --write ../"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^6.5.95",
|
"@mdi/js": "^6.5.95",
|
||||||
|
|
4
ui/pnpm-lock.yaml
generated
4
ui/pnpm-lock.yaml
generated
|
@ -1,4 +1,4 @@
|
||||||
lockfileVersion: 5.3
|
lockfileVersion: 5.4
|
||||||
|
|
||||||
specifiers:
|
specifiers:
|
||||||
"@mdi/js": ^6.5.95
|
"@mdi/js": ^6.5.95
|
||||||
|
@ -282,6 +282,8 @@ packages:
|
||||||
}
|
}
|
||||||
engines: {node: ">=6.0.0"}
|
engines: {node: ">=6.0.0"}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
"@babel/types": 7.16.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@babel/plugin-syntax-jsx/7.16.0:
|
/@babel/plugin-syntax-jsx/7.16.0:
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {Component} from "preact"
|
import {Component} from "preact"
|
||||||
import {mdiTriangleOutline} from "@mdi/js"
|
import {mdiTriangleOutline} from "@mdi/js"
|
||||||
import Icon from "../Icon/Icon"
|
import Icon from "../Icon/Icon"
|
||||||
|
import colors from "../../util/colors"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
source?: string
|
source?: string
|
||||||
|
@ -20,7 +21,7 @@ export default class Alert extends Component<Props> {
|
||||||
return (
|
return (
|
||||||
<div class="alert">
|
<div class="alert">
|
||||||
<span>
|
<span>
|
||||||
<Icon icon={mdiTriangleOutline} color="#FF0039" />
|
<Icon icon={mdiTriangleOutline} color={colors.RED} />
|
||||||
{msg}
|
{msg}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
|
@ -1,7 +1,6 @@
|
||||||
import {mdiCheckCircleOutline, mdiRestore} from "@mdi/js"
|
import {mdiCheckCircleOutline, mdiRestore} from "@mdi/js"
|
||||||
import axios, {AxiosError, AxiosResponse} from "axios"
|
|
||||||
import {Component} from "preact"
|
import {Component} from "preact"
|
||||||
import {apiUrl} from "../../util/apiUrls"
|
import {sebraucApi} from "../../util/apiUrls"
|
||||||
import Icon from "../Icon/Icon"
|
import Icon from "../Icon/Icon"
|
||||||
|
|
||||||
export default class Reboot extends Component {
|
export default class Reboot extends Component {
|
||||||
|
@ -9,9 +8,9 @@ export default class Reboot extends Component {
|
||||||
const res = confirm("Reboot the system?")
|
const res = confirm("Reboot the system?")
|
||||||
if (!res) return
|
if (!res) return
|
||||||
|
|
||||||
axios
|
sebraucApi
|
||||||
.post(apiUrl + "/reboot")
|
.startReboot()
|
||||||
.then((response: AxiosResponse) => {
|
.then((response) => {
|
||||||
const msg = response.data.msg
|
const msg = response.data.msg
|
||||||
|
|
||||||
if (msg !== undefined) {
|
if (msg !== undefined) {
|
||||||
|
@ -20,7 +19,7 @@ export default class Reboot extends Component {
|
||||||
alert("No response")
|
alert("No response")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: AxiosError) => {
|
.catch((error) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const msg = error.response.data.msg
|
const msg = error.response.data.msg
|
||||||
|
|
166
ui/src/components/Updater/SysinfoCard.tsx
Normal file
166
ui/src/components/Updater/SysinfoCard.tsx
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
import {Component} from "preact"
|
||||||
|
import {SystemInfo} from "../../sebrauc-client"
|
||||||
|
import {sebraucApi} from "../../util/apiUrls"
|
||||||
|
import {secondsToString} from "../../util/functions"
|
||||||
|
import Icon from "../Icon/Icon"
|
||||||
|
import {
|
||||||
|
mdiAlphaVCircleOutline,
|
||||||
|
mdiCheckCircleOutline,
|
||||||
|
mdiCircleOutline,
|
||||||
|
mdiClockOutline,
|
||||||
|
mdiCloseCircleOutline,
|
||||||
|
mdiMonitor,
|
||||||
|
mdiPenguin,
|
||||||
|
mdiTagMultipleOutline,
|
||||||
|
mdiTagOutline,
|
||||||
|
} from "@mdi/js"
|
||||||
|
import colors from "../../util/colors"
|
||||||
|
|
||||||
|
type Props = {}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
sysinfo: SystemInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SysinfoCard extends Component<Props, State> {
|
||||||
|
private fetchTimeout: number | undefined
|
||||||
|
|
||||||
|
constructor(props?: Props | undefined, context?: any) {
|
||||||
|
super(props, context)
|
||||||
|
this.fetchInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchInfo = () => {
|
||||||
|
sebraucApi
|
||||||
|
.getInfo()
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status == 200) {
|
||||||
|
this.setState({sysinfo: response.data})
|
||||||
|
} else {
|
||||||
|
console.log("error fetching info", response.data)
|
||||||
|
this.fetchTimeout = window.setTimeout(this.fetchInfo, 3000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
console.log("error fetching info", reason)
|
||||||
|
this.fetchTimeout = window.setTimeout(this.fetchInfo, 3000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSysinfo() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card">
|
||||||
|
<p class="top">System information</p>
|
||||||
|
<table class="table no-bottom-border">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Icon icon={mdiMonitor} /> Hostname
|
||||||
|
</td>
|
||||||
|
<td>{this.state.sysinfo.hostname}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Icon icon={mdiPenguin} /> Operating system
|
||||||
|
</td>
|
||||||
|
<td>{this.state.sysinfo.os_name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Icon icon={mdiAlphaVCircleOutline} /> OS version
|
||||||
|
</td>
|
||||||
|
<td>{this.state.sysinfo.os_version}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Icon icon={mdiClockOutline} /> Uptime
|
||||||
|
</td>
|
||||||
|
<td>{secondsToString(this.state.sysinfo.uptime)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Icon icon={mdiTagOutline} /> Compatible FW
|
||||||
|
</td>
|
||||||
|
<td>{this.state.sysinfo.rauc_compatible}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Icon icon={mdiTagMultipleOutline} /> Compatible FW
|
||||||
|
variant
|
||||||
|
</td>
|
||||||
|
<td>{this.state.sysinfo.rauc_variant}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<p class="top">Rootfs slots</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="table no-bottom-border">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Mountpoint</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.keys(this.state.sysinfo.rauc_rootfs).map(
|
||||||
|
(k, i) => {
|
||||||
|
const rfs = this.state.sysinfo.rauc_rootfs[k]
|
||||||
|
let icon = mdiCircleOutline
|
||||||
|
let iconColor = colors.BLUE
|
||||||
|
|
||||||
|
if (!rfs.bootable) {
|
||||||
|
icon = mdiCloseCircleOutline
|
||||||
|
iconColor = colors.RED
|
||||||
|
} else if (rfs.primary) {
|
||||||
|
icon = mdiCheckCircleOutline
|
||||||
|
iconColor = colors.GREEN
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<Icon
|
||||||
|
icon={icon}
|
||||||
|
color={iconColor}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{k}</td>
|
||||||
|
<td>{rfs.device}</td>
|
||||||
|
<td>{rfs.mountpoint}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLoadingAnimation() {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<p>loading sysinfo...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.fetchTimeout !== undefined) {
|
||||||
|
window.clearTimeout(this.fetchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.sysinfo) {
|
||||||
|
return this.renderSysinfo()
|
||||||
|
}
|
||||||
|
return this.renderLoadingAnimation()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
.uploader {
|
.updater-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 500px;
|
max-width: 600px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
|
||||||
> * {
|
> * {
|
||||||
|
@ -18,8 +18,8 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
padding: 15px 8px;
|
margin-top: 25px;
|
||||||
margin: 8px 0;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
@ -29,6 +29,14 @@
|
||||||
.top {
|
.top {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.pad {
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
|
@ -42,3 +50,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-top-right {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
|
@ -5,10 +5,11 @@ import Dropzone from "../Dropzone/Dropzone"
|
||||||
import ProgressCircle from "../ProgressCircle/ProgressCircle"
|
import ProgressCircle from "../ProgressCircle/ProgressCircle"
|
||||||
import Icon from "../Icon/Icon"
|
import Icon from "../Icon/Icon"
|
||||||
import "./Updater.scss"
|
import "./Updater.scss"
|
||||||
import axios from "axios"
|
|
||||||
import Alert from "./Alert"
|
import Alert from "./Alert"
|
||||||
import Reboot from "./Reboot"
|
import Reboot from "./Reboot"
|
||||||
import {apiUrl, wsUrl} from "../../util/apiUrls"
|
import {sebraucApi} from "../../util/apiUrls"
|
||||||
|
import colors from "../../util/colors"
|
||||||
|
import WebsocketClient from "../../util/websocket"
|
||||||
|
|
||||||
class UploadStatus {
|
class UploadStatus {
|
||||||
uploading = false
|
uploading = false
|
||||||
|
@ -50,21 +51,21 @@ type State = {
|
||||||
wsConnected: boolean
|
wsConnected: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Updater extends Component<Props, State> {
|
export default class UpdaterCard extends Component<Props, State> {
|
||||||
private dropzoneRef = createRef<Dropzone>()
|
private dropzoneRef = createRef<Dropzone>()
|
||||||
private conn: WebSocket | undefined
|
private ws: WebsocketClient
|
||||||
|
|
||||||
constructor(props?: Props | undefined, context?: any) {
|
constructor(props?: Props | undefined, context?: any) {
|
||||||
super(props, context)
|
super(props, context)
|
||||||
|
|
||||||
|
this.ws = new WebsocketClient(this.onWsStatusUpdate, this.onWsMessage)
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
uploadStatus: new UploadStatus(false),
|
uploadStatus: new UploadStatus(false),
|
||||||
uploadFilename: "",
|
uploadFilename: "",
|
||||||
raucStatus: new RaucStatus(),
|
raucStatus: new RaucStatus(),
|
||||||
wsConnected: false,
|
wsConnected: this.ws.api().isConnected(),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connectWebsocket()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buttonClick = () => {
|
private buttonClick = () => {
|
||||||
|
@ -77,19 +78,13 @@ export default class Updater extends Component<Props, State> {
|
||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
const newFile = files[0]
|
const newFile = files[0]
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("updateFile", newFile)
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
uploadStatus: new UploadStatus(true, newFile.size, 0),
|
uploadStatus: new UploadStatus(true, newFile.size, 0),
|
||||||
uploadFilename: newFile.name,
|
uploadFilename: newFile.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
axios
|
sebraucApi
|
||||||
.post(apiUrl + "/update", formData, {
|
.startUpdate(newFile, {
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
onUploadProgress: (progressEvent: {loaded: number; total: number}) => {
|
onUploadProgress: (progressEvent: {loaded: number; total: number}) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
uploadStatus: UploadStatus.fromProgressEvent(progressEvent),
|
uploadStatus: UploadStatus.fromProgressEvent(progressEvent),
|
||||||
|
@ -111,33 +106,16 @@ export default class Updater extends Component<Props, State> {
|
||||||
this.dropzoneRef.current?.reset()
|
this.dropzoneRef.current?.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
private connectWebsocket = () => {
|
private onWsStatusUpdate = (wsConnected: boolean) => {
|
||||||
if (window.WebSocket) {
|
this.setState({wsConnected: wsConnected})
|
||||||
this.conn = new WebSocket(wsUrl)
|
}
|
||||||
this.conn.onopen = () => {
|
|
||||||
this.setState({wsConnected: true})
|
|
||||||
console.log("WS connected")
|
|
||||||
}
|
|
||||||
this.conn.onclose = () => {
|
|
||||||
this.setState({wsConnected: false})
|
|
||||||
console.log("WS connection closed")
|
|
||||||
window.setTimeout(this.connectWebsocket, 3000)
|
|
||||||
}
|
|
||||||
this.conn.onmessage = (evt) => {
|
|
||||||
var messages = evt.data.split("\n")
|
|
||||||
for (var i = 0; i < messages.length; i++) {
|
|
||||||
this.setState({
|
|
||||||
raucStatus: Object.assign(
|
|
||||||
new RaucStatus(),
|
|
||||||
JSON.parse(messages[i])
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(this.state.raucStatus)
|
private onWsMessage = (evt: MessageEvent) => {
|
||||||
}
|
var messages = evt.data.split("\n")
|
||||||
}
|
for (var i = 0; i < messages.length; i++) {
|
||||||
} else {
|
this.setState({
|
||||||
console.log("Your browser does not support WebSockets")
|
raucStatus: Object.assign(new RaucStatus(), JSON.parse(messages[i])),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,9 +141,9 @@ export default class Updater extends Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private circleColor(): string {
|
private circleColor(): string {
|
||||||
if (this.state.raucStatus.installing) return "#FF0039"
|
if (this.state.raucStatus.installing) return colors.RED
|
||||||
if (this.state.uploadStatus.uploading) return "#148420"
|
if (this.state.uploadStatus.uploading) return colors.GREEN
|
||||||
return "#1f85de"
|
return colors.BLUE
|
||||||
}
|
}
|
||||||
|
|
||||||
private circlePercentage(): number {
|
private circlePercentage(): number {
|
||||||
|
@ -175,6 +153,10 @@ export default class Updater extends Component<Props, State> {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.ws.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const acceptUploads = this.acceptUploads()
|
const acceptUploads = this.acceptUploads()
|
||||||
const circleColor = this.circleColor()
|
const circleColor = this.circleColor()
|
||||||
|
@ -195,12 +177,13 @@ export default class Updater extends Component<Props, State> {
|
||||||
topText = "Updating firmware"
|
topText = "Updating firmware"
|
||||||
bottomText = this.state.raucStatus.message
|
bottomText = this.state.raucStatus.message
|
||||||
} else {
|
} else {
|
||||||
topText = "Upload firmware package"
|
topText = "Firmware update"
|
||||||
|
bottomText = "Upload *.raucb FW package"
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="uploader">
|
<div>
|
||||||
<div class="card upload">
|
<div class="card pad">
|
||||||
<div>
|
<div>
|
||||||
<p class="top">{topText}</p>
|
<p class="top">{topText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -215,7 +198,7 @@ export default class Updater extends Component<Props, State> {
|
||||||
progress={circlePercentage}
|
progress={circlePercentage}
|
||||||
color={circleColor}
|
color={circleColor}
|
||||||
>
|
>
|
||||||
<button onClick={this.buttonClick}>
|
<button onClick={this.buttonClick} aria-label="Upload">
|
||||||
<Icon icon={mdiUpload} size={50} />
|
<Icon icon={mdiUpload} size={50} />
|
||||||
</button>
|
</button>
|
||||||
</ProgressCircle>
|
</ProgressCircle>
|
48
ui/src/components/Updater/UpdaterView.tsx
Normal file
48
ui/src/components/Updater/UpdaterView.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import {mdiInformation, mdiUpload} from "@mdi/js"
|
||||||
|
import {Component} from "preact"
|
||||||
|
import Icon from "../Icon/Icon"
|
||||||
|
import SysinfoCard from "./SysinfoCard"
|
||||||
|
import UpdaterCard from "./UpdaterCard"
|
||||||
|
import "./Updater.scss"
|
||||||
|
|
||||||
|
type Props = {}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
flipped: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class UpdaterView extends Component<Props, State> {
|
||||||
|
constructor(props?: Props | undefined, context?: any) {
|
||||||
|
super(props, context)
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
flipped: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private flipCard = () => {
|
||||||
|
this.setState({flipped: !this.state.flipped})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="iconButton button-top-right"
|
||||||
|
onClick={this.flipCard}
|
||||||
|
aria-label={
|
||||||
|
this.state.flipped
|
||||||
|
? "Switch to updater"
|
||||||
|
: "Switch to system info"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon icon={this.state.flipped ? mdiUpload : mdiInformation} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="updater-view">
|
||||||
|
{!this.state.flipped ? <UpdaterCard /> : <SysinfoCard />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,15 @@
|
||||||
import {Component} from "preact"
|
import {Component} from "preact"
|
||||||
import Updater from "./Upload/Updater"
|
import UpdaterView from "./Updater/UpdaterView"
|
||||||
import logo from "../assets/logo.svg"
|
import logo from "../assets/logo.svg"
|
||||||
import {version} from "../util/version"
|
import {getConfig} from "../util/config"
|
||||||
|
|
||||||
export default class App extends Component {
|
export default class App extends Component {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<img src={logo} height="64" />
|
<img src={logo} alt="SEBRAUC" height="64" />
|
||||||
{version}
|
{getConfig().version}
|
||||||
<Updater />
|
<UpdaterView />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
27
ui/src/sebrauc-client/.openapi-generator-ignore
Normal file
27
ui/src/sebrauc-client/.openapi-generator-ignore
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# OpenAPI Generator Ignore
|
||||||
|
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
|
||||||
|
|
||||||
|
# Use this file to prevent files from being overwritten by the generator.
|
||||||
|
# The patterns follow closely to .gitignore or .dockerignore.
|
||||||
|
|
||||||
|
# As an example, the C# client generator defines ApiClient.cs.
|
||||||
|
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
|
||||||
|
#ApiClient.cs
|
||||||
|
|
||||||
|
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
|
||||||
|
#foo/*/qux
|
||||||
|
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
|
||||||
|
|
||||||
|
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
|
||||||
|
#foo/**/qux
|
||||||
|
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
|
||||||
|
|
||||||
|
# You can also negate patterns with an exclamation (!).
|
||||||
|
# For example, you can ignore all files in a docs folder with the file extension .md:
|
||||||
|
#docs/*.md
|
||||||
|
# Then explicitly reverse the ignore rule for a single file:
|
||||||
|
#!docs/README.md
|
||||||
|
|
||||||
|
/.gitignore
|
||||||
|
/.npmignore
|
||||||
|
/git_push.sh
|
5
ui/src/sebrauc-client/.openapi-generator/FILES
Normal file
5
ui/src/sebrauc-client/.openapi-generator/FILES
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
api.ts
|
||||||
|
base.ts
|
||||||
|
common.ts
|
||||||
|
configuration.ts
|
||||||
|
index.ts
|
1
ui/src/sebrauc-client/.openapi-generator/VERSION
Normal file
1
ui/src/sebrauc-client/.openapi-generator/VERSION
Normal file
|
@ -0,0 +1 @@
|
||||||
|
5.3.0
|
566
ui/src/sebrauc-client/api.ts
Normal file
566
ui/src/sebrauc-client/api.ts
Normal file
|
@ -0,0 +1,566 @@
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* SEBRAUC
|
||||||
|
* REST API for the SEBRAUC firmware updater
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: 0.2.0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Configuration} from "./configuration"
|
||||||
|
import globalAxios, {AxiosPromise, AxiosInstance, AxiosRequestConfig} from "axios"
|
||||||
|
// Some imports not used depending on template conditions
|
||||||
|
// @ts-ignore
|
||||||
|
import {
|
||||||
|
DUMMY_BASE_URL,
|
||||||
|
assertParamExists,
|
||||||
|
setApiKeyToObject,
|
||||||
|
setBasicAuthToObject,
|
||||||
|
setBearerAuthToObject,
|
||||||
|
setOAuthToObject,
|
||||||
|
setSearchParams,
|
||||||
|
serializeDataIfNeeded,
|
||||||
|
toPathString,
|
||||||
|
createRequestFunction,
|
||||||
|
} from "./common"
|
||||||
|
// @ts-ignore
|
||||||
|
import {
|
||||||
|
BASE_PATH,
|
||||||
|
COLLECTION_FORMATS,
|
||||||
|
RequestArgs,
|
||||||
|
BaseAPI,
|
||||||
|
RequiredError,
|
||||||
|
} from "./base"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Error contains error relevant information.
|
||||||
|
* @export
|
||||||
|
* @interface ModelError
|
||||||
|
*/
|
||||||
|
export interface ModelError {
|
||||||
|
/**
|
||||||
|
* The general error message according to HTTP specification.
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ModelError
|
||||||
|
*/
|
||||||
|
error: string
|
||||||
|
/**
|
||||||
|
* Concrete error message.
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ModelError
|
||||||
|
*/
|
||||||
|
msg: string
|
||||||
|
/**
|
||||||
|
* The http error code.
|
||||||
|
* @type {number}
|
||||||
|
* @memberof ModelError
|
||||||
|
*/
|
||||||
|
status_code: number
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* RaucStatus contains information about the current RAUC updater status.
|
||||||
|
* @export
|
||||||
|
* @interface RaucStatus
|
||||||
|
*/
|
||||||
|
export interface RaucStatus {
|
||||||
|
/**
|
||||||
|
* True if the installer is running
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof RaucStatus
|
||||||
|
*/
|
||||||
|
installing: boolean
|
||||||
|
/**
|
||||||
|
* Installation error message
|
||||||
|
* @type {string}
|
||||||
|
* @memberof RaucStatus
|
||||||
|
*/
|
||||||
|
last_error: string
|
||||||
|
/**
|
||||||
|
* Full command line output of the current installation
|
||||||
|
* @type {string}
|
||||||
|
* @memberof RaucStatus
|
||||||
|
*/
|
||||||
|
log: string
|
||||||
|
/**
|
||||||
|
* Current installation step
|
||||||
|
* @type {string}
|
||||||
|
* @memberof RaucStatus
|
||||||
|
*/
|
||||||
|
message: string
|
||||||
|
/**
|
||||||
|
* Installation progress
|
||||||
|
* @type {number}
|
||||||
|
* @memberof RaucStatus
|
||||||
|
*/
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface Rootfs
|
||||||
|
*/
|
||||||
|
export interface Rootfs {
|
||||||
|
/**
|
||||||
|
* Is the filesystem bootable?
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof Rootfs
|
||||||
|
*/
|
||||||
|
bootable: boolean
|
||||||
|
/**
|
||||||
|
* Is the filesystem booted?
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof Rootfs
|
||||||
|
*/
|
||||||
|
booted: boolean
|
||||||
|
/**
|
||||||
|
* Block device
|
||||||
|
* @type {string}
|
||||||
|
* @memberof Rootfs
|
||||||
|
*/
|
||||||
|
device: string
|
||||||
|
/**
|
||||||
|
* Mount path (null when not mounted)
|
||||||
|
* @type {string}
|
||||||
|
* @memberof Rootfs
|
||||||
|
*/
|
||||||
|
mountpoint: string
|
||||||
|
/**
|
||||||
|
* Is the filesystem the next boot target?
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof Rootfs
|
||||||
|
*/
|
||||||
|
primary: boolean
|
||||||
|
/**
|
||||||
|
* Filesystem
|
||||||
|
* @type {string}
|
||||||
|
* @memberof Rootfs
|
||||||
|
*/
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* StatusMessage contains the status of an operation.
|
||||||
|
* @export
|
||||||
|
* @interface StatusMessage
|
||||||
|
*/
|
||||||
|
export interface StatusMessage {
|
||||||
|
/**
|
||||||
|
* Status message text
|
||||||
|
* @type {string}
|
||||||
|
* @memberof StatusMessage
|
||||||
|
*/
|
||||||
|
msg: string
|
||||||
|
/**
|
||||||
|
* Is operation successful?
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof StatusMessage
|
||||||
|
*/
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* SystemInfo contains information about the running system.
|
||||||
|
* @export
|
||||||
|
* @interface SystemInfo
|
||||||
|
*/
|
||||||
|
export interface SystemInfo {
|
||||||
|
/**
|
||||||
|
* Hostname of the system
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SystemInfo
|
||||||
|
*/
|
||||||
|
hostname: string
|
||||||
|
/**
|
||||||
|
* Name of the os distribution
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SystemInfo
|
||||||
|
*/
|
||||||
|
os_name: string
|
||||||
|
/**
|
||||||
|
* Operating system version
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SystemInfo
|
||||||
|
*/
|
||||||
|
os_version: string
|
||||||
|
/**
|
||||||
|
* Compatible firmware name
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SystemInfo
|
||||||
|
*/
|
||||||
|
rauc_compatible: string
|
||||||
|
/**
|
||||||
|
* List of RAUC root filesystems
|
||||||
|
* @type {{ [key: string]: Rootfs; }}
|
||||||
|
* @memberof SystemInfo
|
||||||
|
*/
|
||||||
|
rauc_rootfs: {[key: string]: Rootfs}
|
||||||
|
/**
|
||||||
|
* Compatible firmware variant
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SystemInfo
|
||||||
|
*/
|
||||||
|
rauc_variant: string
|
||||||
|
/**
|
||||||
|
* System uptime in seconds
|
||||||
|
* @type {number}
|
||||||
|
* @memberof SystemInfo
|
||||||
|
*/
|
||||||
|
uptime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultApi - axios parameter creator
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the current system info
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getInfo: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/info`
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL)
|
||||||
|
let baseOptions
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = {method: "GET", ...baseOptions, ...options}
|
||||||
|
const localVarHeaderParameter = {} as any
|
||||||
|
const localVarQueryParameter = {} as any
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter)
|
||||||
|
let headersFromBaseOptions =
|
||||||
|
baseOptions && baseOptions.headers ? baseOptions.headers : {}
|
||||||
|
localVarRequestOptions.headers = {
|
||||||
|
...localVarHeaderParameter,
|
||||||
|
...headersFromBaseOptions,
|
||||||
|
...options.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the current status of the RAUC updater
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getStatus: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/status`
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL)
|
||||||
|
let baseOptions
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = {method: "GET", ...baseOptions, ...options}
|
||||||
|
const localVarHeaderParameter = {} as any
|
||||||
|
const localVarQueryParameter = {} as any
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter)
|
||||||
|
let headersFromBaseOptions =
|
||||||
|
baseOptions && baseOptions.headers ? baseOptions.headers : {}
|
||||||
|
localVarRequestOptions.headers = {
|
||||||
|
...localVarHeaderParameter,
|
||||||
|
...headersFromBaseOptions,
|
||||||
|
...options.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Reboot the system
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
startReboot: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/reboot`
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL)
|
||||||
|
let baseOptions
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = {method: "POST", ...baseOptions, ...options}
|
||||||
|
const localVarHeaderParameter = {} as any
|
||||||
|
const localVarQueryParameter = {} as any
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter)
|
||||||
|
let headersFromBaseOptions =
|
||||||
|
baseOptions && baseOptions.headers ? baseOptions.headers : {}
|
||||||
|
localVarRequestOptions.headers = {
|
||||||
|
...localVarHeaderParameter,
|
||||||
|
...headersFromBaseOptions,
|
||||||
|
...options.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Start the update process
|
||||||
|
* @param {any} updateFile RAUC firmware image file (*.raucb)
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
startUpdate: async (
|
||||||
|
updateFile: any,
|
||||||
|
options: AxiosRequestConfig = {}
|
||||||
|
): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'updateFile' is not null or undefined
|
||||||
|
assertParamExists("startUpdate", "updateFile", updateFile)
|
||||||
|
const localVarPath = `/update`
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL)
|
||||||
|
let baseOptions
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = {method: "POST", ...baseOptions, ...options}
|
||||||
|
const localVarHeaderParameter = {} as any
|
||||||
|
const localVarQueryParameter = {} as any
|
||||||
|
const localVarFormParams = new ((configuration &&
|
||||||
|
configuration.formDataCtor) ||
|
||||||
|
FormData)()
|
||||||
|
|
||||||
|
if (updateFile !== undefined) {
|
||||||
|
localVarFormParams.append("updateFile", updateFile as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
localVarHeaderParameter["Content-Type"] = "multipart/form-data"
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter)
|
||||||
|
let headersFromBaseOptions =
|
||||||
|
baseOptions && baseOptions.headers ? baseOptions.headers : {}
|
||||||
|
localVarRequestOptions.headers = {
|
||||||
|
...localVarHeaderParameter,
|
||||||
|
...headersFromBaseOptions,
|
||||||
|
...options.headers,
|
||||||
|
}
|
||||||
|
localVarRequestOptions.data = localVarFormParams
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultApi - functional programming interface
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const DefaultApiFp = function (configuration?: Configuration) {
|
||||||
|
const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration)
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the current system info
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getInfo(
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemInfo>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getInfo(options)
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the current status of the RAUC updater
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getStatus(
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RaucStatus>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getStatus(options)
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Reboot the system
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async startReboot(
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<StatusMessage>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.startReboot(
|
||||||
|
options
|
||||||
|
)
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Start the update process
|
||||||
|
* @param {any} updateFile RAUC firmware image file (*.raucb)
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async startUpdate(
|
||||||
|
updateFile: any,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<StatusMessage>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.startUpdate(
|
||||||
|
updateFile,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultApi - factory interface
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const DefaultApiFactory = function (
|
||||||
|
configuration?: Configuration,
|
||||||
|
basePath?: string,
|
||||||
|
axios?: AxiosInstance
|
||||||
|
) {
|
||||||
|
const localVarFp = DefaultApiFp(configuration)
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the current system info
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getInfo(options?: any): AxiosPromise<SystemInfo> {
|
||||||
|
return localVarFp
|
||||||
|
.getInfo(options)
|
||||||
|
.then((request) => request(axios, basePath))
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the current status of the RAUC updater
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getStatus(options?: any): AxiosPromise<RaucStatus> {
|
||||||
|
return localVarFp
|
||||||
|
.getStatus(options)
|
||||||
|
.then((request) => request(axios, basePath))
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Reboot the system
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
startReboot(options?: any): AxiosPromise<StatusMessage> {
|
||||||
|
return localVarFp
|
||||||
|
.startReboot(options)
|
||||||
|
.then((request) => request(axios, basePath))
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Start the update process
|
||||||
|
* @param {any} updateFile RAUC firmware image file (*.raucb)
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
startUpdate(updateFile: any, options?: any): AxiosPromise<StatusMessage> {
|
||||||
|
return localVarFp
|
||||||
|
.startUpdate(updateFile, options)
|
||||||
|
.then((request) => request(axios, basePath))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultApi - object-oriented interface
|
||||||
|
* @export
|
||||||
|
* @class DefaultApi
|
||||||
|
* @extends {BaseAPI}
|
||||||
|
*/
|
||||||
|
export class DefaultApi extends BaseAPI {
|
||||||
|
/**
|
||||||
|
* Get the current system info
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof DefaultApi
|
||||||
|
*/
|
||||||
|
public getInfo(options?: AxiosRequestConfig) {
|
||||||
|
return DefaultApiFp(this.configuration)
|
||||||
|
.getInfo(options)
|
||||||
|
.then((request) => request(this.axios, this.basePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current status of the RAUC updater
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof DefaultApi
|
||||||
|
*/
|
||||||
|
public getStatus(options?: AxiosRequestConfig) {
|
||||||
|
return DefaultApiFp(this.configuration)
|
||||||
|
.getStatus(options)
|
||||||
|
.then((request) => request(this.axios, this.basePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reboot the system
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof DefaultApi
|
||||||
|
*/
|
||||||
|
public startReboot(options?: AxiosRequestConfig) {
|
||||||
|
return DefaultApiFp(this.configuration)
|
||||||
|
.startReboot(options)
|
||||||
|
.then((request) => request(this.axios, this.basePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the update process
|
||||||
|
* @param {any} updateFile RAUC firmware image file (*.raucb)
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof DefaultApi
|
||||||
|
*/
|
||||||
|
public startUpdate(updateFile: any, options?: AxiosRequestConfig) {
|
||||||
|
return DefaultApiFp(this.configuration)
|
||||||
|
.startUpdate(updateFile, options)
|
||||||
|
.then((request) => request(this.axios, this.basePath))
|
||||||
|
}
|
||||||
|
}
|
74
ui/src/sebrauc-client/base.ts
Normal file
74
ui/src/sebrauc-client/base.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* SEBRAUC
|
||||||
|
* REST API for the SEBRAUC firmware updater
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: 0.2.0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Configuration} from "./configuration"
|
||||||
|
// Some imports not used depending on template conditions
|
||||||
|
// @ts-ignore
|
||||||
|
import globalAxios, {AxiosPromise, AxiosInstance, AxiosRequestConfig} from "axios"
|
||||||
|
|
||||||
|
export const BASE_PATH = "http://localhost".replace(/\/+$/, "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const COLLECTION_FORMATS = {
|
||||||
|
csv: ",",
|
||||||
|
ssv: " ",
|
||||||
|
tsv: "\t",
|
||||||
|
pipes: "|",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface RequestArgs
|
||||||
|
*/
|
||||||
|
export interface RequestArgs {
|
||||||
|
url: string
|
||||||
|
options: AxiosRequestConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class BaseAPI
|
||||||
|
*/
|
||||||
|
export class BaseAPI {
|
||||||
|
protected configuration: Configuration | undefined
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
configuration?: Configuration,
|
||||||
|
protected basePath: string = BASE_PATH,
|
||||||
|
protected axios: AxiosInstance = globalAxios
|
||||||
|
) {
|
||||||
|
if (configuration) {
|
||||||
|
this.configuration = configuration
|
||||||
|
this.basePath = configuration.basePath || this.basePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @class RequiredError
|
||||||
|
* @extends {Error}
|
||||||
|
*/
|
||||||
|
export class RequiredError extends Error {
|
||||||
|
name: "RequiredError" = "RequiredError"
|
||||||
|
constructor(public field: string, msg?: string) {
|
||||||
|
super(msg)
|
||||||
|
}
|
||||||
|
}
|
181
ui/src/sebrauc-client/common.ts
Normal file
181
ui/src/sebrauc-client/common.ts
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* SEBRAUC
|
||||||
|
* REST API for the SEBRAUC firmware updater
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: 0.2.0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Configuration} from "./configuration"
|
||||||
|
import {RequiredError, RequestArgs} from "./base"
|
||||||
|
import {AxiosInstance, AxiosResponse} from "axios"
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const DUMMY_BASE_URL = "https://example.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const assertParamExists = function (
|
||||||
|
functionName: string,
|
||||||
|
paramName: string,
|
||||||
|
paramValue: unknown
|
||||||
|
) {
|
||||||
|
if (paramValue === null || paramValue === undefined) {
|
||||||
|
throw new RequiredError(
|
||||||
|
paramName,
|
||||||
|
`Required parameter ${paramName} was null or undefined when calling ${functionName}.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const setApiKeyToObject = async function (
|
||||||
|
object: any,
|
||||||
|
keyParamName: string,
|
||||||
|
configuration?: Configuration
|
||||||
|
) {
|
||||||
|
if (configuration && configuration.apiKey) {
|
||||||
|
const localVarApiKeyValue =
|
||||||
|
typeof configuration.apiKey === "function"
|
||||||
|
? await configuration.apiKey(keyParamName)
|
||||||
|
: await configuration.apiKey
|
||||||
|
object[keyParamName] = localVarApiKeyValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const setBasicAuthToObject = function (
|
||||||
|
object: any,
|
||||||
|
configuration?: Configuration
|
||||||
|
) {
|
||||||
|
if (configuration && (configuration.username || configuration.password)) {
|
||||||
|
object["auth"] = {
|
||||||
|
username: configuration.username,
|
||||||
|
password: configuration.password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const setBearerAuthToObject = async function (
|
||||||
|
object: any,
|
||||||
|
configuration?: Configuration
|
||||||
|
) {
|
||||||
|
if (configuration && configuration.accessToken) {
|
||||||
|
const accessToken =
|
||||||
|
typeof configuration.accessToken === "function"
|
||||||
|
? await configuration.accessToken()
|
||||||
|
: await configuration.accessToken
|
||||||
|
object["Authorization"] = "Bearer " + accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const setOAuthToObject = async function (
|
||||||
|
object: any,
|
||||||
|
name: string,
|
||||||
|
scopes: string[],
|
||||||
|
configuration?: Configuration
|
||||||
|
) {
|
||||||
|
if (configuration && configuration.accessToken) {
|
||||||
|
const localVarAccessTokenValue =
|
||||||
|
typeof configuration.accessToken === "function"
|
||||||
|
? await configuration.accessToken(name, scopes)
|
||||||
|
: await configuration.accessToken
|
||||||
|
object["Authorization"] = "Bearer " + localVarAccessTokenValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const setSearchParams = function (url: URL, ...objects: any[]) {
|
||||||
|
const searchParams = new URLSearchParams(url.search)
|
||||||
|
for (const object of objects) {
|
||||||
|
for (const key in object) {
|
||||||
|
if (Array.isArray(object[key])) {
|
||||||
|
searchParams.delete(key)
|
||||||
|
for (const item of object[key]) {
|
||||||
|
searchParams.append(key, item)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchParams.set(key, object[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.search = searchParams.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const serializeDataIfNeeded = function (
|
||||||
|
value: any,
|
||||||
|
requestOptions: any,
|
||||||
|
configuration?: Configuration
|
||||||
|
) {
|
||||||
|
const nonString = typeof value !== "string"
|
||||||
|
const needsSerialization =
|
||||||
|
nonString && configuration && configuration.isJsonMime
|
||||||
|
? configuration.isJsonMime(requestOptions.headers["Content-Type"])
|
||||||
|
: nonString
|
||||||
|
return needsSerialization
|
||||||
|
? JSON.stringify(value !== undefined ? value : {})
|
||||||
|
: value || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const toPathString = function (url: URL) {
|
||||||
|
return url.pathname + url.search + url.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const createRequestFunction = function (
|
||||||
|
axiosArgs: RequestArgs,
|
||||||
|
globalAxios: AxiosInstance,
|
||||||
|
BASE_PATH: string,
|
||||||
|
configuration?: Configuration
|
||||||
|
) {
|
||||||
|
return <T = unknown, R = AxiosResponse<T>>(
|
||||||
|
axios: AxiosInstance = globalAxios,
|
||||||
|
basePath: string = BASE_PATH
|
||||||
|
) => {
|
||||||
|
const axiosRequestArgs = {
|
||||||
|
...axiosArgs.options,
|
||||||
|
url: (configuration?.basePath || basePath) + axiosArgs.url,
|
||||||
|
}
|
||||||
|
return axios.request<T, R>(axiosRequestArgs)
|
||||||
|
}
|
||||||
|
}
|
123
ui/src/sebrauc-client/configuration.ts
Normal file
123
ui/src/sebrauc-client/configuration.ts
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* SEBRAUC
|
||||||
|
* REST API for the SEBRAUC firmware updater
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: 0.2.0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConfigurationParameters {
|
||||||
|
apiKey?:
|
||||||
|
| string
|
||||||
|
| Promise<string>
|
||||||
|
| ((name: string) => string)
|
||||||
|
| ((name: string) => Promise<string>)
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
accessToken?:
|
||||||
|
| string
|
||||||
|
| Promise<string>
|
||||||
|
| ((name?: string, scopes?: string[]) => string)
|
||||||
|
| ((name?: string, scopes?: string[]) => Promise<string>)
|
||||||
|
basePath?: string
|
||||||
|
baseOptions?: any
|
||||||
|
formDataCtor?: new () => any
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Configuration {
|
||||||
|
/**
|
||||||
|
* parameter for apiKey security
|
||||||
|
* @param name security name
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
apiKey?:
|
||||||
|
| string
|
||||||
|
| Promise<string>
|
||||||
|
| ((name: string) => string)
|
||||||
|
| ((name: string) => Promise<string>)
|
||||||
|
/**
|
||||||
|
* parameter for basic security
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
username?: string
|
||||||
|
/**
|
||||||
|
* parameter for basic security
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
password?: string
|
||||||
|
/**
|
||||||
|
* parameter for oauth2 security
|
||||||
|
* @param name security name
|
||||||
|
* @param scopes oauth2 scope
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
accessToken?:
|
||||||
|
| string
|
||||||
|
| Promise<string>
|
||||||
|
| ((name?: string, scopes?: string[]) => string)
|
||||||
|
| ((name?: string, scopes?: string[]) => Promise<string>)
|
||||||
|
/**
|
||||||
|
* override base path
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
basePath?: string
|
||||||
|
/**
|
||||||
|
* base options for axios calls
|
||||||
|
*
|
||||||
|
* @type {any}
|
||||||
|
* @memberof Configuration
|
||||||
|
*/
|
||||||
|
baseOptions?: any
|
||||||
|
/**
|
||||||
|
* The FormData constructor that will be used to create multipart form data
|
||||||
|
* requests. You can inject this here so that execution environments that
|
||||||
|
* do not support the FormData class can still run the generated client.
|
||||||
|
*
|
||||||
|
* @type {new () => FormData}
|
||||||
|
*/
|
||||||
|
formDataCtor?: new () => any
|
||||||
|
|
||||||
|
constructor(param: ConfigurationParameters = {}) {
|
||||||
|
this.apiKey = param.apiKey
|
||||||
|
this.username = param.username
|
||||||
|
this.password = param.password
|
||||||
|
this.accessToken = param.accessToken
|
||||||
|
this.basePath = param.basePath
|
||||||
|
this.baseOptions = param.baseOptions
|
||||||
|
this.formDataCtor = param.formDataCtor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given MIME is a JSON MIME.
|
||||||
|
* JSON MIME examples:
|
||||||
|
* application/json
|
||||||
|
* application/json; charset=UTF8
|
||||||
|
* APPLICATION/JSON
|
||||||
|
* application/vnd.company+json
|
||||||
|
* @param mime - MIME (Multipurpose Internet Mail Extensions)
|
||||||
|
* @return True if the given MIME is JSON, false otherwise.
|
||||||
|
*/
|
||||||
|
public isJsonMime(mime: string): boolean {
|
||||||
|
const jsonMime: RegExp = new RegExp(
|
||||||
|
"^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$",
|
||||||
|
"i"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
mime !== null &&
|
||||||
|
(jsonMime.test(mime) ||
|
||||||
|
mime.toLowerCase() === "application/json-patch+json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
16
ui/src/sebrauc-client/index.ts
Normal file
16
ui/src/sebrauc-client/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* SEBRAUC
|
||||||
|
* REST API for the SEBRAUC firmware updater
|
||||||
|
*
|
||||||
|
* The version of the OpenAPI document: 0.2.0
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||||
|
* https://openapi-generator.tech
|
||||||
|
* Do not edit the class manually.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./api"
|
||||||
|
export * from "./configuration"
|
|
@ -1,3 +1,5 @@
|
||||||
|
@use "table";
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
47
ui/src/style/table.scss
Normal file
47
ui/src/style/table.scss
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
.table-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.no-bottom-border {
|
||||||
|
&,
|
||||||
|
> tr:last-child,
|
||||||
|
:not(thead) tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table caption {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0.5em 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr {
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.625em;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: #1f85de;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0.085em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {Configuration, DefaultApi} from "../sebrauc-client"
|
||||||
|
|
||||||
let apiHost = document.location.host
|
let apiHost = document.location.host
|
||||||
const httpProto = document.location.protocol
|
const httpProto = document.location.protocol
|
||||||
const wsProto = httpProto === "https:" ? "wss:" : "ws:"
|
const wsProto = httpProto === "https:" ? "wss:" : "ws:"
|
||||||
|
@ -9,4 +11,10 @@ if (import.meta.env.VITE_API_HOST !== undefined) {
|
||||||
const apiUrl = `${httpProto}//${apiHost}/api`
|
const apiUrl = `${httpProto}//${apiHost}/api`
|
||||||
const wsUrl = `${wsProto}//${apiHost}/api/ws`
|
const wsUrl = `${wsProto}//${apiHost}/api/ws`
|
||||||
|
|
||||||
export {apiUrl, wsUrl}
|
let apicfg = new Configuration({
|
||||||
|
basePath: apiUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sebraucApi = new DefaultApi(apicfg)
|
||||||
|
|
||||||
|
export {apiUrl, wsUrl, sebraucApi}
|
||||||
|
|
7
ui/src/util/colors.ts
Normal file
7
ui/src/util/colors.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class colors {
|
||||||
|
static readonly RED = "#FF0039"
|
||||||
|
static readonly GREEN = "#148420"
|
||||||
|
static readonly BLUE = "#1f85de"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default colors
|
23
ui/src/util/config.ts
Normal file
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",
|
||||||
|
}
|
||||||
|
}
|
18
ui/src/util/functions.ts
Normal file
18
ui/src/util/functions.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
function secondsToString(seconds: number): string {
|
||||||
|
const numyears = Math.floor(seconds / 31536000)
|
||||||
|
const numdays = Math.floor((seconds % 31536000) / 86400)
|
||||||
|
const numhours = Math.floor(((seconds % 31536000) % 86400) / 3600)
|
||||||
|
const numminutes = Math.floor((((seconds % 31536000) % 86400) % 3600) / 60)
|
||||||
|
const numseconds = (((seconds % 31536000) % 86400) % 3600) % 60
|
||||||
|
|
||||||
|
let res = []
|
||||||
|
if (numyears > 0) res.push(numyears + "yr")
|
||||||
|
if (numdays > 0) res.push(numdays + "d")
|
||||||
|
if (numhours > 0) res.push(numhours + "h")
|
||||||
|
if (numminutes > 0) res.push(numminutes + "m")
|
||||||
|
if (seconds < 60) res.push(numseconds + "s")
|
||||||
|
|
||||||
|
return res.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export {secondsToString}
|
|
@ -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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/server/middleware"
|
||||||
|
"code.thetadev.de/TSGRain/SEBRAUC/src/util"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const AssetsDir = "dist"
|
const distDir = "dist"
|
||||||
|
|
||||||
//go:embed dist/**
|
//go:embed dist/**
|
||||||
var Assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
type uiConfig struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func subFS(fsys fs.FS, dir string) fs.FS {
|
||||||
|
sub, err := fs.Sub(fsys, dir)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
func distFS() fs.FS {
|
||||||
|
return subFS(assets, distDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(r gin.IRouter) {
|
||||||
|
indexHandler := getIndexHandler()
|
||||||
|
|
||||||
|
uiAssets := r.Group("/assets", middleware.Cache)
|
||||||
|
|
||||||
|
r.GET("/", indexHandler)
|
||||||
|
r.GET("/index.html", indexHandler)
|
||||||
|
|
||||||
|
uiAssets.StaticFS("/", http.FS(subFS(distFS(), "assets")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIndexHandler() gin.HandlerFunc {
|
||||||
|
content, err := fs.ReadFile(distFS(), "index.html")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiConfigBytes, err := json.Marshal(uiConfig{
|
||||||
|
Version: util.Version(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
content = bytes.ReplaceAll(content, []byte("\"%CONFIG%\""), uiConfigBytes)
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Data(200, "text/html", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
87
ui/ui_test.go
Normal file
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