Aus Linux-Magazin 05/2023

Backup-Lösung per Maus und GUI steuern

© luckybusiness / 123RF.com

Um sein NAS herauf- und herunterzufahren und den aktuellen Status zu prüfen, ohne sich vom Sessel zu erheben, programmiert Mike Schilli eine grafische Oberfläche, die ein Magic-Paket abschickt.

Als Backup-Lösung verwende ich ein Synology-NAS mit einigen dicken Festplatten. Wegen der strikten Verordnungen zu Paranoia und Lärmschutz in den heiligen Hallen der Perlmeister-Studios läuft der Kasten allerdings nur dann, wenn er tatsächlich gebraucht wird, nämlich während ein Backup läuft. Um es bei Bedarf anzuknipsen, bevorzuge ich es, sitzen zu bleiben und mit der Maus auf einer GUI herumzuklicken.

Die hier vorgestellte Desktop-Applikation Syno (Abbildung 1) schaltet das NAS auf Knopfdruck über das lokale Netzwerk ein und zeigt die Meilensteine während des Boot-Vorgangs grafisch und mit Fortschrittsbalken an. Sobald das System fertig hochgefahren und zugriffsbereit ist, teilt sie das dem User mit.

Abbildung 1: Steuerung per Desktop-App: Mikes Synology-NAS fährt hoch.

Abbildung 1: Steuerung per Desktop-App: Mikes Synology-NAS fährt hoch.

Nach getaner Arbeit genügt ein Mausklick auf den Down-Schalter der GUI, und schon erhält das NAS via Netzwerk den Befehl zum Herunterfahren. Während der Shutdown läuft, prüft die GUI, ob das NAS noch betriebsbereit ist oder nicht mehr auf Pings reagiert und sich endlich schlafen gelegt hat (Abbildung 2).

Abbildung 2: Auf Knopfdruck fährt der Netzwerkspeicher wieder herunter.

Abbildung 2: Auf Knopfdruck fährt der Netzwerkspeicher wieder herunter.

Einfach anknipsen

Wie funktioniert dieses Hexenwerk? Auch im ausgeschalteten Zustand wartet das NAS aktiv auf ein sogenanntes Wake-on-LAN-Signal auf dem lokalen Netzwerk. Trotz erloschener Glimmlampen läuft die Netzwerkkarte in einem Low-Power-Modus. Geht via LAN ein passendes Broadcast-Paket ein, signalisiert sie der Stromversorgung oder dem Motherboard des Geräts die Einschaltaufforderung. Das entsprechende Magic Packet enthält die MAC-Adresse des angesprochenen Systems, sodass ein steuernder Sender unterschiedliche Netzwerkteilnehmer unabhängig voneinander ansprechen kann.

Abbildung 3 zeigt ein praktisches Beispiel für das Format eines Magic-Pakets [1]. Die ersten 6 Bytes des Paket-Headers enthalten jeweils den Festwert »0xFF«. Danach folgt die Payload, die aus 16 Wiederholungen der 6 Byte langen MAC-Adresse des angesprochenen Geräts besteht, im Beispiel »00:11:32:6c:ab:cd«. Jede Netzwerkkarte hat ihr eigenes Setting, das den Hersteller, das Modell sowie die individuelle Kennung des Geräts ausweist.

Abbildung 3: Der Hexdump eines Magic Packets für die MAC »00:11:32:6c:ab:cd«.

Abbildung 3: Der Hexdump eines Magic Packets für die MAC »00:11:32:6c:ab:cd«.

Listing 1 implementiert den Bau des Magic Packets in Go. Zeile 7 setzt die MAC-Adresse des NAS als String, und Zeile 8 legt fest, dass sie wirklich 6 Bytes lang ist. In seltenen Fällen gibt tatsächlich auch Geräte mit längeren MAC-Adressen, aber deren Magic-Pakete sind schwieriger zu konstruieren. Für Illustrationszwecke halten wir es kurz und bündig.

Listing 1

wol.go

package main
import (
  "bytes"
  "encoding/binary"
  "net"
)
const synMAC = "00:11:32:6c:ab:cd"
const MacLen = 6
type MagicPacket struct {
  header  [6]byte
  payload [16][MacLen]byte
}
func sendMagicPacket() {
  var packet MagicPacket
  hwAddr, err := net.ParseMAC(synMAC)
  if err != nil {
    panic(err)
  }
  for idx := range packet.header {
    packet.header[idx] = 0xFF
  }
  for idx := range packet.payload {
    for i := 0; i < MacLen; i++ {
      packet.payload[idx][i] = hwAddr[i]
    }
  }
  buf := new(bytes.Buffer)
  if err := binary.Write(buf, binary.BigEndian, packet); err != nil {
    panic(err)
  }
  conn, err := net.Dial("udp", "255.255.255.255:9")
  if err != nil {
    panic(err)
  }
  defer conn.Close()
  _, err = conn.Write(buf.Bytes())
  if err != nil {
    panic(err)
  }
}

Die Struktur »MagicPacket« ab Zeile 9 abstrahiert die zwei unterschiedlichen Bereiche des Pakets aus Header und Payload. Die Funktion »sendMagicPaket()« ab Zeile 13 schnürt dann das Paket und sendet es am Ende an die Broadcast-Adresse »255.255.255.255« auf dem UDP-Port »9« im LAN. So bekommen alle Geräte im lokalen Netzwerk das Paket zu sehen und können entsprechend reagieren. Empfängt ein per WoL lauschendes Gerät ein Paket, bei dem die verpackte MAC-Adresse mit der eigenen übereinstimmt, leitet es den Boot-Vorgang ein.

Die Funktion »ParseMac()« aus dem Standard-Fundus der Go-Library »net« übersetzt den MAC-String aus Zeile 7 in eine binäre Hardwareadresse, wie sie die Netzwerkfunktionen der Library zum Senden des Pakets brauchen. Den Header des Pakets mit sechs »0xFF«-Bytes setzt die For-Schleife ab Zeile 19 zusammen. Die anschließende Doppelschleife ab Zeile 22 schreibt dann 16 Mal hintereinander die MAC-Adresse im Binärformat in den Payload-Bereich des Pakets.

Um die Go-Struktur vom Typ »MagicPacket« in einen Binärstrom von Bytes für Paketempfänger im Netzwerk umzuwandeln, wühlt sich die Standardfunktion »binary.Write()« in Zeile 28 durch die Tiefen der Struktur der Variablen »packet«. Dazu legt sie die Bytes der Struktur im Netzwerkformat (Big Endian, höchstwertiges Byte zuerst) im Puffer »buf« ab. Dessen Inhalt schickt Zeile 36 per »Write()« über den in Zeile 31 mit »net.Dial()« geöffneten UDP-Socket an die Broadcast-Adresse des LAN.

Achtung: »binary.Write()« kann eine Struktur nur dann fehlerfrei serialisieren, wenn alle darin enthaltenen Felder eine feste Länge haben. Dynamisch erweiterbare Slices beherrscht die Funktion nicht und wirft dann hässliche Laufzeitfehler.

Ich krieg Zustände

Die Zustände, in denen sich die Applikation während der Laufzeit befinden kann, sind die eines simplen finiten Automaten. Nach dem Start des Programms schläft das NAS üblicherweise (Zustand DOWN). Der Anwender gibt mit dem Up-Schalter das Aufwachkommando. Während des Hochfahrens prüft die Applikation immer wieder, ob sich das NAS schon pingen lässt. Zeigt es keine Reaktion, schläft es wohl noch, verweilt also im Zustand DOWN. Meldet das Ping-Kommando jedoch Erfolg, ist das NAS betriebsbereit, und der finite Automat springt in den Zustand UP.

Abbildung 4 zeigt das Zustandsdiagramm des Automaten, Listing 2 dessen Implementierung mit der Go-Library »fsm« von Github. Die Funktion »NewFSM« erzeugt ab Zeile 8 eine neue Finite State Machine, die zwei Ereignisse verarbeitet: »wake« in Zeile 11, das vom Zustand DOWN in den Zustand UP führt, und »sleep« (Zeile 12), das den Automaten von UP nach DOWN leitet. Die Bedingungen für diese Übergänge kodiert Listing 2 in den Callbacks »enter_UP« und »enter_DOWN«, die der Automat jeweils anspringt, bevor er einen Übergang tatsächlich angeht.

Abbildung 4: Die Zust&auml;nde und &Uuml;berg&auml;nge des simplen finiten Automaten.

Abbildung 4: Die Zustände und Übergänge des simplen finiten Automaten.

Listing 2

fsm.go

package main
import (
  "context"
  "time"
  "github.com/looplab/fsm"
)
func run(stateReporter chan string, startState string) *fsm.FSM {
  boot := fsm.NewFSM(
    startState,
    fsm.Events{
      {Name: "wake", Src: []string{"DOWN"}, Dst: "UP"},
      {Name: "sleep", Src: []string{"UP"}, Dst: "DOWN"},
    },
    fsm.Callbacks{
      "enter_UP": func(ctx context.Context, e *fsm.Event) {
        for {
          if isPingable() {
            stateReporter <- "UP"
            return
          }
          time.Sleep(1 * time.Second)
        }
      },
      "enter_DOWN": func(_ context.Context, e *fsm.Event) {
        for {
          if !isPingable() {
            stateReporter <- "DOWN"
            return
          }
          time.Sleep(1 * time.Second)
        }
      },
    },
  )
  return boot
}

Nun baut aber jeder dieser beiden Callbacks eine Hürde in Form einer Endlos-For-Schleife auf. Sie prüft mit »isPingable()« immer wieder, ob der Endzustand bereits erreicht ist, in dem das NAS je nach Callback entweder endgültig läuft oder schläft. Falls nicht, warten die Callbacks eine Sekunde und probieren es dann noch einmal. Das wiederholt sich so lange, bis der gewünschte Zustand erreicht ist.

Dann schicken die Callbacks eine Nachricht mit dem neuen Status des Automaten in den vom Aufrufer hereingereichten Channel »stateReporter«. Die Funktion »run()« gibt am Ende eine Referenz auf den fertigen Automaten an den Aufrufer zurück. Der sendet dann später mit Methoden wie »Event()« neue Kommandos an den Automaten, um die zugehörigen Zustandsübergänge einzuleiten.

Interessanterweise nutzt die verwendete 3rd-Party-Library auf Github Strings statt typisierter Variablen für ihre Zustände. Das ist kein guter Go-Stil, denn dadurch genügt ein schlichter Tippfehler in einem Zustand im Code, um eine wilde Sucherei nach Laufzeitfehlern auszulösen. Der Typ-Checker im Go-Compiler hat so keine Chance, den Fehler zur Compile-Zeit zu erkennen. Die Library hat klar noch Luft nach oben, aber einem geschenkten Gaul schaut man nicht ins Maul.

GUI auf den Schirm

Listing 3 spannt die in den Screenshots gezeigte kleine GUI auf. Sie basiert auf dem Fyne-Framework, das der Code bei der Kompilierung flugs von Github einholt.

Listing 3

syno.go

package main
import (
  "context"
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/app"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/theme"
  "fyne.io/fyne/v2/widget"
  "os"
)
func main() {
  state := "DOWN"
  headText := "NAS Control Center"
  a := app.New()
  w := a.NewWindow(headText)
  status := widget.NewLabelWithStyle(state, fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
  progress := widget.NewProgressBarInfinite()
  progress.Stop()
  progress.Hide()
  okIcon := widget.NewIcon(theme.ConfirmIcon())
  okIcon.Hide()
  downIcon := widget.NewIcon(theme.CancelIcon())
  stateReporter := make(chan string)
  runner := run(stateReporter, state)
  var upButton *widget.Button
  var downButton *widget.Button
  upButton = widget.NewButton("Up", func() {
    upButton.Disable()
    downButton.Disable()
    status.Text = "Coming up ..."
    status.Refresh()
    sendMagicPacket()
    progress.Show()
    go func() {
      runner.Event(context.Background(), "wake")
    }()
  })
  downButton = widget.NewButton("Down", func() {
    upButton.Disable()
    downButton.Disable()
    status.Text = "Going down ..."
    status.Refresh()
    progress.Show()
    shutdownNAS()
    go func() {
      runner.Event(context.Background(), "sleep")
    }()
  })
  downButton.Disable()
  go func() {
    for {
      select {
      case newState := <-stateReporter:
        progress.Hide()
        switch newState {
        case "DOWN":
          okIcon.Hide()
          downIcon.Show()
          upButton.Enable()
        case "UP":
          okIcon.Show()
          downIcon.Hide()
          downButton.Enable()
        }
        status.Text = newState
        status.Refresh()
      }
    }
  }()
  img := canvas.NewImageFromResource(nil)
  img.SetMinSize(
    fyne.NewSize(400, 0))
  grid := container.NewVBox(
    img,
    status,
    okIcon,
    downIcon,
    progress,
    container.NewHBox(
      upButton,
      downButton,
      widget.NewButton("Quit", func() {
        os.Exit(0)
      }),
    ),
  )
  w.SetContent(grid)
  w.ShowAndRun()
}

Die App besteht aus einem Applikationsfenster mit einem Label-Widget, das den NAS-Status anzeigt (UP oder DOWN). Hinzu kommen ein Icon (Häkchen, falls das NAS betriebsbereit ist; Kreuz, falls nicht) und drei Schaltflächen zur Steuerung durch den Benutzer. Außerdem erscheint während der Übergangsphasen ein Fortschrittsbalken (»progress«). Nach dem Programmstart ist zunächst nur der Up-Knopf aktiv, der Down-Schalter bleibt ausgegraut (Abbildung 5). Klickt der User auf eine der Schaltflächen, graut die App beide aus, damit nicht weitere ungeduldige Klicks verwirrende Folgeaktionen auslösen.

Abbildung 5: Anfangs ist der <span class="ui-element">Down</span>-Schalter inaktiv.

Abbildung 5: Anfangs ist der Down-Schalter inaktiv.

Dass sich Teile der GUI dynamisch mit dem Programmfluss ändern, erreicht die Applikation, indem sie in manchen Situationen bestimmte Widgets mittels deren Funktion »Hide()« verschwinden lässt und später mit »Show()« wieder zum Vorschein bringt. Das Icon zum NAS-Status – entweder ein Häkchen oder ein Kreuzchen – besteht tatsächlich aus den zwei separaten Widgets »okIcon« und »downIcon«, von denen die App aber immer nur eines anzeigt.

Auch der unendliche Fortschrittsbalken »progressbar« gehört integral zum Applikationsfenster. Er ist allerdings nur dann sichtbar und in Bewegung, falls gerade eine Aktion läuft, zum Beispiel im Callback des Up-Buttons ab Zeile 28: Nachdem Zeile 33 mit »sendMagicPacket()« das Kommando zum Start des NAS aufs Netzwerk geschickt hat, zeigt Zeile 34 mit »progress.Show()« den Balken an. Springt der Status des Automaten um, sorgt Zeile 56 mit »Hide()« dafür, dass er optisch wieder aus der App verschwindet.

Kommando: Aufwachen!

Drückt der User den Up-Button, benachrichtigt der Callback in Zeile 36 den finiten Automaten, der mit seinen Übergangsregeln die nächsten Schritte der Applikation bestimmt. Zeile 36 sendet dazu mit der Funktion »Event()« das Ereignis »wake« an den Automaten. Der blockiert daraufhin so lange, bis das NAS aufwacht, und schickt abschließend auf dem Channel »stateReporter« die Meldung »UP«. Da die GUI während dieser Blockade aber nicht einfrieren darf, wickelt der Callback den Aufruf der »Event()«-Funktion des Automaten in eine Go-Routine, die parallel weiterläuft, während sich der Callback beendet.

Ereignisse aus dem Channel »stateReporter« fängt die parallel laufende Go-Routine ab Zeile 51 ab. Mit einer Select-Anweisung lauscht sie auf dem Channel, und sobald der finite Automat dort einen neuen Status bekannt gibt, springt sie eine der beiden Case-Blöcke an. Das veranlasst die GUI dazu, die Anzeige entsprechend der neuen Gegebenheiten aufzufrischen.

Der ab Zeile 74 neu erzeugte Container reiht alle Widgets auf, sowohl die sichtbaren als auch die unsichtbaren. »NewHBox()« in Zeile 80 definiert den Sub-Container am unteren Ende des Haupt-Containers, damit die drei Schaltflächen zur Steuerung der App horizontal nebeneinander zu liegen kommen. Der Haupt-Container packt derweil mit »NewVBox()« die restlichen Widgets wie Text und Icon für den Status, den Fortschrittsbalken sowie den Sub-Container übereinander.

Zeile 88 bugsiert alles ins Applikationsfenster. Zeile 89 springt schließlich mit »ShowAndRun()« in die Endlosschleife der GUI. Sie fängt Mauseingaben des Benutzers ab und zeigt die vom parallel laufenden Programmcode eingeleiteten GUI-Änderungen verzögerungsfrei an.

Nicht unter 400 Pixeln

Das Fyne-Framework läuft nicht nur auf Desktop-Oberflächen, sondern wurde auch für mobile Geräte entwickelt. Deshalb bietet es beispielsweise keine Optionen dafür an, ein Applikationsfenster nach dem Programmstart auf eine definierte Mindestgröße zu setzen.

Das mag ja im großen Zusammenhang der plattformübergreifenden Framework-Entwicklung die richtige Strategie sein. Es sieht aber äußerst bescheiden aus, wenn eine Applikation wie Syno als Minifenster mit 50 x 50 Pixeln hochkommt, die man auf dem Desktop kaum findet. Mit einem Trick lässt sich allerdings doch eine Mindestgröße einstellen: Die App füttert das Canvas-Widget ab Zeile 72 mit einem leeren Image der Dimension 400 x 0 Pixel, das so unsichtbar Teil der VBox mit den Widgets wird. Das zwingt den Renderer, das Fenster von Anfang an mindestens 400 Pixel breit aufzuziehen.

Ausschalten mit Sudo

Der letzte Teil der Applikation in Listing 4 definiert mit »isPingable()« eine Funktion, die prüft, ob das NAS betriebsbereit ist. Das ginge wohl auch mit der entsprechenden Komponente der »net«-Standardbibliothek aus dem Go-Fundus, aber der Einfachheit halber ruft die Funktion einfach via Shell das Programm Ping auf. Je nach dessen Return-Code gibt es einen wahren oder falschen Wert an den Aufrufer zurück.

Listing 4

util.go

package main
import (
  "os/exec"
)
const synIP = "192.168.3.33"
func shutdownNAS() {
  cmd := exec.Command("ssh", "synuser@"+synIP, "sudo", "/sbin/poweroff")
  if err := cmd.Run(); err != nil {
      panic(err)
  }
}
func isPingable() bool {
  cmd := exec.Command("ping", "-c", "1", "-t", "3", synIP)
  if err := cmd.Run(); err != nil {
    return false
  }
  return true
}

Zum Ausschalten nach getaner Arbeit nimmt die Funktion »shutdownNAS()« ab Zeile 6 Kontakt mit dem NAS unter dessen IP-Adresse auf und loggt sich auf einem vordefinierten SSH-Account ein. Dann setzt sie in der resultierenden Shell den Befehl »sudo poweroff« ab. Daraufhin fährt das Plattenmonster herunter und trennt sich auch noch selbstständig vom Strom. Der stetig prüfende Zustandsautomat bekommt das mit, und die GUI springt optisch auf DOWN um.

Dazu muss sich der Steuerungsrechner per SSH ohne Eingabe eines Passworts auf dem NAS einloggen können. Das erreicht man typischerweise dadurch, dass man den Public Key des Schlüsselpaars des Steuerungsrechners in der Datei »~/.ssh/authorized_keys« des NAS ablegt. Außerdem braucht der User auf dem NAS Sudo-Rechte, um den Befehl »poweroff« auszuführen. Dafür sorgt die Zeile »synouser ALL=(ALL) NOPASSWD: /sbin/poweroff« in »/etc/sudoers«.

Nicht nur Linux

Bleibt nur noch, die Applikation mittels der Befehle aus Listing 5 zusammenzubauen. Die ersten beiden »mod«-Kommandos ziehen im Code benutzte Bibliotheken von Github herein. Der »build«-Befehl linkt alles zu einem Binary »syno« zusammen, das sich an eine beliebige Stelle kopieren und ausführen lässt. Die im Code verwendeten IP- und MAC-Adressen sowie den SSH-User auf dem NAS gilt es an die lokalen Gegebenheiten anzupassen.

Listing 5

Applikation bauen

$ go mod init syno
$ go mod tidy
$ go build

Ein Pluspunkt des Fyne-Frameworks ist dessen Plattformunabhängigkeit: Es lässt sich auch auf anderen Betriebssystemen und Oberflächen zusammenbauen. Abbildung 6 zeigt die laufende Applikation auf einem Macbook, sie ließe sich ebenso unter Windows nutzen. Für Android bringt Fyne sogar Tools mit, um die Applikation entsprechend der Vorgaben des Betriebssystems zu einem Bündel zu packen [2]. Ein wahrer plattformübergreifender Tausendsassa! (uba)

Abbildung 6: Auch auf einem Mac macht das Go-Programm eine gute Figur.

Abbildung 6: Auch auf einem Mac macht das Go-Programm eine gute Figur.

Der Autor

Michael Schilli arbeitet als Software Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben