In echt jetzt? (Linux-Magazin, Juni 2021)

Seit Anbeginn der Unix-Zeit ist die Shell einzigartig darin, wie einfach sie es macht, Kommandos miteinander zu verknüpfen. Die Ausgabe des einen Kommandos zur Eingabe des anderen zu leiten ist ein Kinderspiel, und zusammenhängende Abläufe in Skripts zu verpacken hilft spielerisch sukzessive immer komplexere Probleme automatisiert zu knacken.

Wer sich die Mühe macht, einmal hinter die Kulissen der simpel erscheinenden Oberfläche eines Terminal-Windows zu blicken, entdeckt, dass dort erstaunlich komplexe Vorgänge ablaufen, die aus der Steinzeit der Datenverarbeitung stammen und sich über Dekaden praktisch unverändert gehalten haben.

Während der User nämlich auf der Tastatur herumtippt und das Terminalfenster die Werte der eingegebenen Zeichen sowie die Ausgabe abgeschickter Kommandos anzeigt, springt jedes Mal der Kernel dazwischen. Die laufende Shell ist über einen Device-Eintrag wie zum Beispiel /dev/pts/0 mit dem Kernel verbunden. Dahinter hängt ein sogenanntes Tty, ein Relikt aus Zeiten dummer Terminals, die über eine serielle Schnittstelle mit dem Rechner verbunden waren, ausgegebene Zeichen darstellten und die Tastendrücke des Users zurücklieferten.

Heute lesen Applikationen aus der Device-Datei, was der User getippt hat, und schreiben hinein, was auf dem Terminal erscheinen soll. Der Kernel übernimmt dann die teils komplizierten Details.

Seltenes Tier tty

Was ist nun eigentlich ein Tty? Eigentlich nur eine aktive Kernelfunktion, die auf einem Device-Eintrag wie zum Beispiel /dev/pts/0 in den User-Raum hineinspitzelt und dort zwei Dinge erledigt: Einmal reicht der Kernel Tastatureingaben des Users, die ihm intern über eine USB- oder Bluetooth-Schnittstelle vorliegen, an den Device-Eintrag weiter, von wo aus eine Applikation im User-Space sie lesend entgegennehmen kann. Und zweitens kann die Applikation, meist über ihre Standardausgabe, auf den Device-Eintrag schreibend zugreifen, worauf der Kernel diese Daten entgegennimmt und an das Ausgabe-Terminal weiterreicht. Letzteres ist auf aktuellen Linux-Systemen meist ein Terminal-Emulator wie das Programm xterm, aber das Prinzip bleibt das Gleiche.

Meist hat ein Tty übrigens auch noch die Echo-Funktion eingestellt, und leitet die Tastatureingaben des Users nicht nur an angeschlossene Applikationen weiter, sondern schreibt sie auch gleich ins Terminal ohne den Umweg über den User-Space. Möchte eine Applikation aber zum Beispiel zur Passworteingabe die Echo-Funktion abstellen, modifiziert sie kurzerhand die Tty-Einstellungen. Daraufhin stellt der Kernel den Echo-Service temporär ein, bis die Applikation ihn wieder anfordert.

Und meist befindet sich ein Tty im sogenannten "cooked mode", und reicht die Eingaben des Users nicht bei jedem Tastendruck an die angeschlossene Applikation weiter, sondern nur in einem Schwung als ganze Zeile, sobald ein Zeilenumbruch kommt. Dieses Verfahren ist gut für Applikationen wie eine Kommandozeilen-Shell, doch Editoren wie zum Beispiel vi können damit nichts anfangen, da sie jeden Tastendruck direkt brauchen und nicht erst nachdem der User "Enter" gedrückt hat. Deshalb stellen sie das verwendete Tty in den "raw mode", und bekommen damit immer sofort alles mit. Allerdings müssen sie dafür sorgen, dass das Terminal nach Abschluss des Programms wieder im Normalmodus ist, sonst rauft der User sich die Haare, wenn seine Shell plötzlich ausflippt und Sonderzeichen ausspuckt.

Abbildung 1: Tastatur und Terminal verbindet der Kernel normalerweise über ein Tty mit dem Skript.

Schwer zu Automatisieren

Dass Programme oder Skripts mit dem Tty kommunizieren, wird schnell klar, wenn man sich den Unterschied zwischen Programmen klar macht, die über die Standardeingabe User-Eingaben entgegennehmen und solche, die dies direkt über das Tty machen. Listing 1 nutzt die Shell-Funktion read, um eine mit Enter abgeschickte Eingabe des Users einzulesen und diese anschließend auszugeben.

Wer den Aufruf von Listing 1 automatisieren möchte, um die geforderte Eingabe über ein Skript bereitzustellen, kann dies wie im oberen Teil von Abbildung 2 mit Hilfe eines Here-Dokuments erledigen. Dieses pumpt den anliegenden Text einfach in die Standardeingabe der Applikation, und letztere weiß gar nicht, dass die Information nicht vom User am Keyboard, sondern aus der Konserve stammt.

Listing 1: from-stdin.sh

    1 #!/bin/sh
    2 
    3 read -p "Type something: " input
    4 echo "You typed: $input"

Abbildung 2: Das erste Skript lässt sich automatisieren, das zweite nicht.

Ganz anders hingegen Listing 2: Es liest den einzugebenden Text direkt vom Tty, sperrt also Skripts, die Daten in die Standardeingabe schreiben, kategorisch aus. Dafür mag es gute Gründe geben, weil zum Beispiel Passwörter im Spiel sind, die die Applikation lieber live eingetippt sähe als aus der Konserve kommend, was doch nur dazu führt, dass eifrige Automatisierer sie gedankenlos im Dateisystem speichern. Aber einen schalen Nachgeschmack hinterlässt das Ganze schon, wäre es -- im Notfall! -- denn ganz und gar unmöglich, so ein Programm mit eingeweckten Daten zu füttern?

Listing 2: from-tty.sh

    1 #!/bin/sh
    2 
    3 /bin/echo -n "Type something: "
    4 /bin/echo "You typed:" `head -1 /dev/tty`
    5 /bin/echo "End of tty script"

Auf Unix ist ja bekanntermaßen nichts unmöglich, und das gilt auch in diesem Fall, aber um dieses Problem zu knacken, ist ein Ausflug in die obskure Welt der Ttys und ihren kamerascheuen Artverwandten, den Ptys, unumgänglich.

Pseudo statt Real

Nicht jede Applikation hängt direkt an einem Rechner mit Tastatur und Bildschirm. Was, wenn der User sich via ssh auf einem Remote-System einloggt, und dort einen Terminal-Editor wie vi startet? Der vi auf dem Remote-System nimmt Tastendrücke von einem Tty dort entgegen, und stellt seine Ausgabe ebenfalls über dieses Tty dar. Von Bits, die sowohl bei Ein- als auch bei Ausgaben dabei über's Netzwerk fliegen, hat er keinen blassen Schimmer.

Die Shell auf dem Remote-System muss also dort aufgerufenen Applikationen ein Tty bereitstellen, das eigentlich keines ist, weil der dortige Kernel weder über ein angeschlossenes Keyboard noch ein Ausgabeterminal verfügt. Vielmehr handelt es sich über ein Pseudo-Tty, ein pty, das einer angeschlossenen Applikation vorgaukelt, ein wahres Tty zu sein, aus der sie Tastendrücke lesen und an das sie darzustellende Zeichen senden kann.

Abbildung 3: Das Skript denkt, mit einem Tty zu kommunizieren, redet aber mit einem vom Controller gesteuerten Pty.

Das Pseudo-Tty gibt sich also gegenüber der Applikation auf dem Remote-Rechner als Tty aus, hält aber gleichzeitig eine Verbindung mit dem Host, von dem die ssh-Verbindung ausging, leitet die Tastatureingaben des Users dort an die Remote-Shell weiter, und schickt die Ausgabe des Editors zurück zum Ursprungs-Host. Wie Abbildung 2 zeigt, besteht ein Pty generell aus zwei Komponenten, die Master und Slave heißen. Der Slave gaukelt einer ferngesteuerten Applikation vor, ein lokales Tty zu sein, das Eingaben liefert und Ausgaben entgegennimmt. Der Master hingegen steuert die Slave-Komponente, indem er ihr Eingaben schickt, die der Slave als Tastatureingaben an die Applikation weiterreicht. Weiter holt der Master Ausgaben vom Slave ab, die von der Applikation stammen und für das Terminal zum Anzeigen bestimmt sind, interpretiert und verarbeitet sie.

Kind ferngesteuert

Damit nun ein Kontroll-Programm ein Skript wie Listing 2 fernsteuern kann, ihm Eingaben unterjubeln und Ausgaben abfangen kann, erzeugt es die zwei Komponenten eines Ptys und startet das Skript in einem Kindprozess. Dann weist es dem Child die Slave-Komponente des Ptys als Tty zu, und verbindet sich selbst mit der Master-Komponente. So bekommt es mit, wenn das Kind etwas schreibt, kann ihm daraufhin Usereingaben unterjubeln und wieder auf Ausgaben lauschen. Genau nach diesem Verfahren arbeiten bekannte Programme wie expect oder auch script, das Shell-Sessions bequem und transparent in einer Logdatei aufzeichnet.

Listing 3: pty.c

    001 #define _XOPEN_SOURCE 600
    002 #include <fcntl.h>
    003 #include <unistd.h>
    004 #include <stdlib.h>
    005 #include <string.h>
    006 #include <stdio.h>
    007 
    008 #define BUF_SIZE  1024
    009 
    010 pid_t pty_fork(int *mfd);
    011 int pty_master_open(char *sname);
    012 
    013 int main(int argc, char *argv[]) {
    014   char buf[BUF_SIZE];
    015   char *shell;
    016   int mfd;
    017   size_t numRead;
    018   pid_t cpid;
    019   int written = 0;
    020 
    021   cpid = pty_fork(&mfd);
    022   if (cpid == -1)
    023     exit(1); /* fork failed */
    024 
    025     if (cpid == 0) {
    026       shell = "/bin/sh";
    027       execlp(shell, shell, "-c",
    028         "./from-tty.sh", (char *) NULL);
    029       exit(2);      /* exe failed */
    030     }
    031 
    032     /* Send response */
    033     sprintf(buf, "blah blah\n");
    034     numRead = strlen(buf);
    035     if (write(mfd, buf, numRead) != numRead)
    036       exit(6);
    037     sleep(2);
    038 }
    039 
    040 pid_t pty_fork(int *mfd) {
    041     int sfd;
    042     pid_t cpid;
    043     char sname[BUF_SIZE];
    044 
    045     *mfd = pty_master_open(sname);
    046     if (*mfd == -1)
    047         return -1;
    048 
    049     cpid = fork();
    050 
    051     if (cpid == -1) { /* fork() failed */
    052         close(*mfd);
    053         return -1;
    054     }
    055 
    056     if (cpid != 0) {
    057         return cpid; /* parent returns */
    058     }
    059 
    060     /* New session */
    061     if (setsid() == -1)
    062         exit(7);
    063 
    064     /* spty becomes child's stdin */
    065     sfd = open(sname, O_RDWR);
    066     if (sfd == -1)
    067         exit(8);
    068     if (dup2(sfd, STDIN_FILENO) !=
    069 	    STDIN_FILENO)
    070       exit(9);
    071 
    072     return 0;
    073 }
    074 
    075 int pty_master_open(char *sname) {
    076     int mfd;
    077     char *spty;
    078 
    079     /* open pty master */
    080     mfd = posix_openpt(O_RDWR | O_NOCTTY);
    081     if (mfd == -1)
    082         return -1;
    083 
    084     if (grantpt(mfd) == -1) {
    085         close(mfd);
    086         return -1;
    087     }
    088 
    089     if (unlockpt(mfd) == -1) {
    090         close(mfd);
    091         return -1;
    092     }
    093 
    094     spty = ptsname(mfd);
    095     if (spty == NULL) {
    096         close(mfd);
    097         return -1;
    098     }
    099 
    100     if (strlen(spty) < BUF_SIZE) {
    101         strcpy(sname, spty);
    102     } else { /* buf too small */
    103         close(mfd);
    104         return -1;
    105     }
    106 
    107     return mfd;
    108 }

Listing 3 zeigt ein Programm, das Listing 2 fernsteuert, indem es ihm auf dessen tty wie gewünscht Eingaben liefert. Zur Wahl der Programmiersprache für diese Aufgabe: Eigentlich bietet Go ja bequemere und sicherere Methoden zur Stringbehandlung und eigentlich allem was mit dem Allokieren von Speicher zutun hat. In C arbeitet der Trapezkünstler ohne Netz und doppelten Boden, ein zu kleiner Stringpuffer und schon stürzt das Programm ab oder, noch schlimmer, hält Angreifern wegen Bufferoverflows die Tür auf. Allerdings fehlen in Go recht viele der in dieser Ausgabe genutzten Funktionen der Linux-Programmierschnittstelle. Zwar ist es möglich, sie mittels der CGO-Schnittstelle aus dem Go-Code aufzurufen, aber das ist umständlicher als gleich die natürliche C-Schnittstelle zu verwenden.

Vater kontrolliert

Das Hauptprogramm main ab Zeile 13 erzeugt mit pty_fork() in Zeile 21 erst ein Pty-Paar und dann einen Kindprozess, der parallel mit dem Vaterprozess aus der Funktion zurückkommt und sich vom Vater darin unterscheidet, dass die Variable cpid für das Kind 0 ist und für den Vater den Wert der pid des Kindes annimmt.

So kann die if-Bedingung in Zeile 25 den Kindprozess abfangen und es dazu veranlassen, das Skript from-tty.sh (Listing 2) in einer neu erzeugten Shell auszuführen. Aus pty_fork() kommt außer der pid des Kindprozesses noch eine weitere Variable zurück: In mfd liefert die Funktion einen File-Deskriptor des erzeugten Master-Ptys. Da Funktionen in C im Gegensatz zu Go nur einen Wert zurückgeben können, und pty_fork() mit der als Rückgabewert gelieferten pid schon ausgelastet ist, übergibt Zeile 21 die Variable mfd einfach als Pointer, worauf die Funktion den ermittelten Wert für den Master-File-Deskriptor an die angegebene Adresse schreibt, sodass das das Hauptprogramm später auf den Wert zugreifen kann.

Um nun dem ferngesteuerten Kindprozess eine Nachricht aufs pty zu schreiben, auf dass dieses sie als Usereingabe übers Terminal interpretiert, muss der Vater lediglich Daten auf den File-Deskriptor mfd des Master-Ptys schreiben, wie das Zeile 35 mit der Systemfunktion write() tut, die einen Buffer und dessen Länge entgegennimmt.

Sekundenschlaf zu Testzwecken

Damit das Kontrollprogramm nicht sofort nach dem Senden der Tippsequenz abbricht und die Reaktion des ferngesteuerten Skripts verpasst, wartet Zeile 37 im Hauptprogramm in Listing 3 noch zwei Sekunden, bevor main sich beendet. Das ist freilich nur zu Testzwecken akzeptabel, da Sekundenschlaf keine Garantie zur zeitgerechten Ausführung bietet. In einer Produktionsumgebung würde der Fernsteuerer Ausgaben des Fernzusteuernden abfangen und darauf mit Eingaben reagieren, damit sichergestellt ist, dass alle Nachrichten auch definitiv angekommen sind.

Das zur Kommunikation zwischen Vater und Kind benötigte Pty-Paar legt die Funktion pty_master_open() ab Zeile 75 in Listing 3 an. Sie gibt im Erfolgsfall zwei Werte zurück: Den Integerwert für den File-Deskriptor des Master-Ptys und, als Pointer im Argumentenfeld, den Dateipfad des Slave-Ptys sname. Hierfür erzeugt zunächst die Linux-Systemfunktion posix_openpt() ein Pty-Paar und mit grantpt() in Zeile 84 und unlockpt() in Zeile 89 erhält der Vaterprozess Zugriff darauf. Den Pty-Pfad zum zugehörigen Pty-Slave liefert die Systemfunktion ptsname() in Zeile 94.

Um mit letzterem Kontakt aufzunehmen und ihn als kontrollierendes Terminal zu adoptieren, muss der Kindprozess in pty_fork() ab Zeile 61 mit setsid() erst eine neue Session erzeugen (so wie das eine Shell tut), und dann den vorher ermittelten Pty-Dateipfad mit open() zum Lesen und Schreiben (O_RDWR) öffnen. Die Unix-Systemfunktion dup2() in Zeile 68 verbindet anschließend die Standardeingabe STDIN des Kindes (nicht die des Vaters, der ist bereits in Zeile 57 zurückgekehrt) auf den Pty-Slave. Eine Applikation, die sowohl lesend als auch schreibend fernsteuert, würde die Deskriptoren für STDOUT und STDERR ebenso ans Tty hängen.

It's a Wrap

Das C-Programm in Listing 3 kompiliert sich mit cc -o pty pty.c zu einem Binary pty. Übrigens braucht Listing 3 in Zeile 1 die Makro-Definition

    # define _XOPEN_SOURCE 600

damit es auch den Posix-Standard von 2004 nutzt, sonst kompiliert gcc die Pty-Systemfunktionen nur mit Warnungen und das Programm stürzt mit einem Segfault ab.

Wer das Binary pty ausführt, und das Shell-Skript von Listing 2 im gleichen Verzeichnis abgelegt hat, sieht, dass das Ganze wie gewünscht funktioniert:

    $ ./pty
    Type something:
    You typed: blah blah
    End of tty script

Das Shellskript from_tty.sh dachte offensichtlich, es hätte die Eingabe blah blah vom User über's Terminal erhalten und nicht von dem fernsteuernden Kontrollprogramm in Listing 3. So kann man sich irren.

Vertiefung gefällig?

Wer sich weiter in die Materie vertiefen möchte, findet im Jahrhundertwerk von Michael Kerrisk zur Linux-Systemprogrammierung ([2]) exzellente Erklärungen zur Funktion von Ptys und hilfreiche Codebeispiele zur Fernsteuerung, unter anderem der komplette Source-Code einer simplen Implementierung der Utility script zur Aufzeichnung von Terminal-Sessions. Das Blogpost von Linus Akesson ([3]) gibt einen historischen Abriss über Terminals, Ttys und Ptys und bietet anschauliche Beispiele.

Infos

[1]

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

[2]

Michael Kerrisk, "The Linux Programming Interface", No Starch Press, 2010, ISBN 1593272200, Free online: https://man7.org/tlpi/

[3]

"TTYs demystified", http://www.linusakesson.net/programming/tty/index.php

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