From 1bc1e972d68d29d91e37d1f3b7f68510da00d9f2 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 21:16:37 +0200 Subject: [PATCH] Der Griff, mit dem ein Sitz in der Nextcloud wirksam wird MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlegen, einladen, Gruppe, sperren, freigeben. Keine Methode wirft — ein nicht erreichbarer Gast gibt false zurueck, statt den Arbeiter mitzureissen, auf dem die bezahlte Bereitstellung laeuft. Zwei Fallen sind hier eingebaut statt umgangen: user:disable allein laesst Sitzungen fuenf Minuten weiterleben (deshalb auth-tokens:delete daneben), und ein Konto mit eigenem Speicherplatz folgt der Paketvorgabe nicht mehr (deshalb --delete beim Verlassen von readonly, kein Ueberschreiben). --- app/Services/Nextcloud/NextcloudUsers.php | 180 ++++++++++++++++++ .../Feature/Nextcloud/NextcloudUsersTest.php | 118 ++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 app/Services/Nextcloud/NextcloudUsers.php create mode 100644 tests/Feature/Nextcloud/NextcloudUsersTest.php diff --git a/app/Services/Nextcloud/NextcloudUsers.php b/app/Services/Nextcloud/NextcloudUsers.php new file mode 100644 index 0000000..39ec794 --- /dev/null +++ b/app/Services/Nextcloud/NextcloudUsers.php @@ -0,0 +1,180 @@ +nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat) { + $vorhanden = (int) ($pve->guestExec( + $node, $vmid, NextcloudOcc::command('user:info '.escapeshellarg($user)) + )['exitcode'] ?? 1) === 0; + + // Wiederholbar nach einem Absturz: ein zweiter Lauf legt keinen + // zweiten Benutzer an, sondern schickt die Willkommensmail erneut. + // Genau wie CreateCustomerAdmin es tut. + return $vorhanden + ? ['user:welcome --reset-password '.escapeshellarg($user)] + : [ + 'user:add --generate-password' + .' --email='.escapeshellarg((string) $seat->email) + .' --display-name='.escapeshellarg((string) ($seat->name ?: $seat->email)) + .' --group='.escapeshellarg(Seat::GROUPS[$seat->role] ?? 'mitarbeiter') + .' '.escapeshellarg($user), + ]; + }); + } + + /** Gruppe setzen — und bei readonly der Speicherplatz. */ + public function applyRole(Instance $instance, Seat $seat): bool + { + $user = (string) $seat->nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + $ziel = Seat::GROUPS[$seat->role] ?? 'mitarbeiter'; + + return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat, $ziel) { + $befehle = []; + + // Aus jeder anderen bekannten Gruppe heraus, in die eine hinein. + foreach (array_unique(array_values(Seat::GROUPS)) as $gruppe) { + if ($gruppe !== $ziel) { + $befehle[] = 'group:removeuser '.escapeshellarg($gruppe).' '.escapeshellarg($user); + } + } + + $befehle[] = 'group:adduser '.escapeshellarg($ziel).' '.escapeshellarg($user); + + // Der Speicherplatz. Siehe ApplyStorageQuota: ein Konto mit + // EIGENEM Wert folgt der Vorgabe der Instanz nicht mehr. Fuer + // readonly ist genau das gewollt; beim VERLASSEN der Rolle muss + // der eigene Wert deshalb WEG, nicht ueberschrieben werden. + $befehle[] = $seat->isReadonly() + ? 'user:setting '.escapeshellarg($user).' files quota '.escapeshellarg('0 B') + : 'user:setting '.escapeshellarg($user).' files quota --delete'; + + return $befehle; + }); + } + + public function disable(Instance $instance, Seat $seat): bool + { + $user = (string) $seat->nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + return $this->run($instance, fn ($pve, $node, $vmid) => [ + 'user:disable '.escapeshellarg($user), + // user:disable allein laesst laufende Sitzungen bis zu fuenf + // Minuten weiterleben. Bei jemandem, der gerade gegangen ist, + // sind fuenf Minuten fuenf zu viel. + 'user:auth-tokens:delete '.escapeshellarg($user), + ]); + } + + public function enable(Instance $instance, Seat $seat): bool + { + $user = (string) $seat->nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + return $this->run($instance, fn ($pve, $node, $vmid) => ['user:enable '.escapeshellarg($user)]); + } + + /** + * Nextcloud laesst Buchstaben, Ziffern und `-_.@` in Kennungen zu. Alles + * andere ist entweder ein Fehler weiter oben oder ein Versuch — beides + * will man sehen, und keines darf in eine Shell. + */ + private function isWellFormed(string $user, Seat $seat): bool + { + if ($user !== '' && preg_match('/^[A-Za-z0-9._@-]+$/', $user) === 1) { + return true; + } + + report(new RuntimeException( + "NextcloudUsers: abgewiesene Kennung fuer Sitz [{$seat->uuid}] — nichts ausgefuehrt." + )); + + return false; + } + + /** + * Der Verbindungsaufbau steht EINMAL hier, nicht in jeder Methode. Der + * Rueckruf bekommt den fertigen Client mit — er braucht ihn, weil `invite()` + * erst nachsehen muss, ob es den Benutzer schon gibt, bevor es entscheidet, + * welchen Befehl es baut. + * + * @param callable(ProxmoxClient, string, int): array $bauen + */ + private function run(Instance $instance, callable $bauen): bool + { + if ($instance->host === null || blank($instance->vmid)) { + return false; + } + + $node = $instance->host->node ?? 'pve'; + $vmid = (int) $instance->vmid; + + try { + $pve = $this->pve->forHost($instance->host); + $ok = true; + + foreach ($bauen($pve, $node, $vmid) as $argumente) { + $ergebnis = $pve->guestExec($node, $vmid, NextcloudOcc::command($argumente)); + $ok = ((int) ($ergebnis['exitcode'] ?? 1) === 0) && $ok; + } + + return $ok; + } catch (Throwable $e) { + // Ein abgeschalteter Gast wirft, statt einen Fehlercode zu liefern. + // Nie mit Zugangsdaten, nie mit Stacktrace an den Kunden. + Log::warning('nextcloud user command failed', [ + 'instance' => $instance->uuid, 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/tests/Feature/Nextcloud/NextcloudUsersTest.php b/tests/Feature/Nextcloud/NextcloudUsersTest.php new file mode 100644 index 0000000..1702f69 --- /dev/null +++ b/tests/Feature/Nextcloud/NextcloudUsersTest.php @@ -0,0 +1,118 @@ +instance(ProxmoxClient::class, $pve); + + // Ohne host_id bliebe die host-Beziehung null, und run() wiese jeden + // Befehl kommentarlos ab, statt einen zu bauen — die Instanz braucht + // einen echten Host, damit forHost() ueberhaupt greifen kann. + return [$pve, Instance::factory()->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()])]; +} + +it('legt einen Benutzer mit erzeugtem Passwort an, das niemand sieht', function () { + [$pve, $instance] = gastBereit(); + // FakeProxmoxClient antwortet auf einen ungeskripteten Befehl mit Exitcode + // 0 — ohne diese Zeile saehe user:info wie "Benutzer existiert bereits" + // aus. Dieselbe Falle, dasselbe Skript wie in CustomerStepsTest. + $pve->guestScript('user:info', 1); + $sitz = Seat::factory()->create(['email' => 'anna@firma.tld', 'name' => 'Anna', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->invite($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + // --generate-password: Nextcloud erzeugt es, NIEMAND bekommt es zu sehen. + // --email: dorthin geht der Link, an dem der Mitarbeiter sein eigenes setzt. + expect($befehle)->toContain('--generate-password') + ->and($befehle)->toContain('--email') + ->and($befehle)->toContain('anna@firma.tld'); +}); + +it('verschickt bei einem bestehenden Benutzer nur die Willkommensmail neu', function () { + [$pve, $instance] = gastBereit(); + $pve->guestScripts['user:info'] = ['exitcode' => 0, 'out-data' => 'user_id: anna@firma.tld']; + $sitz = Seat::factory()->create(['email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->invite($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + // Wiederholbar: ein zweiter Lauf nach einem Absturz legt keinen zweiten + // Benutzer an. Genau wie CreateCustomerAdmin es tut. + expect($befehle)->toContain('user:welcome --reset-password') + ->and($befehle)->not->toContain('user:add'); +}); + +it('setzt bei readonly einen eigenen Speicherplatz von null', function () { + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['role' => 'readonly', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->applyRole($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('group:adduser') + ->and($befehle)->toContain('nur-lesen') + ->and($befehle)->toContain('files quota') + ->and($befehle)->toContain('0 B'); +}); + +it('LOESCHT den eigenen Speicherplatz, wenn readonly verlassen wird', function () { + // Die Falle aus ApplyStorageQuota: "An account with an explicit quota stops + // following the default". Ein Konto, das mit einem festen Wert aus der + // Rolle herauskommt, waere bei der naechsten Paketaenderung stumm + // ausgenommen — und niemand merkte es, bis der Kunde fragt, warum sein + // Mitarbeiter weniger Platz hat als bezahlt. + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['role' => 'member', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->applyRole($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('files quota --delete') + ->and($befehle)->not->toContain('0 B'); +}); + +it('wirft Sitzungen beim Sperren SOFORT hinaus', function () { + // user:disable allein laesst laufende Sitzungen bis zu fuenf Minuten + // weiterleben. Bei einem Mitarbeiter, der gerade gegangen ist, sind fuenf + // Minuten fuenf zu viel. + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->disable($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('user:disable') + ->and($befehle)->toContain('user:auth-tokens:delete'); +}); + +it('gibt false zurueck statt zu werfen, wenn der Gast nicht antwortet', function () { + [$pve, $instance] = gastBereit(); + $pve->guestThrows[201] = new RuntimeException('guest agent unreachable'); + $sitz = Seat::factory()->create(['nc_username' => 'anna@firma.tld']); + + expect(app(NextcloudUsers::class)->disable($instance, $sitz))->toBeFalse(); +}); + +it('fuehrt gar nichts aus, wenn der Benutzername keiner ist', function () { + // Der Name wandert in eine Wurzel-Shell im Gast. Dieselbe Regel wie bei + // HostFirewall: ein Dienst, der eine Shell fuettert, darf sich nicht + // darauf verlassen, dass sein Aufrufer sauber war. + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['nc_username' => 'anna; rm -rf /']); + + expect(app(NextcloudUsers::class)->disable($instance, $sitz))->toBeFalse() + ->and($pve->guestCommands)->toBe([]); +});