Magische Fracht (Linux-Magazin, Mai 2023)

Als Backuplösung verwende ich zuhause 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. Statt mich nun von meinem Arbeitssessel zu erheben und zwei Schritte gen NAS zu laufen, um es bei Bedarf anzuknipsen, bevorzuge ich es, sitzen zu bleiben und mit der Maus auf einer GUI herumzuklicken.

Die in dieser Ausgabe vorgestellte Desktop-Applikation syno (Abbildung 1) stellt das NAS auf Knopfdruck über das lokale Netzwerk an (Abbildung 2), zeigt die Meilensteine während des Bootvorgangs grafisch und mit Fortschrittsbalken an, und teilt dem User schließlich mit, wenn das System fertig hochgefahren und zugriffsbereit ist. Nach getaner Arbeit genügt ein Mausklick auf den Down-Button der GUI, und schon erhält das NAS übers Netzwerk den Befehl zum Herunterfahren. Das System leitet die notwendigen Schritte ein, und 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 3).

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

Abbildung 2: Das NAS empfängt ein WOL-Paket und fährt hoch.

WOL-le mer's anknipse?

Wie funktioniert dieses Hexenwerk? Auch im ausgeschalteten Zustand wartet das NAS aktiv auf ein sogenanntes "Wake-On-Lan"-Signal ([2]) auf dem lokalen Netzwerk. Dass die Netzwerkkarte des ausgeschalteten Geräts auf der Ethernetleitung lauscht und Aktionen einleitet, sobald bestimmte Pakete vorbeiflitzen, ist schnell erklärt: Das NAS ist eben trotz erloschener Glimmlampen doch nicht ganz abgeschaltet, sondern die Netzwerkkarte läuft in einem Low-Power-Modus und signalisiert der Stromversorgung oder dem Motherboard des Geräts bei einem eingehenden passenden Broadcast-Paket auf dem LAN, dass es sich nun bitte selbst anschalten möge. Dieses Magic-Paket enthält die MAC-Adresse des angesprochenen Geräts, sodass ein steuernder Sender also auch unterschiedliche Netzwerkteilnehmer unabhängig voneinander ansprechen könnte.

Abbildung 3: Auf Knopfdruck fährt das NAS wieder herunter.

Das Format des Magic-Pakets ist in [2] spezifiziert und Abbildung 4 zeigt ein praktisches Beispiel. Die ersten 6 Bytes des Paket-Headers führen jeweils den Festwert 0xFF. Ab Byte Nummer sieben kommt dann die Payload, die aus 16 Wiederholungen der sechs Byte langen MAC-Adresse des angesprochenen Geräts besteht. Im Beispiel ist dies "00:11:32:6c:ab:cd", aber jede Netzwerkkarte hat ihr eigenes Setting, das den Hersteller, das Modell, sowie die individuelle Kennung des Geräts ausweist.

Abbildung 4: Hexdump des Magic Packets mit 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 nochmal fest, dass sie tatsächlich sechs 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's kurz und bündig.

Listing 1: wol.go

    01 package main
    02 import (
    03   "bytes"
    04   "encoding/binary"
    05   "net"
    06 )
    07 const synMAC = "00:11:32:6c:ab:cd"
    08 const MacLen = 6
    09 type MagicPacket struct {
    10   header  [6]byte
    11   payload [16][MacLen]byte
    12 }
    13 func sendMagicPacket() {
    14   var packet MagicPacket
    15   hwAddr, err := net.ParseMAC(synMAC)
    16   if err != nil {
    17     panic(err)
    18   }
    19   for idx := range packet.header {
    20     packet.header[idx] = 0xFF
    21   }
    22   for idx := range packet.payload {
    23     for i := 0; i < MacLen; i++ {
    24       packet.payload[idx][i] = hwAddr[i]
    25     }
    26   }
    27   buf := new(bytes.Buffer)
    28   if err := binary.Write(buf, binary.BigEndian, packet); err != nil {
    29     panic(err)
    30   }
    31   conn, err := net.Dial("udp", "255.255.255.255:9")
    32   if err != nil {
    33     panic(err)
    34   }
    35   defer conn.Close()
    36   _, err = conn.Write(buf.Bytes())
    37   if err != nil {
    38     panic(err)
    39   }
    40 }

Die zwei unterschiedlichen Bereiche des Pakets aus Header und Payload abstrahiert die Struktur MagicPacket ab Zeile 9. Die Funktion sendMagicPaket() ab Zeile 13 schnürt dann das Paket und schickt es am Ende der Funktion an die Broadcast-Adresse 255.255.255.255 auf dem UDP-Port 9 aufs lokale Netzwerk. So bekommen alle am LAN angeschlossenen Geräte das Paket zu sehen und können entsprechend reagieren. Stimmt die verpackte MAC-Adresse mit einem nach dem WOL-Standard lauschenden Gerät überein, leitet dieses den Bootvorgang ein.

Aus dem String mit der MAC-Adresse in Zeile 7 macht die Funktion ParseMac() aus dem Standard-Fundus der Go-Library net eine binäre Hardware-Adresse, wie sie die Netzwerkfunktionen der Library zum Senden des Pakets brauchen. Den Header des Pakets mit sechs Bytes zu 0xFF setzt die for-Schleife ab Zeile 19. 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 nun in einen Binärstrom an Bytes für Paketempfänger auf dem Netzwerk zu verwandeln, wühlt sich die Standardfunktion binary.Write() in Zeile 28 durch die Tiefen der Struktur der Variablen packet. Im Netzwerkformat (Big Endian, also höchstwertiges Byte zuerst) legt sie dazu die Bytes der Struktur im Puffer buf ab, dessen Inhalt Zeile 36 mit Write() auf den per net.Dial() in Zeile 31 geöffneten UDP-Socket auf die Broadcast-Adresse des LAN schickt.

Zu beachten ist, dass binary.Write() eine Struktur nur dann fehlerfrei serialisieren kann, falls alle darin enthaltenen Felder eine feste Länge aufweisen. Dynamisch erweiterbare Slices beherrscht die Funktion nicht und wirft hässliche Laufzeitfehler, falls sie auf eines stößt.

Ich krieg Zustände

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

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

Abbildung 5 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" weiter unten: Diese Funktionen springt der Automat jeweils an, bevor er einen Übergang tatsächlich durchführt.

Listing 2: fsm.go

    01 package main
    02 import (
    03   "context"
    04   "time"
    05   "github.com/looplab/fsm"
    06 )
    07 func run(stateReporter chan string, startState string) *fsm.FSM {
    08   boot := fsm.NewFSM(
    09     startState,
    10     fsm.Events{
    11       {Name: "wake", Src: []string{"DOWN"}, Dst: "UP"},
    12       {Name: "sleep", Src: []string{"UP"}, Dst: "DOWN"},
    13     },
    14     fsm.Callbacks{
    15       "enter_UP": func(ctx context.Context, e *fsm.Event) {
    16         for {
    17           if isPingable() {
    18             stateReporter <- "UP"
    19             return
    20           }
    21           time.Sleep(1 * time.Second)
    22         }
    23       },
    24       "enter_DOWN": func(_ context.Context, e *fsm.Event) {
    25         for {
    26           if !isPingable() {
    27             stateReporter <- "DOWN"
    28             return
    29           }
    30           time.Sleep(1 * time.Second)
    31         }
    32       },
    33     },
    34   )
    35   return boot
    36 }

Nun baut aber jeder dieser beiden Callbacks eine Hürde auf, die es zu überwinden gilt: Sie springen in eine Endlos-For-Schleife, die mit isPingable() immer wieder prüft, ob der Endzustand (in dem das NAS je nach Callback entweder endgültig läuft oder schläft) bereits erreicht ist. Ist das noch nicht der Fall, warten die Callbacks eine Sekunde und probieren es dann noch einmal. Das wiederholt sich, bis es endlich klappt, und im Erfolgsfall schicken sie eine Nachricht in den vom Aufrufer hereingereichten Channel stateReporter mit dem neuen Status des Automaten. Am Ende der Funktion run() gibt diese eine Referenz auf den fertigen Automaten an den Aufrufer zurück, der dann später mit Methoden wie Event() neue Kommandos an den Automaten schickt, damit dieser die zugehörigen Zustandsübergänge einleitet.

Was auffällt, ist, dass die verwendete 3rd-Party-Library auf Github Strings statt typisierter Variablen für ihre Zustände nutzt. Das ist kein guter Go-Stil, denn ein Tippfehler in einem Zustand im Code und schon geht die wilde Sucherei nach Laufzeitfehlern los. Der Typ-Checker im Go-Compiler hat so keine Chanche, den Fehler zur Compile-Zeit zu erkennen. Die Library hat klar noch Luft nach oben, aber einem geschenkten Gaul schaut man bekanntlich nicht ins Maul!

Abbildung 6: Anfangs ist der "Down"-Button inaktiv.

GUI auf den Schirm

Listing 3 spannt die in den Screenshots gezeigte kleine GUI auf, und zwar mit Hilfe des Fyne-Frameworks, das der Code später bei der Kompilierung flugs von Github einholt. Die App besteht aus einem Applikationsfenster mit einem Label-Widget, das den NAS-Status anzeigt ("UP" oder "DOWN"), einem Icon (Häkchen falls das NAS betriebsbereit ist, Kreuz falls nicht) und drei Buttons zur Steuerung durch den User. Außerdem erscheint während der Übergangsphasen ein Fortschrittsbalken progress, der mit einer Animation suggeriert, dass etwas im Fluss ist. Nach dem Programmstart ist zunächst nur der "Up"-Button aktiv, der "Down"-Button ist ausgegraut (Abbildung 6). Klickt der User auf einen der Buttons, graut die App beide Knöpfe aus, damit weitere Klicks des ungeduldigen Users keine verwirrenden Folgeaktionen auslösen.

Listing 3: syno.go

    01 package main
    02 import (
    03   "context"
    04   "fyne.io/fyne/v2"
    05   "fyne.io/fyne/v2/app"
    06   "fyne.io/fyne/v2/canvas"
    07   "fyne.io/fyne/v2/container"
    08   "fyne.io/fyne/v2/theme"
    09   "fyne.io/fyne/v2/widget"
    10   "os"
    11 )
    12 func main() {
    13   state := "DOWN"
    14   headText := "NAS Control Center"
    15   a := app.New()
    16   w := a.NewWindow(headText)
    17   status := widget.NewLabelWithStyle(state,
    18     fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
    19   progress := widget.NewProgressBarInfinite()
    20   progress.Stop()
    21   progress.Hide()
    22   okIcon := widget.NewIcon(theme.ConfirmIcon())
    23   okIcon.Hide()
    24   downIcon := widget.NewIcon(theme.CancelIcon())
    25   stateReporter := make(chan string)
    26   runner := run(stateReporter, state)
    27   var upButton *widget.Button
    28   var downButton *widget.Button
    29   upButton = widget.NewButton("Up", func() {
    30     upButton.Disable()
    31     downButton.Disable()
    32     status.Text = "Coming up ..."
    33     status.Refresh()
    34     sendMagicPacket()
    35     progress.Show()
    36     go func() {
    37       runner.Event(context.Background(), "wake")
    38     }()
    39   })
    40   downButton = widget.NewButton("Down", func() {
    41     upButton.Disable()
    42     downButton.Disable()
    43     status.Text = "Going down ..."
    44     status.Refresh()
    45     progress.Show()
    46     shutdownNAS()
    47     go func() {
    48       runner.Event(context.Background(), "sleep")
    49     }()
    50   })
    51   downButton.Disable()
    52   go func() {
    53     for {
    54       select {
    55       case newState := <-stateReporter:
    56         progress.Hide()
    57         switch newState {
    58         case "DOWN":
    59           okIcon.Hide()
    60           downIcon.Show()
    61           upButton.Enable()
    62         case "UP":
    63           okIcon.Show()
    64           downIcon.Hide()
    65           downButton.Enable()
    66         }
    67         status.Text = newState
    68         status.Refresh()
    69       }
    70     }
    71   }()
    72   img := canvas.NewImageFromResource(nil)
    73   img.SetMinSize(
    74     fyne.NewSize(400, 0))
    75   grid := container.NewVBox(
    76     img,
    77     status,
    78     okIcon,
    79     downIcon,
    80     progress,
    81     container.NewHBox(
    82       upButton,
    83       downButton,
    84       widget.NewButton("Quit", func() {
    85         os.Exit(0)
    86       }),
    87     ),
    88   )
    89   w.SetContent(grid)
    90   w.ShowAndRun()
    91 }

Dass sich Teile der GUI dynamisch mit dem Programmfluss ändern, erreicht die Applikation damit, dass sie in manchen Situationen bestimmte Widgets mittels deren Funktion Hide() verschwinden lässt und später mit Show() wieder anzeigt. Das Icon zum NAS-Status, das entweder ein Häkchen darstellt oder ein Kreuzchen, besteht in zum Beispiel Wirklichkeit aus zwei separaten Widgets, okIcon und downIcon, von denen aber immer nur eines angezeigt wird.

Auch der unendliche Fortschrittsbalken progressbar ist immer Teil des Applikationsfensters, allerdings nur dann sichtbar und in Bewegung, falls gerade eine Aktion läuft, wie zum Beispiel im Callback des "Up"-Buttons ab Zeile 30, wo Zeile 35 mit progress.Show() den Balken anzeigt, nachdem Zeile 34 mit sendMagicPacket() das Kommando zum Start des NAS aufs Netzwerk geschickt hat. Springt der Status des Automaten um, sorgt Zeile 56 mit Hide() dafür, dass der Fortschrittsbalken optisch aus der App verschwindet.

Kommando: Aufwachen!

Drückt der User auf den "Up"-Button, benachrichtigt der Callback in Zeile 37 den finiten Automaten, der mit seinen Übergangsregeln die nächsten Schritte der Applikation bestimmt. Zeile 37 schickt dazu mit der Funktion Event() das Ereignis "wake" an den Automaten, der daraufhin solange blockiert, bis das NAS tatsächlich aufgewacht ist, und abschließend auf dem Channel stateReporter die Meldung "UP" schickt. 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 weiter läuft, während sich der Callback beendet.

Ereignisse aus dem Channel "stateReporter" fängt die parallel laufende Go-Routine ab Zeile 52 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, was die GUI dazu veranlasst, ihre Anzeige entsprechend der neuen Gegebenheiten aufzufrischen.

Alle Widgets, sowohl die sichtbaren als auch die unsichtbaren, reiht der ab Zeile 75 neu erzeugte Container auf. Den Sub-Container am unteren Ende des Hauptcontainers definiert NewHBox() in Zeile 81, damit die die drei Buttons zur Steuerung der App horizontal nebeneinander zu liegen kommen. Der Hauptcontainer packt derweil mit NewVBox() die restlichen Widgets wie Statustext, -Icon und Fortschrittsbalken, sowie den Sub-Container übereinander. Zeile 89 bugsiert alles ins Applikations-Window und Zeile 90 springt mit ShowAndRun() in die Endlosschleife der GUI, die Mauseingaben des Users abfängt und die vom parallel laufenden Programmcode eingeleitete GUI-Änderungen verzögerungsfrei anzeigt.

Nicht unter 400 Pixeln

Da das Fyne-Framework nicht nur auf Desktop-Oberflächen läuft sondern auch für mobile Geräte designt wurde, kapriziert es sich damit, dass es beispielsweise keine Optionen dafür anbietet, das Applikationsfenster nach dem Programmstart auf eine definierte Mindestgröße zu setzen. Das mag zwar im großen Zusammenhang der plattformübergreifenden Framework-Entwicklung eine richtige Strategie sein, sieht aber äußerst bekloppt aus, wenn eine Applikation wie syno als Minifenster mit 50x50 Pixeln hochkommt, die man auf dem Desktop kaum findet. Mit einem Trick lässt sich allerdings eine Mindestgröße einstellen: Das Canvas-Widget ab Zeile 72 mit einem leeren Image der Dimension 400 x 0, das unsichtbar Teil der VBox mit den Widgets wird, 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. Dies ginge wohl auch mit der entsprechenden Komponente aus der net-Standardbibliothek aus dem Go-Fundus, aber der Einfachheit halber ruft die Funktion einfach über die Shell das ping-Programm auf und gibt je nach dessen Return-Wert einen wahren oder falschen Wert an den Aufrufer zurück.

Listing 4: util.go

    01 package main
    02 import (
    03   "os/exec"
    04 )
    05 const synIP = "192.168.3.33"
    06 func shutdownNAS() {
    07   cmd := exec.Command("ssh", "synuser@"+synIP, "sudo", "/sbin/poweroff")
    08   if err := cmd.Run(); err != nil {
    09       panic(err)
    10   }
    11 }
    12 func isPingable() bool {
    13   cmd := exec.Command("ping", "-c", "1", "-t", "3", synIP)
    14   if err := cmd.Run(); err != nil {
    15     return false
    16   }
    17   return true
    18 }

Zum Ausschalten nach getaner Arbeit nimmt die Funktion shutdownNAS() ab Zeile 6 Kontakt mit dem NAS unter seiner IP auf und loggt sich auf einem vordefinierten ssh-Account dort ein. Dann setzt sie in der aufgehenden Shell dort den Befehl sudo poweroff ab, worauf das Plattenmonster herunterfährt und sich auch noch selbständig vom Strom trennt. Der stetig prüfende Zustandsautomat bekommt das mit und die GUI schnackelt optisch auf "DOWN" um.

Dazu muss sich der Steuerungsrechner per ssh ohne Eingabe eines Passworts auf dem NAS einloggen können, was typischerweise dadurch eingestellt wird, dass der Public-Key des Schlüsselpaars des Steuerungsrechners in der Datei ~/.ssh/authorized_keys des NAS landet. Außerdem braucht der User auf dem NAS sudo-Rechte um den Befehl poweroff auszuführen, was durch

    synouser ALL=(ALL) NOPASSWD: /sbin/poweroff 

in /etc/sudoers passiert.

Nicht nur Linux

Bleibt nur noch, die Applikation mit allen vier Listings in einem Verzeichnis mittels

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

zusammenzubauen. Die ersten beiden mod-Kommandos ziehen im Code benutzte Libraries von Github herein und das letzte build-Kommando linkt alles zu einem Binary syno zusammen, das sich irgendwo hinkopieren und ausführen lässt. Die im Code verwendeten IP- und MAC- Addressen sowie der ssh-User auf dem NAS sind natürlich an die lokalen Gegebenheiten anzupassen.

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

Ein weiterer Pluspunkt des Fyne-Frameworks ist dessen Plattformunabhängigkeit. Es lässt sich 1:1 auf anderen Betriebssystemen und Oberflächen zusammenbauen. Abbildung 7 zeigt die laufende Applikation auf einem Macbook. Ähnliches gilt für Windows und sogar Android, wo fyne sogar Tools mitbringt, um die Applikation entsprechend der Vorgaben des Betriebssystems zu einem Bündel zu packen ([3]). Ein wahrer plattformübergreifender Tausendsassa!

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/05/snapshot/

[2]

"Wake on LAN", Wikipedia, https://en.wikipedia.org/wiki/Wake-on-LAN

[3]

"Fyne, Mobile Packaging", https://developer.fyne.io/started/mobile

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 mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc