Aus Linux-Magazin 10/2023

Shell-Skripting mit Go

© Oleksandr Hruts | 123RF.com

Einfach Shell-Skripte in der kompilierenden Programmiersprache Go schreiben? In der Praxis funktioniert die vermeintliche Schnapsidee ausgesprochen gut, birgt allerdings einige Stolperfallen.

Um die Log-Dateien auf einem System zu zählen, genügt ein kleines Shell-Skript wie der folgende Einzeiler:

find /var/log -name "*.log" | wc -l

Oft führen Shell-Skripte mit wenig Code wie im Beispiel zu schnellen Ergebnissen, weshalb vor allem Administratoren und Softwareentwickler kaum ohne sie auskommen. Bei etwas komplexeren Aufgaben mutieren Shell-Skripte allerdings häufig zu einem kryptischen Zeichensalat. Bereits die obige Zeile erweist sich als nicht ganz selbsterklärend: »find« liefert jede Datei mit der Endung ».log« in einer eigenen Zeile zurück, die dann »wc -l« zählt. Das zuletzt genannte Tool knöpft sich eigentlich Wörter vor. Wer die Parameter von »wc« nicht auswendig kennt, könnte daher von einem falschen Verhalten ausgehen. Beim Einlesen in vorhandene Skripte benötigen Sie deshalb oft das Handbuch der Bash, die Manpages von Kommandozeilenwerkzeugen und eine gute Internetsuchmaschine. Die kryptischen Befehle stehen nicht nur dem Verständnis im Weg, sie erschweren zudem das Testen. Bei komplexen Aufgaben setzt darüber hinaus oft der Funktionsumfang der Tools Grenzen, was nicht selten in hässlichen Workarounds mündet. Wer regelmäßig in weiteren Sprachen programmiert, muss schließlich noch zwischen unterschiedlichen Syntaxen hin und her springen.

Langsam und schnell

Die Befehle im Shell-Skript arbeitet ein Interpreter geruhsam nacheinander ab. Hinzu kommt, dass viele Shell-Skripte ihre Daten ineffizient durch die Pipes schieben. Beides bremst spätestens dann, wenn die Skripte wiederholt anlaufen oder zeitaufwendige Aufgaben erledigen müssen. Ein kompiliertes Go-Programm würde sich beispielsweise deutlich schneller durch umfangreiche Log-Daten wühlen als die eingangs vorgestellte Zeile [1].

Da liegt die Idee nahe, direkt Go als Skriptsprache zu verwenden. Auf diese Weise beschleunigen Sie nicht nur die Ausführung, Sie kommen auch in den Genuss einer einheitlichen Syntax. Obendrein können Sie auf zahlreiche externe Bibliotheken zugreifen, mit denen sich deutlich mehr Aufgaben flexibel umsetzen lassen. Der Go-Compiler bietet eine Typprüfung und fängt viele Programm- und Flüchtigkeitsfehler ab. In Shell-Skripten würden Ihnen solche Fehler erst zur Laufzeit um die Ohren fliegen. Abschließend besitzt das produzierte Binary keine Abhängigkeiten. Anders als bei Shell-Skripten müssen Sie somit noch nicht einmal sicherstellen, dass auf dem Zielrechner sämtliche verwendeten Werkzeuge oder eine spezielle Shell vorliegen. Kurzum: Mit Go als Skriptsprache sparen Sie sich Arbeit, Hirnschmalz und Zeit – gäbe es da nicht ein paar kleinere Haken.

Vereinfachte Syntax

Das Go-Programm aus Listing 1 zählt die Log-Dateien im Verzeichnis »/var/log«. Aufgrund der C-ähnlichen Syntax und der recht systemnahen Funktionen aus der mitgelieferten Standardbibliothek benötigt es deutlich mehr Code als der eingangs vorgestellte Einzeiler für die Shell.

Listing 1

Zählen aller Log-Dateien unter /var/log in Go

package main
import (
  "fmt"
  "io/fs"
  "path/filepath"
)
func main() {
  var count int = 0
  var dir string = "/var/log"
  var name string = "*.log"
  filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
    islog , _ := filepath.Match(name, d.Name())
    if islog { count++ }
    return nil
  })
  fmt.Println(count)
}

Go wäre also von Haus aus kein guter Ersatz für flott geschriebene Shell-Skripte. Das würde sich erst ändern, sobald man in Go kurz und knackig so etwas wie das Folgende schreiben könnte:

script.FindFiles("/home/tim")\
  .Match(".log").CountLines()

Genau das ermöglicht die Go-Bibliothek Script [2] (Abbildung 1). Sie bildet in Teilen das Verhalten der Shell sowie einiger Linux-Kommandos nach. »Match()« ersetzt beispielsweise »grep«, »CountLines()« zählt wie »wc -l« die Zeilen. Script simuliert sogar Pipes über den Punktoperator. Von ihm macht auch das obige Beispiel regen Gebrauch: Zunächst übernimmt »FindFiles()« die Rolle von »find« und liefert alle Dateien aus sämtlichen Unterverzeichnissen des angegebenen Ordners zurück. Diese Dateinamen wandern über eine Pipe weiter zu »Match()«, das die passenden Dateinamen herausfiltert und an »CountLines()« übergibt. Als angenehmer Nebeneffekt glänzt der Go-Code sogar noch mit besserer Lesbarkeit als das Shell-Pendant.

Abbildung 1: Die Bibliothek Script lässt sich in regulären Go-Programmen nutzen, um beispielsweise schnell Textdateien zu verarbeiten.

Abbildung 1: Die Bibliothek Script lässt sich in regulären Go-Programmen nutzen, um beispielsweise schnell Textdateien zu verarbeiten.

Um die Hilfe von Script in Anspruch zu nehmen, importieren Sie lediglich das entsprechende Package und verstauen den Skript-Code in der für Go-Programme obligatorischen Funktion »main()«. Listing 2 zeigt den kompletten Quellcode, der die Log-Dateien im Verzeichnis »/var/logs« zählt. Gegenüber dem Shell-Skript vom Anfang gibt es damit zwar immer noch viele zusätzliche Zeilen, die jedoch Go-Kenner schnell eintippen dürften.

Listing 2

Log-Dateien zählen mit Script

package main
import (
  "fmt"
  "github.com/bitfield/script"
)
func main() {
  count, _ := script.FindFiles("/var/log").Match(".log").CountLines()
  fmt.Println(count)
}

Feintuning

Die im Beispiel gewählte Funktion »Match()« erweist sich jedoch als keine ganz so gute Wahl: Sie prüft nur, ob die übergebene Zeichenkette irgendwo auftaucht. Listing 2 würde daher fälschlicherweise ebenfalls die Datei »der.logarithmus.txt« mitzählen. Dementsprechend empfiehlt es sich, »MatchRegexp()« einzusetzen. Diese Funktion verarbeitet die in Go bereits verfügbaren regulären Ausdrücke und demonstriert so gleichzeitig, wie sich Script in Go-Programme integriert.

regex := regexp.MustCompile(`\.log$`)
count, _ := script.FindFiles("/var/log")\
  .MatchRegexp(regex).CountLines()

Das Werkzeug ist vor allem auf die Verarbeitung von Texten ausgerichtet. Beispielsweise lesen Sie mit dem Befehl »File()« schnell eine Datei ein, ohne mit den File-Deskriptoren von Go hantieren zu müssen. Das folgende Kommando fischt alle Zeilen aus der Datei »error.log«, in denen das Wort »Panic« vorkommt, und gibt die Fundstücke per »Stdout()« auf der Standardausgabe aus.

script.File("error.log")\
  .Match("Panic").Stdout()

Externe Programme führt »Exec()« aus, das außerdem die Kommandosubstitution ersetzt. Das nachstehende Beispiel lässt sich von »ip« den aktuellen Status der Netzwerkschnittstellen liefern, aus dem dann »Match()« alle aktiven Schnittstellen heraussucht.

script.Exec("ip a").Match("UP").Stdout()

Möchten Sie ein Kommando mehrfach mit unterschiedlichen Parametern ausführen, helfen »ExecForEach()« und die Go Templates [3]. Die anschließende Codezeile filtert mithilfe des Tools Xz alle Dateien heraus, deren Dateinamen Sie dem Go-Programm als Parameter übergeben haben.

script.Args().ExecForEach\
  ("xz -9 {{.}}").Stdout()

»Args()« schiebt zunächst die Parameter in eine Pipe, über die sie zu »ExecForEach()« rutschen. Das Template »{{.}}« ersetzt Go durch den jeweils nächsten Parameter und – im Beispiel durch einen Dateinamen. Abschließend ruft »ExecForEach()« das damit in den Anführungszeichen entstandene Kommando auf. Das Ganze wiederholt sich, bis alle Parameter abgearbeitet sind. Innerhalb von »ExecForEach()« können Sie die Go-Template-Syntax voll ausnutzen.

Viele weitere Shell-Befehle und ihre Entsprechungen in Script fasst die Tabelle “Shell-Befehle und ihre »script«-Pendants” zusammen. Besonders nützlich ist dabei der Curl-Ersatz. Beispielsweise stellt »Get()« eine HTTP-Anfrage, deren zurückgelieferte Daten Sie zu einem String formen und im Go-Programm weiterverarbeiten können.

weather, _ := script.Get\
  ("https://wttr.in/Berlin?format=3").String()
fmt.Println(weather)
Tabelle 1
Shell-Befehle und ihre script-Pendants

beliebiges Programm ausführen/aufrufen

»Exec« und »ExecForEach«

»[ -f FILE ]«

»IfExists«

»>«

»WriteFile«

»>>«

»AppendFile«

»$*«

»Args«

»basename«

»Basename«

»cat«

»File« und »Concat«

»curl«

»Do«, »Get« und »Post«

»cut«

»Column«

»dirname«

»Dirname«

»echo«

»Echo«

»find«

»FindFiles«

»grep«

»Match« und »MatchRegexp«

»grep -v«

»Reject« und »RejectRegexp«

»head«

»First«

»jq«

»JQ«

»ls«

»ListFiles«

»sed«

»Replace« und »ReplaceRegexp«

»sha256sum«

»SHA256Sum« und »SHA256Sums«

»tail«

»Last«

»tee«

»Tee«

»uniq -c«

»Freq«

»wc -l«

»CountLines«

»xargs«

»ExecForEach«

Berg- und Talfahrt

Sobald Sie ein bisschen mit Script experimentiert haben, stellen Sie schnell fest, dass die simulierten Pipes über weit weniger Flexibilität verfügen als ihre Vorbilder aus der Shell. Script unterscheidet strikt zwischen Quellen, Filtern und Senken. Quellen bilden den Ausgangspunkt der Verarbeitungskette. Dazu zählen unter anderem »Args()«, »File()« und »FindFile()«. Diese Funktionen erzeugen jeweils eine Pipe und lassen sich selbst nicht in eine Pipe einbauen. Folglich weigert sich »Args()«, einen als Parameter übergebenen Ordner an »FindFile()« weiterzureichen. Das folgende Kommando führt daher zu einem Fehler.

script.Args().FindFile().Match(".log").Stdout()

Filter wie »Match()« nehmen Eingaben entgegen und verändern sie. Anders als Quellen und Senken dürfen Sie Filter beliebig oft hintereinander schalten. Senken stehen am Ende der Pipeline und geben die durchgelaufenen Daten in irgendeiner Form aus – »Stdout()« druckt sie zum Beispiel auf die Standardausgabe.

Sollten Sie einen Filter vermissen, rüsten Sie ihn mit dem universellen »Filter()« nach. Dazu übergeben Sie »Filter()« eine eigene Funktion, die wiederum zwei Parameter entgegennimmt: Über einen Reader erhält sie die zu verarbeitenden Daten, über den übergebenen Writer gibt sie ihre Ergebnisse zurück. In Listing 3 schiebt der Filter einfach alle Daten aus der Datei »error.log« unverändert durch den Filter in die Standardausgabe.

Listing 3

Beispiel für einen eigenen Filter

script.File("error.log").Filter(func (r io.Reader, w io.Writer) error {
  _, e := io.Copy(w, r)
  return e
}).Stdout()

Übersetzungshürden

Neben Script existieren noch zahlreiche weitere hilfreiche Bibliotheken. So kopiert Copy in einem Rutsch komplette Verzeichnisbäume [4], während Cobra unter anderem beim Auswerten von Kommandozeilenparametern hilft [5] (Abbildung 2). Da der Go-Compiler sämtliche Abhängigkeiten selbst auflöst und die Bibliotheken statisch linkt, bleibt der Code in jedem Fall plattformunabhängig. Davon ausgenommen sind lediglich externe Packages, die Cgo verwenden und somit C-Code einmischen.

Abbildung 2: Cobra nimmt übergebene Parameter auseinander und hilft sogar bei der Erstellung von Manpages.

Abbildung 2: Cobra nimmt übergebene Parameter auseinander und hilft sogar bei der Erstellung von Manpages.

Go-Code müssen Sie immer zuerst durch den Go-Compiler laufen lassen, bevor Sie ihn in Form des erzeugten Programms starten dürfen. Diese zweistufige Prozedur sägt insbesondere dann an den Nerven, wenn sich der Code während der Entwicklung häufig ändert. Praktischerweise kennt der Go-Compiler das Kommando »run«, mit dem er den Go-Code in einem Rutsch übersetzt und startet: »go run logcount.go«.

Allerdings bringt dieses Vorgehen gleich mehrere Nachteile mit sich. Zum Beispiel tippen Sie immer noch mehr Zeichen ein als beim Start eines Shell-Skripts. Dessen Speicherort können Sie in die Umgebungsvariable PATH aufnehmen, womit sich das Skript von einem beliebigen Ort aufrufen lässt. Um dasselbe mit »go run« zu erreichen, müssten Sie die vom Go-Compiler verwendeten Umgebungsvariablen umständlich mehrfach umbiegen.

Weg mit der Raute!

Deutlich angenehmer wäre es, wenn man »logcount.go« direkt wie ein Shell-Skript aufrufen könnte. Damit das bei einem Shell-Skript funktioniert, deponieren Sie darin in der ersten Zeile hinter dem sogenannten Shebang den zu verwendenden Interpreter, etwa »#!/bin/bash«. Das Linux-System wertet diese erste Zeile aus und startet im Beispiel das Shell-Skript mit der Bash. Man könnte jetzt auf die Idee kommen, einfach im Go-Code ein passendes Shebang in der ersten Zeile zu hinterlegen: »#!/usr/bin/go run«.

Abbildung 3: Eine Shebang-Zeile am Anfang des Go-Quellcodes verwirrt den Go-Compiler, der umgehend mit einem Fehler aussteigt.

Abbildung 3: Eine Shebang-Zeile am Anfang des Go-Quellcodes verwirrt den Go-Compiler, der umgehend mit einem Fehler aussteigt.

Kennzeichnen Sie die zugehörige Datei ».go« als ausführbar und rufen dieses Go-Skript auf, erhalten Sie den Fehler aus Abbildung 3. Die Shell wertet zunächst die erste Zeile aus und stößt wie gewünscht »go run« an. Diesem Befehl übergibt die Shell das komplette Go-Skript. Dessen Code knöpft sich der Go-Compiler vor, der direkt über die erste Zeile mit dem Shebang stolpert. Bereits das Rautenzeichen »#« stellt keinen gültigen Go-Code dar, was der Compiler bemängelt und seinen Dienst quittiert. Mit einigen Kniffen verändern Sie die Shebang-Zeile jedoch so, dass sie sowohl der Shell als auch dem Go-Compiler schmeckt.

///usr/bin/go run "$0" "$@"; exit "$?"

Die beiden ersten Schrägstriche leiten für den Go-Compiler einen Kommentar ein, sodass er den Rest der Zeile ignoriert. Für die Shell handelt es sich hingegen um ein Kommando. Den dreifachen Schrägstrich »///« interpretiert sie dabei als einen einzigen, sodass sich der Pfad zum Programm »go« ergibt. Diesem übergibt die Shell das Kommando »run«, gefolgt vom Namen des Go-Skripts sowie allen übergebenen Parametern. Der Name des aufgerufenen Go-Skripts steckt in der Shell-Variablen »$0«, die an das Go-Skript übergebenen Parameter lagern in »$@«.

Sobald der Go-Compiler seine Arbeit verrichtet hat, übernimmt wieder die Shell, die umgehend und unermüdlich die übrigen Zeilen im Go-Skript abarbeitet. Im Fall von Listing 2 würde die Shell versuchen, »package main« aufzurufen, und damit krachend scheitern. Um das zu verhindern, bricht im modifizierten Shebang »exit« die Verarbeitung der Shell vorzeitig ab.

Die alternative Shebang-Zeile entstand durch die Zusammenarbeit in der Community, vor allem in entsprechenden Diskussionen auf Stackoverflow [6]. Dort finden sich zudem weitere Modifikationen und Anregungen. Eine gute Zusammenfassung liefert der Entwickler Eyal Posener auf seiner GitHub-Seite [7]. Ein Problem beseitigt die optimierte erste Zeile allerdings nicht: den falschen Exit-Code.

Geh-Hilfe

Sobald sich ein Programm beendet, gibt es an die Shell einen Exit-Code zurück. Mit ihm meldet das Programm, ob es bei seiner Aufgabe erfolgreich war oder ob ein Fehler auftrat. Wenn Sie »go run logcount.go« aufrufen, erhalten Sie den Exit-Code von »go run« und nicht den des Go-Programms »logcount.go«. Sie erfahren also ausschließlich, ob der Compiler erfolgreich gearbeitet hat. Auch die (modifizierte) Shebang-Zeile nutzt »go run«, weshalb das komplette Go-Skript ebenfalls nur den Exit-Code des Compilers zurückliefert.

Abhilfe schafft das Tool Gorun [8]. Es erlaubt Ihnen, die herkömmliche Shebang-Zeile am Anfang eines Go-Programms zu verwenden: »#!/usr/bin/gorun«. Mit ihr aktiviert die Shell beim Start des Go-Skripts Gorun. Das kleine Werkzeug entfernt die erste Zeile aus dem Quellcode und verfüttert den Rest an den Go-Compiler. Das dabei erzeugte Binary ruft Gorun auf, alle Parameter reicht das Werkzeug passend weiter. Die Anwendung sorgt außerdem dafür, dass das Go-Skript seinen eigenen Exit-Code zurückliefert.

Da Gorun kaum einer Distribution beiliegt, müssen Sie die Software per Hand auf allen Systemen installieren, auf denen das Go-Skript zum Einsatz kommen soll. Durch die Shebang-Zeile lässt sich der Code außerdem nicht mehr direkt an den Go-Compiler übergeben. Dank eines Tricks verzichten Sie aber zumindest auf Linux-Systemen auf die Shebang-Zeile.

Kernel-Tricks

Mithilfe weniger Handgriffe sieht der Linux-Kernel alle Dateien mit der Endung ».go« als Programme an und führt sie bei ihrem Aufruf automatisch mit Gorun aus. Das Verfahren wandte das Unternehmen Cloudflare bereits 2018 an. In einem Blog-Beitrag [9] finden Sie ausführliche Informationen dazu. Ausgangspunkt ist das Kernel-Modul »binfmt_misc«, das Programme anhand ihrer Dateinamenserweiterung erkennt und anschließend ein hinterlegtes Wrapper-Programm startet. Die Konfiguration gelingt über die Datei »/proc/sys/fs/binfmt_misc/register«. In sie schreibt man die gewünschte Einstellung in einem relativ unleserlichen Format. Der für die Go-Skripte notwendige Befehl lautet:

echo ':golang:E::go::/usr/bin/gorun:OC'\
  | sudo tee /proc/sys/fs/binfmt_misc/register\
    :golang:E::go::/usr/bin/gorun:OC

Der Abschnitt »/usr/bin/gorun« steht darin für den Pfad zu Gorun. Wie das »sudo« im Befehl andeutet, erfordern die Anpassungen Root-Rechte. Doch diese lassen sich nicht auf jedem System erlangen. Darüber hinaus überdauern die Einstellungen keinen Systemneustart.

Weitere Hürden

Damit das Go-Skript auf anderen Systemen läuft, benötigt man auch dort den Go-Compiler in der passenden Version. Der Compiler selbst belegt zwar nur wenig Platz, steht aber nicht immer zur Verfügung. Darüber hinaus muss der Compiler den Quellcode übersetzen können. Dazu benötigt er Schreibrechte, die vor allem auf Embedded-Systemen fehlen. Während der Übersetzung lädt der Go-Compiler zudem automatisch alle benötigten Bibliotheken herunter, folglich ist auf dem jeweiligen System eine Internetverbindung Pflicht.

Aufgrund dieser Hürden empfiehlt es sich, auf die Shebang-Zeile zu verzichten und den Go-Code auf klassischem Weg zu übersetzen. Das erfordert zwar während der Entwicklung zusätzliche Handgriffe, das erzeugte Binary besitzt aber keine Abhängigkeiten und lässt sich leichter auf Embedded-Systeme verteilen. Dank der eingebauten Cross-Compiler-Fähigkeit von Go erzeugen Sie auf dem eigenen Arbeitssystem die Fassungen für verschiedene Architekturen. Der Compiler ist mit dem Kommando »go install« sogar in der Lage, das erstellte Programm zu bauen und anschließend im System zu installieren.

Fazit

Wer Go beherrscht, kann mit der Programmiersprache recht komfortabel Shell-Skripte ersetzen. Bibliotheken wie Script sparen Tipparbeit und sorgen für knackigen, lesbaren Code. Trotz dieser Hilfen bleibt der Go-Quellcode länger als entsprechende Shell-Pendants. Des Weiteren ist der Go-Compiler nicht für den Skript-Betrieb ausgelegt. Um den Go-Code wie Shell-Skripte zu starten, müssen Sie in der ersten Zeile tricksen oder das Tool Gorun heranziehen. Beides können Sie sich jedoch sparen, wenn Sie alte Shell-Denkmuster ablegen und den Go-Code direkt in ein leicht zu verteilendes Binary gießen. (csi)

Schritt für Schritt

Anders als etwa Python und Perl kennt Go keinen interaktiven Modus (Read-Eval-Print-Loop, REPL). Man kann folglich nicht an einem speziellen Prompt einzelne Befehle eintippen, die dann Go jeweils umgehend auswertet. Einige Entwickler versuchen, dieses Manko mit einem eigenen Go-Interpreter zu beheben.

Den Anfang machten Neugram [10] und Gomacro [11]. Beide Interpreter unterstützen jedoch nicht die komplette Go-Syntax und liegen schon seit Längerem auf Eis. Aktiv voran treibt derzeit das Traefik-Projekt seinen Go-Interpreter Yaegi [12]. Trotz der niedrigen Versionsnummer lässt er sich bereits produktiv einsetzen, versteht die komplette Go-Spezifikation und lässt sich sogar bequem in eigene Go-Programme einbetten (Abbildung 4). Dort führt dann die von Yaegi angebotene Funktion »Eval()« je nach Bedarf eine oder mehrere Go-Codezeilen aus. Auf diesem Weg ergänzen Sie die eigene Software leicht um Skripting-Möglichkeiten.

Abbildung 4: Yaegi bietet einen Interpeter mit REPL-Modus, der wie hier einzelne Go-Befehle direkt ausführt.

Abbildung 4: Yaegi bietet einen Interpeter mit REPL-Modus, der wie hier einzelne Go-Befehle direkt ausführt.

Infos

  1. Energieeffiziente Software: Tim Schürmann, “Go Green?”, LM 03/2023, S. 30, https://www.lm-online.de/48614
  2. Script: https://github.com/bitfield/script
  3. Go Templates: https://pkg.go.dev/text/template
  4. Copy: https://pkg.go.dev/github.com/otiai10/copy
  5. Cobra: https://cobra.dev/
  6. Stackoverflow – What’s the appropriate Go shebang line?: https://stackoverflow.com/questions/7707178/whats-the-appropriate-go-shebang-line
  7. Story: Writing Scripts with Go, Eyal Posener: https://gist.github.com/posener/73ffd326d88483df6b1cb66e8ed1e0bd
  8. Gorun: https://github.com/erning/gorun
  9. Cloudflare – Using Go as a scripting language in Linux: https://blog.cloudflare.com/using-go-as-a-scripting-language-in-linux/
  10. Neugram: https://github.com/neugram/ng
  11. Gomacro: https://github.com/cosmos72/gomacro
  12. Yaegi: https://github.com/traefik/yaegi
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 5 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