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-shelleingefü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=yesbekommt die Shell ein eigenes/tmp. Für den laufenden Prozess sindsystemd-analyze unit-shellodernsenterdas 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.