Der Griff, mit dem ein Sitz in der Nextcloud wirksam wird
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).feat/versandtakt
parent
ea54387a5e
commit
1bc1e972d6
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Nextcloud;
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Models\Seat;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use App\Support\NextcloudOcc;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Der Griff, mit dem ein Sitz in der Nextcloud eines Kunden wirksam wird.
|
||||
*
|
||||
* Nur Griffe: anlegen, einladen, Gruppe setzen, sperren, freigeben. WER wann
|
||||
* welchen zieht, steht in SyncSeatToNextcloud — dieselbe Trennung wie bei
|
||||
* HostFirewall und BlockAddress.
|
||||
*
|
||||
* Keine Methode wirft. Ein nicht erreichbarer Gast gibt `false` zurueck, und
|
||||
* der Auftrag schreibt das an den Sitz, wo der Inhaber es liest. Eine
|
||||
* Ausnahme wuerde stattdessen den Bereitstellungs-Arbeiter mitreissen, auf dem
|
||||
* die bezahlte Kundenbereitstellung laeuft.
|
||||
*
|
||||
* Jeder Benutzername geht vor dem Einsetzen durch `isWellFormed()`. Das ist
|
||||
* kein doppelter Boden fuer einen ohnehin sauberen Aufrufer, sondern die
|
||||
* Bedingung dafuer, dass dieser Dienst eine Shell im Gast fuettern darf.
|
||||
*/
|
||||
class NextcloudUsers
|
||||
{
|
||||
public function __construct(private ProxmoxClient $pve) {}
|
||||
|
||||
/** Anlegen falls noetig, danach die Willkommensmail — in einem Zug. */
|
||||
public function invite(Instance $instance, Seat $seat): bool
|
||||
{
|
||||
$user = (string) $seat->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<int, string> $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php // tests/Feature/Nextcloud/NextcloudUsersTest.php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Seat;
|
||||
use App\Services\Nextcloud\NextcloudUsers;
|
||||
use App\Services\Proxmox\FakeProxmoxClient;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
|
||||
function gastBereit(): array
|
||||
{
|
||||
$pve = new FakeProxmoxClient;
|
||||
app()->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([]);
|
||||
});
|
||||
Loading…
Reference in New Issue