Vernünftige Daemons in Ruby (Update 10.03.14)
Marvin Gülker · 09.03.2014
Daemon in Ruby schreiben.
Die meisten Systemdienste (Dæmons) sind wohl in C/C++ geschrieben. Allerdings erfüllt auch Ruby bereits von Haus aus alle dafür erforderlichen Voraussetzungen, wenn auch mit kleineren Stolperfallen. In diesem Artikel will ich mich damit beschäftigen, wie man mit Ruby einen üblichen Dæmon schreibt, der aus Sicherheitsgründen sowohl seine Rechte aufgibt (privilege dropping) als auch unter einer anderen Dateiwurzel läuft (chroot).
Dæmons sind ein kompliziertes Thema. Die oberste und wichtigste Regel bei der Arbeit an einem Dæmon ist folgende: Sicherheit. Dæmons benötigen oft spezielle Rechte und werden daher (zunächst) als root-Nutzer ausgeführt; ein Sicherheitsleck in einem Dæmon kann es einem Angreifer ermöglichen, mit vollen root-Rechten beliebige Kommandos auf dem System durchzuführen. Es ist daher von zwingender Notwendigkeit, seinen Code möglichst intensiv auf Sicherheitslecks zu überprüfen bzw. am besten überprüfen zu lassen. Diese Artikel kann keine Anleitung dafür sein, wie man sicheren Ruby-Code schreibt, vielmehr beschäftigt er sich mit den Grundaufgaben eines jeden Dæmons und wie man zumindest diese sicher ausführt.
Was ist ein Dæmon?
Ein Dæmon ist ein Prozess, der sich durch spezielles Verhalten besonders
auszeichnet. Hauptmerkmal eines Dæmons ist der Detach bzw. Fork,
d.h. die „Verselbständigung“ des Prozesses als eigener, von einem
Elternprozess unabhängiger Prozess, der direkt unterhalb von init
läuft. Er tritt in den Hintergrund und benötigt keine Nutzerinteraktion.
Weitere typische Merkmale von Dæmons sind:
- Er schreibt seine Prozessnummer (PID) in eine bestimmte Datei.
- Er reagiert auf Prozesssignale (SIGTERM, SIGHUP, …).
- Er liest eine Konfigurationsdatei.
- Dæmons werden üblicherweise nicht direkt gestartet, sondern vom
jeweiligen Dienstmanager. Heutzutage kommt dafür meist der Befehl
systemctl
zum Einsatz, mit dem systemd(1) angewiesen wird, einen bestimmten Dienst zu starten oder zu stoppen.
Aus Sicherheitsgründen sollte ein Dæmon zudem noch folgendes tun:
- Privilege dropping: Werden root-Rechte benötigt (etwa zum Zugriff auf bestimmte Dateien oder zum Öffnen eines privilegierten Ports), so müssen diese so früh wie möglich abgegeben werden.
- chroot: Falls ein Sicherheitsproblem auftauchen sollte, limitiert eine geänderte Verzeichniswurzel zumindest den Dateizugriff auf den Bereich hierunter.
Ziel
In diesem Artikel wird ein Dæmon erstellt, der den folgenden Kriterien genügt:
- Forking (sonst kein Dæmon ;-))
- Logging ins Syslog
- PID File
- Chroot
- Privilege dropping
Aufgabe das Dæmons wird es sein, auf TCP-Port 70 zu lauschen und jedem dorthin verbindenden Client die aktuelle Uhrzeit mitzuteilen. In einem späteren Artikel werde ich den bestehenden Code noch um Kommandozeilenargumente, eine Konfigurationsdatei und bessere Handhabung des Startens/Stoppens erweitern.
Gabeln
Hauptmerkmal des Dæmons ist die Abspaltung in den Hintergrund, der sog.
Fork. Die grundlegende Funktion wird von jedem POSIX-konformem System
(also nicht von Windows) über den fork(3p)-Syscall bereitgestellt, der
in Ruby über die Methode
Kernel#fork
bereitgestellt wird. #fork „spaltet“ den aktuellen Prozess in zwei und
gibt im bisherigen Prozess (dem Elternprozess) die PID des neuen
Prozesses (des Kindprozesses) zurück. Im Kindprozess dagegen gibt #fork
schlicht nil
zurück. Folgt man der Ruby-Dokumentation, so sollte man
im Elternprozess nach dem Aufruf von #fork noch klarstellen, welches
Interesse der Elternprozess an seinem neuen Kind hat — im Falle eines
Dæmons überhaupt keines. Der Elternprozess dient nur als Startrampe für
den neuen Prozess und wird unmittelbar nach Aufruf von #fork beendet;
fehlt besagte Klarstellung, wird er vom Kernel jedoch künstlich als
„Zombie“ am Leben erhalten.
Der folgende Code forkt und schließt den Elternprozess sauber ab:
if pid = fork # Dies hier wird nur im Elternprozess ausgeführt Process.detach(pid) exit end # Ab hier beginnt der Kindprozess-Code
Process::detach teilt dem Kernel mit, dass dieser (Eltern-)prozess kein Interesse an dem Kind mit der angegebenen PID hat. Der Kernel darf den Prozess vollständig entfernen, sodass kein Zombieprozess zurückbleibt. Da der Elternprozess nicht weiter benötigt wird, wird er mithilfe von #exit beendet.
Boilerplate
Nun gilt es, im neugeschaffenen Kindprozess ein paar Sicherheitsgrundsätze umzusetzen. Es muss ein sicherer Zustand hergestellt werden und nutzlose Verbindungen zur Außenwelt werden gekappt (ein Dæmon läuft ohne Nutzerinteraktion).
- Eine
umask
muss festgelegt werden, d.h. die Standardzugriffsrechte für neu angelegte Dateien werden festgelegt. - Das Syslog muss geöffnet werden.
- Der Dæmon muss Wurzel einer eigenen Prozessgruppe werden.
- Die Standard-IO-Streams werden geschlossen.
umask
Die umask
legt fest, welche Zugriffsrechte für neu erstellte Dateien
(einschließlich etwaiger Logfiles abseits vom Syslog) festgelegt werden.
Zu beachten ist dabei, dass die hier festgelegte Maske von der
hypothetischen „ich-darf-alles-Maske“ 0777
abgezogen wird. Eine
umask
von 0 würde folglich Lese-, Schreib- und Ausführrechte für alle
neuen Dateien gewähren. Sinnvoller erscheint eine Reduktion auf
rw-r-----
, also Lese- und Schreibrechte für den Eigentümer, Leserechte
für die Gruppe, ansonsten keine Rechte. Die erforderlichen numerischen
Werte können chmod(1) entnommen
werden, sodass sich dafür folgender Code ergibt
(File::umask
setzt die umask
):
# Neue Dateien mit rw-r----- File.umask(0137)
Syslog öffnen
Da ein Dæmon keinen Kontakt zur direkten Außenwelt mithilfe der
Standard-IO-Streams pflegt, braucht es andere Kanäle. Der typische Weg
dafür ist die Ausgabe in das Systemlog (Syslog), das mithilfe von
journalctl
eingesehen werden kann (Linux-Distributionen, die nicht auf
Systemd + Journald setzen, führen meist die klassische Logfile
/var/log/syslog
). Ruby enthält bereits in der stdlib ein
Modul
für den Syslog-Zugriff:
# Syslog-Modul einbinden require "syslog" # ... # Syslog öffnen. Syslog::open erfordert einen kurzen Bezeichner, # unter dem alle Meldungen, die dieses Programm loggt, ausgegeben # werden. Syslog::log sollte selbsterklärend sein. Syslog.open("foo") Syslog.log(Syslog::LOG_INFO, "Starting up.")
Selbstverwaltung
Ein geforkter Prozess übernimmt die Umgebung von seinem Elternprozess, insbesondere die Standard-IO-Streams und die Prozessgruppe. Der neue Prozess muss aber vollständig selbständig sein, was sich durch die Anforderung einer eigenen Session-ID vom Kernel über Process::setsid erreichen lässt:
# Neue Prozessgruppe anfordern, um sich vollständig vom # Elternprozess zu lösen (Achtung! Ab hier kein # Terminalzugriff mehr!) Process.setsid
Aufräumen
Die nunmehr nutzlosen Standardstreams, die in Ruby als STDIN
, STDOUT
und STDERR
zur Verfügung stehen, können nun der Ordentlichkeit halber
geschlossen werden. Ich möchte an dieser Stelle wegen eines
weiterverbreiteten Missverständnisses noch einmal betonen, dass diese
Konstanten dafür gedacht sind, immer auf das kontrollierende Terminal
zu zeigen. Sie sind nicht dafür gedacht, die Standardein- und -ausgabe
in eine Datei umzulenken (deswegen sind es ja auch *Konstant*en). Dies
ist durch Neuzuweisung der entsprechenden globalen Variablen $stdin
,
$stdout
und $stderr
zu erreichen.
# Standard-Dateideskriptoren schließen, weil wir sie # ohnehin nicht verwenden können. STDIN.close STDOUT.close STDERR.close
Prozessname
Eher kosmetisch ist die Zuweisung eines hübschen Prozessnamens. Durch
Zuweisung an $0
kann festgelegt werden, wie der Prozess von Werkzeugen
wie ps(1) angezeigt wird:
# Hübschen Prozessnamen festlegen $0 = "foo"
PID File
Jeder Dæmon sollte ein PID-File schreiben, um gewährleisten zu können,
dass Prozessmanager wie systemctl
ihn richtig ansprechen können.
Leider stellt sich dies bei Verwendung der auf jeden Fall aus
Sicherheitsgründen zu empfehlenden Abgabe von root-Rechten als etwas
problematisch heraus. PID-Files werden üblicherweise in /run
(früher
/var/run
) abgelegt, einem Verzeichnis, das nur für root schreibbar
ist. So ist es zwar kein Problem, die PID-File erst einmal anzulegen:
# Wenn bereits eine PID-File existiert, den Start verweigern. Zwei Dæmons, # die miteinander kämpfen, sind gefährlich. if File.exists?("/run/foo.pid") Syslog.log(Syslog::LOG_CRIT, "PID file /run/foo.pid exists. Exiting.") raise "PID file /run/foo.pid exists." end File.open("/run/foo.pid", "w"){|pidfile| pidfile.write($$)}
Problematisch wird es jedoch beim Entfernen. Nachdem man wie unten
gezeigt die root-Rechte abgegeben hat, ist es nicht mehr möglich, die
PID-File wieder zu löschen (auch nicht mithilfe von at_exit
). Es
gibt zwar Tricks, dies zu umgehen, diese sind jedoch
aus
Sicherheitsgründen nicht zu empfehlen: Ein Angreifer, der in den
Prozess einbricht, könnte eine beliebige PID in die PID-File schreiben
und so Systemd oder andere Initmanager dazu bewegen, den falschen Dienst
zu beenden (etwa sshd
). Aus diesem Grunde werden viele Dæmons entweder
mit Kontrollskripts ausgeliefert (apachectl
), die ihrerseits selbst
als root laufen und nach erfolgter Beendigung des eigentlichen Dæmons
die PID-File selbst entfernen, oder der Dæmon übernimmt diese Aufgabe
vor dem Fork als Resultat der Auswertung seiner Kommandozeile
(postfix stop
) und sieht vom Fork ab. Letztere Variante werde ich in
einem Folgepost vorstellen.
Verzeichnisse
Ein Dæmon, der keinen Zugriff auf das Dateisystem benötigt, sollte per
chroot(2) in einem leeren
Verzeichnis eingesperrt werden. Dies ist aber auch für Dæmons sinnvoll,
die nicht Zugriff auf alle Verzeichnisse benötigen, sondern nur auf
bestimmte Verzeichnisse, die von ihnen selbst gemanaged werden (ein
Beispiel dafür ist Postfix' /var/spool/postfix
). Diese können dann
entweder schlicht (durch den Dæmon) im Chroot angelegt werden, oder per
mount --bind
in das Chroot eingehängt werden. Nach erfolgten Wechsel
der Verzeichniswurzel muss immer noch ein separater Wechsel des
Arbeitsverzeichnisses folgen, da ein Zugriff auf Dateien außerhalb des
Chroots sonst immer noch mithilfe relativer Pfade
möglich
wäre. Nach der Ausführung von
Dir::chroot
beziehen sich alle absoluten Verzeichnisangaben auf die neue Wurzel,
sodass der Aufruf von
Dir::chdir
mit /
erfolgen muss11
Tatsächlich sollte auch ein Dæmon, der ohne Chroot auskommt, sein
Arbeitsverzeichnis auf /
stellen – dies ist das einzige
Verzeichnis, dessen Existenz auf jeder noch so exotischen
Linux-Distribution garantiert ist.
.
# Begrenze den Dateisystemzugriff dieses Prozesses # (Verzeichnis muss existieren) Dir.chroot("/run/empty") # In das Wurzelverzeichnis der neuen Wurzel wechseln Dir.chdir("/")
Letzte privilegierte Operationen
An dieser Stelle sind die letzten Operationen durchzuführen, für die root-Rechte erforderlich sind. Da unser Dæmon auf Port 70 – ein privilegierter Port (< 256), den man nur mit root-Rechten öffnen kann – lauschen soll, muss dieser nun geöffnet werden:
require "socket" # ... # Privilegierten Port öffnen server = TCPServer.new(70)
Rechte abgeben
Nun ist der Zeitpunkt gekommen, die administrativen root-Rechte
abzugeben. An sich ist der dafür erforderliche Code in Gestalt des
Aufrufs von
Process::Sys::setuid
plus zusätzlicher Überprüfung reichlich unspektakulär (die Nutzer-ID
1000 sollte natürlich durch eine sinvolle wie etwa die von nobody
ersetzt werden):
# Privilegien abgeben Process::Sys.setuid(1000) # Sicherstellen, dass sie wirklich weg sind und keine root-Rechte # mehr möglich sind: begin Process::Sys.setuid(0) rescue Errno::EPERM Syslog.log(Syslog::LOG_INFO, "Successfully dropped privileges.") else Syslog.log(Syslog::LOG_CRIT, "Regained root privileges! Exiting!") raise "Regained root privileges!" end
Fatalerweise ist an dieser Stelle jedoch häufig ein Aufruf von Process::euid= zu finden. Dieser Aufruf garantiert jedoch nicht, dass der Prozess dauerhaft unterprivilegiert bleibt. Vielmehr hält er ein Hintertürchen offen, das das privilege dropping effektiv wertlos macht. Daher ist auf den Aufruf der richtigen Methode unbedingt zu achten.
Eigentlicher Code
Zuletzt folgt der eigentliche Dæmon-Code, den ich dem Leser zum Selbststudium überlassen möchte. Es handelt sich dabei um keine sonderlich spektakuläre Verwendung von Rubys Socket-Bibliothek:
$finish = false Signal.trap("SIGTERM") do $finish = true end threads = [] loop do # Timeout zur Prüfung der Beendigungsbedingung if IO.select([server], nil, nil, 5) t = Thread.start(server.accept) do |client| Syslog.log(Syslog::LOG_INFO, "Accepted connection from #{client.remote_address.ip_address}") client.puts("Local time is #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}") client.close end threads << t end if $finish threads.each(&:join) break end end Syslog.log(Syslog::LOG_INFO, "Shutting down.") Syslog.close
Testen
Der Dæmon benötigt root-Rechte einmal wegen der Bindung an Port 70, aber
auch wegen der Aufrufe von chroot und setuid. Er muss folglich mit
sudo
gestartet werden:
$ sudo ruby foo.rb
Ein Blick ins Syslog sollte sich dabei lohnen.
Mär 09 17:22:57 atlantis sudo[10925]: quintus : TTY=pts/0 ; PWD=/home/quintus/cronfile ; USER=root ; COMMAND=/usr/bin/ruby foo.rb Mär 09 17:22:57 atlantis sudo[10925]: pam_unix(sudo:session): session opened for user root by (uid=0) Mär 09 17:22:57 atlantis foo[10928]: Starting up. Mär 09 17:22:57 atlantis foo[10928]: Successfully dropped privileges.
Auch die Verbindung kann getestet werden:
$ telnet 70 Trying ::1... Connection failed: Verbindungsaufbau abgelehnt Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. Local time is 2014-03-09 16:23:47 Connection closed by foreign host.
Mär 09 17:23:47 atlantis foo[10928]: Accepted connection from 127.0.0.1
Vollständiger Code
# -*- coding: utf-8 -*- require "syslog" require "socket" # Process.detach verhindert Zombies if pid = fork Process.detach(pid) exit end # Neue Dateien mit rw-r----- File.umask(0137) # Syslog öffnen. Syslog::open erfordert einen kurzen Bezeichner, # unter dem alle Meldungen, die dieses Programm loggt, ausgegeben # werden. Syslog::log sollte selbsterklärend sein. Syslog.open("foo") Syslog.log(Syslog::LOG_INFO, "Starting up.") # Neue Prozessgruppe anfordern, um sich vollständig vom # Elternprozess zu lösen (Achtung! Ab hier kein # Terminalzugriff mehr!) Process.setsid # Standard-Dateideskriptoren schließen, weil wir sie # ohnehin nicht verwenden können. STDIN.close STDOUT.close STDERR.close # Hübschen Prozessnamen festlegen $0 = "foo" # Wenn bereits eine PID-File existiert, den Start verweigern. Zwei Dæmons, # die miteinander kämpfen, sind gefährlich. if File.exists?("/run/foo.pid") Syslog.log(Syslog::LOG_CRIT, "PID file /run/foo.pid exists. Exiting.") raise "PID file /run/foo.pid exists." end File.open("/run/foo.pid", "w"){|pidfile| pidfile.write($$)} # Begrenze den Dateisystemzugriff dieses Prozesses # (Verzeichnis muss existieren) Dir.chroot("/run/empty") # In das Wurzelverzeichnis der neuen Wurzel wechseln Dir.chdir("/") # Privilegierten Port öffnen server = TCPServer.new(70) # Privilegien abgeben Process::Sys.setuid(1000) # Sicherstellen, dass sie wirklich weg sind und keine root-Rechte # mehr möglich sind: begin Process::Sys.setuid(0) rescue Errno::EPERM Syslog.log(Syslog::LOG_INFO, "Successfully dropped privileges.") else Syslog.log(Syslog::LOG_CRIT, "Regained root privileges! Exiting!") raise "Regained root privileges!" end ######################################## # Eigentlicher Code $finish = false Signal.trap("SIGTERM") do $finish = true end threads = [] loop do # Timeout zur Prüfung der Beendigungsbedingung if IO.select([server], nil, nil, 5) t = Thread.start(server.accept) do |client| Syslog.log(Syslog::LOG_INFO, "Accepted connection from #{client.remote_address.ip_address}") client.puts("Local time is #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}") client.close end threads << t end if $finish threads.each(&:join) break end end Syslog.log(Syslog::LOG_INFO, "Shutting down.") Syslog.close
Links
- Linux Dæmon-HOWTO (Hier sollte man sich den Vorteilen von Rubys Exceptions sehr deutlich bewusst werden)
- Dropping Privileges in C
Update
Weil ich darauf hingewiesen worden bin: Selbstverständlich ist es nach dem Schließen der Standard-IO-Streams nicht mehr möglich, Methoden wie #puts zu verwenden. Stattdessen würde eine Exception ähnlich dieser geworfen:
x.rb:4:in `write': closed stream (IOError) from x.rb:4:in `puts' from x.rb:4:in `puts' from x.rb:4:in `<main>'
Dies kann insbesondere bei Gems (beispielsweise durch Deprecation Warnings) problematisch sein, über die man selbst keine Kontrolle hat. Es kann insofern sinnvoll sein — und sollte im Sinne der Nachprüfbarkeit auch empfohlen sein — die Standardausgabe und den Standard-Error-Stream in eine Logfile zu leiten. Eine rudimentäre Version ohne hübsche Zeitstempel sähe dabei einfach so aus:
$stdout = $stderr = File.open("/var/log/foo.log", "a")
Fußnoten:
Tatsächlich sollte auch ein Dæmon, der ohne Chroot auskommt, sein
Arbeitsverzeichnis auf /
stellen – dies ist das einzige
Verzeichnis, dessen Existenz auf jeder noch so exotischen
Linux-Distribution garantiert ist.