Engineering Blog
BackCorrect quoting of non-interactive SSH commands
This content is only available in German:
Ich stolpere immer wieder mal über ungenügendes Quoting und Escaping von Kommandos, die ich via SSH ausführen will. In diesem Artikel versuche ich zu beischreiben, wie das Problem genau auftritt, und wie das Problem mithilfe von Features von modernen Shells automatisiert gelöst werden kann.
Eine einfache Aufgabe
Ich verwende eine typische, modernen Unix-Shell und habe ein einfaches Ziel: Ich möchte mithilfe von grep
alle Skripts unter /etc/init.d
finden, welche den String case "$1" in
enthalten:
michi@myserver$ grep -RF 'case "$1" in' /etc/init.d
/etc/init.d/udev:case "$1" in
/etc/init.d/nullmailer:case "$1" in
/etc/init.d/dbus:case "$1" in
/etc/init.d/haveged:case "$1" in
...
Ich verwende Bash 5.2. Eine andere populäre Shell unter Linux-Benutzer*innen ist die Z shell (
zsh
). Alles hier beschriebene funktioniert auch unter der Z shell. Wo Unterschiede bestehen, werde ich das anmerken.
Das funktioniert gut. Nun möchte ich das Gleiche automatisiert auf mehreren Servern tun. OpenSSH erlaubt es, auf der Kommandozeile, nach allen anderen Argumenten, ein Kommando anzugeben. Das Kommando wird auf dem Server ausgeführt, dann wir die Verbindung wieder getrennt:
$ ssh myserver uname -sr
Linux 5.4.0-165-generic
Also kann ich folgendes tun:
$ ssh myserver grep -RF 'case "$1" in' /etc/init.d
grep: : No such file or directory
grep: in: No such file or directory
/etc/init.d/udev: case "$type" in
/etc/init.d/udev:case "$1" in
Leider funktioniert das nicht, oder nur so halb. Es sieht aus, als wäre grep
mit ganz anderen Argumenten aufgerufen worden, als ich angegeben habe. Um dem auf den Grund zu gehen, werde ich die folgende Shell-Funktion verwenden. Diese füge ich am Anfang von meinem ~/.bashrc
(~/.zshrc
unter der Z shell) ein, sowohl lokal als auch auf einem der Server, auf die ich mich verbinden möchte:
show-args() {
printf "%s\n" "$@" | nl -ba
}
show-args
gibt mir die Möglichkeit, statt ein Kommando auszuführen, zu sehen, welche Argumente an das Programm übergeben worden wären. Wenn ich das lokal teste, sehe ich folgendes:
$ show-args grep -RF 'case "$1" in' /etc/init.d
1 grep
2 -RF
3 case "$1" in
4 /etc/init.d
grep
würde also mit 3 Argumenten aufgerufen. Den Flags, dem String, nach dem gesucht werden soll, und dem Verzeichnispfad, welcher durchsucht werden soll. Dass dieses Kommando an ssh
übergeben wird, ändert daran nichts. ssh
wird mit grep
als Kommando und den gleichen 3 Argumenten aufgerufen:
$ show-args ssh myserver grep -RF 'case "$1" in' /etc/init.d
1 ssh
2 myserver
3 grep
4 -RF
5 case "$1" in
6 /etc/init.d
Also muss das Problem auf der anderen Seite liegen. Indem wir show-args
und ssh myserver
auf der Kommandozeile vertauschen, können wir überprüfen, wie grep
auf dem Server aufgerufen wird:
$ ssh myserver show-args grep -RF 'case "$1" in' /etc/init.d
1 grep
2 -RF
3 case
4
5 in
6 /etc/init.d
Der Such-String case "$1" in
wurde also aufgeteilt in 3 Argumente, als wäre dieser ohne Anführungszeichen geschrieben worden. Was ist hier passiert?
SSH und nicht-interaktive Kommandos
Die Manpage von ssh gibt uns einen Hinweis für das beobachtete Verhalten:
If a command is specified, it will be executed on the remote host instead of a login shell. A complete command line may be specified as command, or it may have additional arguments. If supplied, the arguments will be appended to the command, separated by spaces, before it is sent to the server to be executed.
Wenn ssh
mit einem auszuführenden Kommando aufgerufen wird, verwendet der SSH-client den "exec"
-Request (RFC 4254, Abschnitt 6.5). Dieser sieht vor, dass ein einzelner String als Kommando mitgegeben wird. Auf dem Server wird dieser dann an die Login-Shell übergeben und von dieser ausgeführt.
ssh
erhält als Kommando 5 Argumenteshow-args
,grep
,-RF
,case "$1" in
und/etc/init.d
. Hier ist wichtig zu bemerken, dass die einfachen Anführungszeichen (''
) nicht teil des 3. Arguments sind. Diese wurden von der lokal laufenden Shell bereits entfernt.ssh
fügt die Argumente zu einem String zusammen:show-args grep -RF case "$1" in /etc/init.d
. Dieser wird via der SSH-Verbindung an den Server geschickt.- Die Shell auf dem Server parst diesen String und macht daraus 7 Teile:
show-args
,grep
,-RF
,case
,"$1"
,in
und/etc/init.d
. - Da die einfachen Anführungszeichen entfernt wurden, wird das
$1
als Verwendung einer Variable behandelt. Da diese Variable nicht gesetzt ist, wird sie durch den leeren String ersetzt. Das Resultat ist ein Argument der Länge 0. - Die Funktion
show-args
wird mit den restlichen 6 Argumenten aufgerufen.
Teil des Problems ist, dass das eingetippte Kommando zweimal geparst wird, einmal von der lokalen Shell und ein zweites Mal von der Shell auf dem Server. Die einfachen Anführungszeichen um 'case "$1" in'
reichen also nicht aus. Eine Möglichkeit ist, das Argument in eine zweite Klammerung an Anführungszeichen einzupacken. Zusätzlich müssen alle Zeichen, die für eine Unix-Shell eine spezielle bedeutung haben, mit \
escaped werden:
$ ssh myserver show-args grep -RF "'case \"\$1\" in'" /etc/init.d
1 grep
2 -RF
3 case "$1" in
4 /etc/init.d
Nun wird das Kommando beim Parsen auf dem Server also in die gewünschten 4 Teile unterteilt. Und wenn wir show-args
aus dem Kommando wieder entfernen, funktioniert das Kommando auch wie gewünscht:
$ ssh myserver grep -RF "'case \"\$1\" in'" /etc/init.d
/etc/init.d/udev:case "$1" in
/etc/init.d/nullmailer:case "$1" in
/etc/init.d/dbus:case "$1" in
/etc/init.d/haveged:case "$1" in
...
Eine automatisierte Lösung
Das Quoting und Escaping im letzten Beispiel hat funktioniert, aber es kann mühsam und unübersichtlich werden.
Da ich häufiger in diese Situation komme, habe ich mir ein einfaches Werkzeug gebaut, dass das Problem etwas abschwächt: Ich verwende eine weitere Shell-Funktion, welche ich q
(für "Quoting") nenne:
# Bash
q() { echo "${@@Q}"; }
# Z shell
q() { echo "${(q)@}"; }
Diese Funktion kann auch wieder in ~/.bashrc
bzw. ~/.zshrc
abgelegt werden.
Die Funktion verwendet ein Feature der jeweiligen Shell um Strings automatisch zu quoten (Bash Manual, Shell Parameter Expansion bzw. Z shell Manual, Parameter Expansion Flags). Der Q-Operator macht sozusagen das Entfernen von Anführungszeichen rückgängig, das beim Parsen durch die Shell passiert.
Um die Funktion anzuwenden, muss einfach das ganze Kommando, welches an ssh
übergeben wird, in $(q ...)
eingepackt werden. Keine zusätzlichen Anführungszeichen sind notwendig:
$ ssh myserver show-args $(q grep -RF 'case "$1" in' /etc/init.d)
1 grep
2 -RF
3 case "$1" in
4 /etc/init.d
$ ssh myserver $(q grep -RF 'case "$1" in' /etc/init.d)
/etc/init.d/udev:case "$1" in
/etc/init.d/nullmailer:case "$1" in
/etc/init.d/dbus:case "$1" in
/etc/init.d/haveged:case "$1" in
...
Ein Anwendungsfall, bei dem dies sehr praktisch sein kann, ist, wenn in das Kommando Variablen eingesetzt werden sollen:
$ for i in '##' '$1' 'a > b'; do
> ssh myserver $(q echo "\$i = $i");
> done
$i = ##
$i = $1
$i = a > b
Viele weitere Anwendungen sind möglich, z.B. kann der Output von q
in eine Datei geschrieben werden, welche später ausgeführt werden kann.
If you have comments or corrections to share, you can reach our engineers at engineering-blog@cloudscale.ch.