Shellscripting

Shellscripts sind nichts anderes als Textdateien, die Shellkommandos enthalten. Wird die Datei ausgeführt, werden einfach alle darin enthaltenen Befehle ausgeführt. Shellscripts sind daher praktisch um sich wiederholende Aufgaben zu automatisieren und zu vereinfachen.

Jede Scriptdatei (nicht nur Shellscripts, sondern auch z. B. Perlscripts) enthält als erste Zeile die sogenannte Shebang-Zeile. Diese Zeile gibt dem System bekannt, mit welchen Kommandointerpreter, das Script ausgeführt wird, also gewissermaßen in welcher Sprache das Script geschrieben ist. Die Shebang-Zeile kann bei einem Shellscript etwa so aussehen:

#!/bin/sh

Die Zeichen #! stehen immer am Anfang der Shebang-Zeile. /bin/sh gibt den Pfad zum Kommandointerpreter bekannt. Jedes Unixsystem muß unter /bin/sh eine Bourne-Shell kompatible Shell installiert haben. Unter Linux ist /bin/sh meistens ein Link auf /bin/bash also die Bash-Shell. Auf einem Linuxsystem kann man daher auch #!/bin/bash verwenden.

Normalerweise steht das #-Zeichen für eine Kommentarzeile, die nicht weiter beachtet wird. Speziell in der ersten Zeile als Shebang-Zeile ist dies jedoch eine Ausnahme.

Ein erstes einfaches Script

Als erstes einfaches Script möchte ich ein Backup-Script schreiben. Wenn ich annehme das ich eine Externe Festplatte angeschlossen habe, welche unter /media/hdd gemountet werden kann und mehrere Partitionen habe, welche ich alle per tar jeweils pro Partition in eine Tardatei sichern will könnte ich folgendes Shellscript schreiben:

#!/bin/sh

# Backupscript

mount /media/hdd

tar czf /media/hdd/root.tar.gz /
tar czf /media/hdd/boot.tar.gz /boot
tar czf /media/hdd/usr.tar.gz /usr
tar czf /media/hdd/var.tar.gz /var
tar czf /media/hdd/home.tar.gz /home

umount /media/hdd

Ich speichere die Datei z. B. unter den Namen backup ab. Wenn ich allerdings versuche das Script auszuführen erhalte ich eine Fehlermeldung. Dies passiert weil die Datei nicht ausführbar ist. Normale Dateien werden per Default ohne Ausführrechte angelegt, daher muß ich die Datei erst ausführbar machen:

chmod +x backup

Danach kann ich die Scriptdatei, sofern ich mich im gleichen Verzeichnis wie die Datei befinde einfach so aufrufen:

./backup

Befinde ich mich in einem anderen Verzeichnis, muß ich den kompletten Pfad auf die Scriptdatei mit angeben (außer ich kopiere die Scriptdatei in ein Verzeichnis, das in der Umgebungsvariable PATH enthalten ist).

Dieses Script ist schon ganz praktisch, bietet aber auch noch viel Raum für Verbesserungen. So ist es z. B. unpraktisch den Pfad wohin die Backupdateien kopiert werden überall fest hinein zu schreiben. Möchte ich diesen ändern, muß ich das an mehreren Stellen machen. Daher empfiehlt es sich dafür eine am Anfang definierte Variable zu verwenden:

#!/bin/sh

# Backupscript

backuppath=/media/hdd

mount ${backuppath}

tar czf ${backuppath}/root.tar.gz /
tar czf ${backuppath}/boot.tar.gz /boot
tar czf ${backuppath}/usr.tar.gz /usr
tar czf ${backuppath}/var.tar.gz /var
tar czf ${backuppath}/home.tar.gz /home

umount ${backuppath}

Sollte sich jetzt der Pfad mal ändern muß ich dies nur zentral an einer Stelle machen.

If-Bedingungen

Um in Scripts auf Bedingungen reagieren zu können, gibt es den if-Befehl. Der if-Befehl führt einen Block von Befehlen nur aus wenn seine Bedingung wahr ist. Optional kann auch noch ein Block von Befehlen ausgeführt werden wenn eine Bedingung nicht wahr ist. Die Syntax sieht so aus:

if Bedingung
then
Befehle wenn Bedingung wahr
else
Befehle wenn Bedingung nicht wahr
fi

Der Teil nach else ist optional. Ein fi muß jedoch immer den if-Block abschließen. Als Bedingung wird meistens ein test-Befehl verwendet. Die eckigen Klammern als alternative Schreibweise des test-Befehls passen hier gut zur Klammerung der Bedingung, des if-Befehls in anderen Programmiersprachen:

if [ -f ~/datei ]
then
rm ~/datei
fi

In diesem Fall wird erst getestet ob eine Datei existiert und wenn dies der Fall ist wird sie gelöscht. Das gleiche hätte man natürlich auch schneller so schreiben können:

[ -f ~/datei ] && rm ~/datei

Sobald man aber mal mehrere Befehle abhängig von einer Bedingung ausführen möchte ist die Schreibweise mit dem if-Befehl besser.

Exit-Status

Jeder Befehl beendet sich mit einem Exitstatus. Ist der Exitstatus gleich 0 war der Befehl erfolgreich, ist er ungleich 0 war er nicht erfolgreich. Der Exitstatus des letzten Befehls steht in der Spezialvariablen $?. Zusammen mit einer If-Bedingung läßt sich damit auf den Exitstatus des letzten Kommandos innerhalb eines Scripts reagieren. Ich kann also testen ob bestimmte Befehle, die kritisch für die Ausführung meines Scripts sind funktioniert haben und wenn dies nicht der Fall ist entsprechend darauf reagieren:

#!/bin/sh

# Backupscript

backuppath=/media/hdd

mount ${backuppath}
if [ $? -ne 0 ]
then
   echo "Mounten fehlgeschlagen" >&2
   exit 1
fi

tar czf ${backuppath}/root.tar.gz /
tar czf ${backuppath}/boot.tar.gz /boot
tar czf ${backuppath}/usr.tar.gz /usr
tar czf ${backuppath}/var.tar.gz /var
tar czf ${backuppath}/home.tar.gz /home

umount ${backuppath}

Ich überprüfe hier direkt nach meinem mount-Befehl ob der Existstatus ungleich 0 ist. Ist er ungleich 0, weiß ich das ein Fehler aufgetreten ist und gebe eine Fehlermeldung aus. Normalerweise gibt echo seine Meldungen auf dem Standardausgabekanal aus. Da es sich um eine Fehlermeldung handelt, möchte ich das echo die Meldung auf dem Standardfehlerkanal ausgibt. Dies erreiche ich indem ich die Meldung auf Kanal Nr. 2 umleite mit folgender Syntax: >&2.

Danach weise ich das Script noch über den Befehl exit an sich zu beenden, da der Fehler kritisch für die Ausführung des Scripts ist. Die Zahl nach dem exit steht für den Exitstatus, den das Script zurück gibt. Normalerweise entspricht der Exitstatus eines Scripts, dem Exitstatus des letzten ausgeführten Befehls. Da wir das Script aber mit einem Fehler beenden, muß der Exitstatus explizit auf einen Wert ungleich 0 gesetzt werden.

Den Block aus Befehlen innerhalb meiner if-Bedingung habe ich eingerückt. Dies ist nicht zwingend vorgeschrieben, dient aber der Übersichtlichkeit und entspricht guten Programmierstil.

case-Bedingungen

Manchmal möchte man eine bestimmte Variable auf mehrere Werte hin prüfen und je nach Wert eine andere Aktion ausführen. Sie können dafür natürlich auch eine lange Kette an if-Bedingungen schreiben, effizienter ist in diesen Fall aber eine case-Bedingung. Die case-Bedingung enthält Anweisungsblöcke je nach Wert einer Variablen. Dies sieht z. B. so aus:

case "$var" in
muster)
   befehl
;;
muster2)
   befehl
;;
esac

Eingeleitet wird die case-Bedingung mit case, abgeschlossen mit esac. Überprüft wird jeweils ob muster auf den Inhalt von $var paßt. muster wird immer mit einer runden Klammer zu begrenzt ). Danach folgt der Befehlsblock der ausgeführt wird, wenn muster zutrifft, abgeschlossen von 2 Strichpunkten ;;.

Verwendet werden case-Bedingung z. B. in Start/Stop-Scripts für Dienste, welchen einfach einen Anweisungsblock enthalten falls als Parameter start angegeben wurde, und einen weiteren Anweisungsblock falls stop angegeben wurde. Dies kann z. B. so aussehen:

#!/bin/sh

dienst=/usr/local/sbin/myserverd
params="-D -c /etc/myserverd/config"

case "$1" in
start)
   $dienst "$params"
   [ $? -eq 0 ] && echo "$dienst gestartet"
;;
stop)
   pkill $dienst
   [ $? -eq 0 ] && echo "$dienst gestoppt"
;;
restart)
   $0 stop
   $0 start
;;
*)
   echo "Usage: $0 (start|stop|restart)"
;;
esac

Der erste Parameter der dem Script übergeben wird ist in $1 enthalten. Es wird überprüft ob der Inahlt von $1 start, stop oder restart ist. Ist der Inhalt von $1 keines der 3 angegeben Muster wird der Anweisungsblock beim Muster *) ausgeführt. Dieser trifft einfach auf alles zu. Hier wird ein Benutzungshinweis gegeben.

Bei dem Anweisungsblock für restart habe ich die Spezialvariable $0 verwendet. Sie beinhaltet das aufgerufene Kommando, also der Name des Scripts selbst. Wird $0 als Kommando innerhalb eines Scripts aufgerufen, ruft sich das Script selber als Unterprozess auf.

Das Kommando pkill, das ich zum stoppen des Dienstes verwendet habe, ist übrigens nicht auf jedem System vorhanden. Es sendet ein Kill-Signal an den Prozess mit dem angegebenen Namen (das kill-Kommando versteht keine Namen, nur Prozessnummern). je nach Dienst könnte es natürlich auch sein, das der Dienst ein eigenes Kommando zum starten/stoppen zur Verfügung stellt, wie etwa apachectl für Apache.

Eingabe

Manchmal möchte man seinem Script variable Daten zur Verfügung stellen, also Eingaben machen. Eine Form der Eingabe hatte ich bereits bei den Funktionen vorgestellt: Die Angabe von Parametern beim Scriptaufruf. Diese landen dann in den Variablen $1, $2, .. bis $9. Eine andere Methode ist eine Abfrage direkt im Script. Das Script soll also eine Frage ausgeben und dann auf eine Eingabe warten und erst wieder weiterarbeiten, wenn eine Eingabe gemacht wurde. Dies geht mit dem Befehl read. Ein Beispiel:

#!/bin/sh

echo "Wie geht es Ihnen?"
read zustand

echo "Ihnen geht es also heute $zustand"

Dem Kommando read muß ein Variablenname mitgegeben werden. In dieser Variable wird die Eingabe gespeichert. Mitunter möchten Sie die Frage auf der gleichen Zeile haben wie ihre Eingabe. In so einem Fall gibt es je nach Shell unterschiedliche Methoden (read ist in Shell Builtin Kommando). In der bash können sie den Parameter -p für read benutzen um auch gleich eine Frage mit anzugeben:

#!/bin/bash

read -p "Wie geht es Ihnen? " zustand

echo "Ihnen geht es also heute $zustand"

In anderen Shells, wie etwa der ksh hat der Parameter -p eine andere Bedeutung. Hier kann eventuell für das echo-Kommando der Parameter -n verwendet werden, welcher den abschließenden Zeilenumbruch nach echo verhindert.

while-Schleifen

Die Shell kennt auch Schleifen. Die while-Schleife hat eine einfache Syntax:

while ausdruck
do
befehle
done

Dabei wird geprüft ob ausdruck wahr ist (oder besser gesagt ob der Existstatus von ausdruck gleich 0 ist). Solange ausdruck wahr ist, wird der Schleifeninhalt ausgeführt. Häufig wird für den Ausdruck genau wie bei der if-Bedingung eine test-Anweisung benutzt:

#!/bin/sh

zaehler=1

while [ $zaehler -lt 11 ]
do
   echo $zaehler
   zaehler=$(($zaehler+1))
done

Dieses Script realisiert einen Zähler. Die Variable $zaehler wird mit dem Wert 1 initalisiert. Die while-Schleife fragt ab ob $zaehler kleiner als 11 ist. Ist dies der Fall wird $zaehler ausgegeben und danach um 1 erhöht. Das Script zählt also von 1 bis 10.

Die Erhöhung des Zählers geschah mit der Syntax $((Rechenaufgabe)). Die Shell kann im Gegensatz zu vielen anderen Scriptsprachen nicht direkt Rechnen. Sie benötigt dazu entweder einen externen Befehl oder für Berechnungen mit Ganzzahlen kann man auch das Konstrukt $(()) verwenden, wobei alle Grundrechenarten funktionieren. Als externer Befehl zum Rechnen läßt sich auch bc verwenden (siehe man bc).

Datei einlesen

Manchmal möchte man in einem Script eine komplette Datei einlesen um den Inhalt Zeile für Zeile zu bearbeiten. Auch dies läßt sich mit einer while-Schleife erledigen, zusammen mit einer Eingabeumleitung:

while read var
do
befehle
done < datei

Durch die Eingabeumlenkung wird der Inhalt von datei an den read Befehl weitergegeben. Dieser speichert jeweils eine Zeile in der Variablen var ab, welche dann im Schleifenkörper bearbeitet werden kann. Ist die Datei zu Ende schlägt der Befehl read fehl und damit ist die Schleifenbedingung unwahr, womit der Schleifenköper nicht mehr weiter ausgeführt wird.

for-Schleifen

Die for-Schleife funktioniert in der Shell ein wenig anders als in den meisten Scriptsprachen. Die for-Schleife dient zum durchlaufen einer Liste von Elementen. Die Syntax sieht so aus:

for var in liste
do
befehle
done

Dabei kann liste alles sein was eben eine Liste von Elementen erzeugt, wie etwa ein Befehl der eine Liste von Elementen ausgibt oder ein Globbing-Ausdruck für die Shell mit einer Liste von Dateien. Beim Schleifendurchlauf wird das jeweils aktuelle Element von liste in der Variablen var gespeichert und kann in den Befehlen innerhalb der Schleife verwendet werden.

Nehmen wir z. B. an sie haben eine Reihe von Dateien nach dem Muster dataXY.txt, wobei XY für eine zweistellige Zahl steht. An alle diese Dateien möchte sie am Ende noch eine Zeile Text anhängen und diese dann nach dem Muster NEUdataXY.txt umbennen:

#!/bin/sh

for datei in data??.txt
do
   echo "Neue Zeile" >> "$datei"
   mv "$datei" "NEU${datei}"
done

Menüs mit select

Es gibt in der Shell noch einen sehr speziellen Schleifentyp: Die select-Schleife. Die select-Schleife dient speziell dazu Menüstrukturen einfach und schnell zu bilden. Mitunter möchten sie in einem Shellscript eine Auswahl zwischen mehreren vorgebenen Antwortmöglichkeiten anbieten. Am einfachsten läßt sich dies mit select umsetzen. Die select-Schleife hat eine Syntax wie die for-Schleife:

select var in liste
do
befehle
done

Dabei muß liste die Auswahlpunkte enthalten. Die Eingabe des Benutzers wird in der Variablen var gespeichert. Die Menüpunkte werden mit Nummern versehen und die Auswahl geschieht durch die Nummer. select funktioniert als Endlosschleife und kehrt immer wieder zum Auswahlmenü zurück, sofern sie nicht einen Befehl einbauen der aus der Schleife aussteigt. Als Schleifenausstiegsbefehl läßt sich break verwenden:

#!/bin/sh

echo "Was ist ihr Lieblingsessen?"
select essen in Pizza Kuchen Brot Nudeln Reis beenden
do
   if [ $essen = beenden ]
   then
      break
   fi
   echo "Ihr Lieblingsessen ist also $essen"
done

Der Frageprompt "#?" ist der Inhalt der Variablen PS3, welcher mit #? vorbelegt ist, aber auch verändert werden kann:

#!/bin/sh

PS3="Geben Sie die Nr. ein: "
echo "Was ist ihr Lieblingsessen?"
select essen in Pizza Kuchen Brot Nudeln Reis beenden
do
   if [ $essen = beenden ]
   then
      break
   fi
   echo "Ihr Lieblingsessen ist also $essen"
done

Befehlsausgaben weiterverwenden

Manchmal möchte man die Ausgabe eines Kommandos weiterverwenden, z. B. in eine Variable speichern. Dies funktioniert indem man den Befehl einfach in Backticks setzt:

var=`kommando`

Dies wird auch als Kommando-Substitution bezeichnet.

Sicherheitstips

Shellscripts sind genauso wie andere Programme auch anfällig für Sicherheitsprobleme durch Programmierfehler. Daher ist es z. B. unter Linux nicht möglich ein Shellscript über das SUID-Bit durch einen Benutzer mit root-Rechten laufen zu lassen. Der Linuxkernel ignoriert ein gesetztes SUID-Bit bei Shellscripts.

Manchmal benötigt man in Shellscripts temporäre Dateien um kurz etwas abzuspeichern. Falls nun ein anderer Benutzer des Systems im vorraus weiß wie diese temporäre Datei heißen wird, könnte er diese unmittelbar bevor sie im Script angelegt werden soll, selber bereits als Symlink auf eine eigene Datei anlegen und alles mitlesen, was sie in diese Datei schreiben. Dies ist möglich da im temporären Verzeichnis /tmp jeder Benutzer Schreibrechte hat. Um dies zu verhindern muß man für seine Datei einen Namen wählen der vom Angreifer nicht vorhergesehen werden kann, also einen zufälligen Namen. Zu genau diesem Zweck existiert das Programm mktemp. Es erstellt einen zufälligen Dateinamen unter /tmp und liefert diesen als Rückgabewert zurück. Sie können dies z. B. so verwenden:

tempdatei=`mktemp`
echo "Schreibe Daten" > $tempdatei

Literatur

Patrick Ditchen: Shell-Script Programmierung, Mitp-Verlag, 2006.


René Maroufi, dozent (at) maruweb.de
Creative Commons By ND