sandbox-shell: eine Shell in der Sandbox eines systemd-Dienstes

Ein kleines Werkzeug, das eine Unit-Datei samt Drop-ins liest und eine Shell oder einen Befehl mit demselben Benutzer, derselben Umgebung und denselben Sandbox-Einstellungen startet, auch wenn der Dienst nicht läuft.

Das Problem

Viele systemd-Dienste laufen heute in einer Sandbox: DynamicUser=yes gibt ihnen einen temporären Benutzer, ProtectSystem=strict macht das Dateisystem schreibgeschützt, ReadWritePaths= öffnet einzelne Verzeichnisse wieder, dazu kommen Einstellungen für Capabilities, Systemaufrufe und Namespaces. Fällt so ein Dienst aus, lautet die Frage meist: Wie sieht die Welt von innen aus? Als welcher Benutzer laufe ich, darf ich in dieses Verzeichnis schreiben, gibt es diesen Pfad?

Die üblichen Antworten sind nsenter in den laufenden Prozess, was nicht hilft, wenn der Dienst schon beim Start scheitert, oder systemd-run mit einer -p-Option pro Zeile der Unit-Datei und ihrer Drop-ins, von Hand abgeschrieben.

Andere haben nach einem besseren Weg gefragt:

  • systemd/systemd#28163, "Get a shell in the environment/sandbox of an existing service", offen seit Juni 2023, 37 Reaktionen. systemd 258 hat systemd-analyze unit-shell eingeführt, das in eine laufende Unit wechselt. Die letzten Kommentare im Thread fragen nach demselben für eine abgestürzte Unit, als deren dynamischer Benutzer statt als root.
  • systemd/systemd#9907, "Allow passing a .service file to systemd-run", offen seit August 2018, 20 Reaktionen.

Wie es arbeitet

sandbox-shell liest die Unit mit systemctl cat, parst sie wie systemd (Kommentare, Zeilenfortsetzungen, leere Zuweisungen, die eine Einstellung zurücksetzen) und behält die [Service]-Einstellungen, die die Ausführungsumgebung beschreiben. Es verwirft die, die den Lebenszyklus beschreiben: ExecStart= und die anderen Exec*=-Zeilen, Type=, Restart=, Timeouts, RuntimeMaxSec=, die Standard-Datenströme. Spezifizierer wie %N ersetzt es durch den Namen der ursprünglichen Unit, damit StateDirectory=%N weiter auf das Verzeichnis des Dienstes zeigt. Dann startet es den Befehl mit systemd-run --wait --collect --pty.

Zwei Details sorgen dafür, dass der echte Dienst nicht gestört wird. Bei DynamicUser=yes ohne User= leitet systemd den Benutzernamen aus dem Unit-Namen ab. Die transiente Unit hat einen anderen Namen, bekäme also eine andere UID, und systemd würde das Zustandsverzeichnis des Dienstes auf diese UID umschreiben. Das Werkzeug setzt deshalb User= auf den Namen, den systemd ableiten würde, einschließlich des SipHash-basierten Namens für Unit-Namen, die keine gültigen Benutzernamen sind. Units mit demselben dynamischen Benutzernamen teilen sich die UID. Und ein RuntimeDirectory= bekommt RuntimeDirectoryPreserve=yes, damit es beim Beenden der Shell nicht gelöscht wird.

Vor dem eigentlichen Lauf startet das Werkzeug einmal /bin/true mit denselben Einstellungen. Lehnt systemd eine Einstellung für transiente Units ab, wird sie weggelassen und genannt. Lässt sich die Sandbox gar nicht aufbauen, gibt das Werkzeug den Exit-Status und die erklärenden Zeilen aus dem Journal aus:

$ sudo sandbox-shell demo-app.service
sandbox-shell: the sandbox of demo-app.service cannot be set up: the test process exited with status 226/NAMESPACE before it ran.
  Started demo-app-sandbox-574315-preflight.service - sandbox-shell preflight.
  demo-app-sandbox-574315-preflight.service: Failed to set up mount namespacing: /srv/demo-app/uploads: No such file or directory
  demo-app-sandbox-574315-preflight.service: Main process exited, code=exited, status=226/NAMESPACE
  demo-app-sandbox-574315-preflight.service: Failed with result 'exit-code'.
demo-app.service fails to start for the same reason. Use --drop KEY or -p KEY=VALUE to try a fix, or --no-preflight to start anyway.

Nachdem das Verzeichnis angelegt ist, derselbe Dienst, weiterhin nicht gestartet:

$ sudo sandbox-shell demo-app.service -- sh -c 'id; pwd; echo "$APP_ENV"; touch /srv/demo-app/uploads/x && echo uploads writable; touch /etc/x'
sandbox-shell: running sh -c 'id; pwd; echo "$APP_ENV"; touch /srv/demo-app/uploads/x && echo uploads writable; touch /etc/x' as demo-app with 7 settings of demo-app.service
  not carried over: ExecStart
  added: User=demo-app (the dynamic user of demo-app.service)
uid=61953(demo-app) gid=61953(demo-app) groups=61953(demo-app)
/var/lib/private/demo-app
production
touch: cannot touch '/srv/demo-app/uploads/x': Permission denied
touch: cannot touch '/etc/x': Read-only file system

Das nächste Problem ist sofort sichtbar: Das Upload-Verzeichnis gehört root, und der dynamische Benutzer darf dort nicht schreiben.

Tests

27 Unit-Tests decken den Parser, die Auswahl der Einstellungen, die Spezifizierer und den dynamischen Benutzernamen ab; die SipHash-Implementierung wird gegen die Referenz-Testvektoren geprüft. Acht Integrationstests laufen gegen den echten System-Manager. Sie legen Wegwerf-Units unter /run/systemd/system an und prüfen, dass der Befehl mit der UID des Hauptprozesses des laufenden Dienstes läuft (auch bei einem Unit-Namen mit Punkten, der den gehashten Benutzernamen braucht), die Umgebung aus einem Drop-in sieht, ein schreibgeschütztes /usr und ein beschreibbares Zustandsverzeichnis hat, und dass der laufende Dienst und sein Laufzeitverzeichnis danach noch da sind. Weitere Tests betreffen einen fehlgeschlagenen Dienst, eine Template-Instanz, einen Sandbox-Fehler mit Status 226, eine unbekannte Einstellung und den Exit-Status. Das CI führt sie auf den GitHub-Runnern mit systemd 255 und 249 aus.

Grenzen

  • Es startet eine neue transiente Unit und tritt nicht den Namespaces eines laufenden Dienstes bei. Mit PrivateTmp=yes bekommt die Shell ein eigenes /tmp. Für den laufenden Prozess sind systemd-analyze unit-shell oder nsenter das richtige Werkzeug.
  • Socket-Aktivierung und ExecStartPre=-Schritte werden nicht nachgebildet.
  • Credentials aus LoadCredential= landen im eigenen Credentials-Verzeichnis der transienten Unit.
  • Getestet ist es nur mit systemd 255 und 249.

Der Code liegt unter github.com/Jwrede/sandbox-shell.