201 lines
7.7 KiB
PHP
201 lines
7.7 KiB
PHP
<?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` zurück, und
|
|
* der Auftrag schreibt das an den Sitz, wo der Inhaber es liest. Eine
|
|
* Ausnahme würde stattdessen den Bereitstellungs-Arbeiter mitreissen, auf dem
|
|
* die bezahlte Kundenbereitstellung läuft.
|
|
*
|
|
* Jeder Benutzername geht vor dem Einsetzen durch `isWellFormed()`. Das ist
|
|
* kein doppelter Boden für einen ohnehin sauberen Aufrufer, sondern die
|
|
* Bedingung dafür, dass dieser Dienst eine Shell im Gast füttern darf.
|
|
*/
|
|
class NextcloudUsers
|
|
{
|
|
public function __construct(private ProxmoxClient $pve) {}
|
|
|
|
/** Anlegen falls nötig, 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.
|
|
//
|
|
// `2>/dev/null || true` hinter dem Entfernen ist kein Wegsehen,
|
|
// sondern die richtige Bedeutung — dieselbe Begründung wie bei
|
|
// HostFirewall::releaseMany(): jemanden aus einer Gruppe zu
|
|
// nehmen, in der er nicht ist, ist kein Fehler, sondern der
|
|
// gewünschte Endzustand.
|
|
//
|
|
// Und ohne das Netz war es der HAEUFIGSTE Fall, nicht ein
|
|
// Randfall: `mitarbeiter` und `nur-lesen` legt niemand an. Sie
|
|
// entstehen erst, wenn `user:add --group=…` sie zum ersten Mal
|
|
// braucht — `group:removeuser` legt nichts an und beendet mit
|
|
// Fehlercode, wenn die Gruppe fehlt. Auf einer frischen Instanz
|
|
// scheiterte damit die erste Rollenänderung dauerhaft, weil
|
|
// run() alle Exitcodes verundet und Wiederholen wieder `role`
|
|
// wählt.
|
|
//
|
|
// Nur fürs Entfernen. Ein gescheitertes `group:adduser` bleibt
|
|
// ein Fehlschlag: wer in keiner Gruppe landet, sieht in seiner
|
|
// neuen Cloud nichts.
|
|
foreach (array_unique(array_values(Seat::GROUPS)) as $gruppe) {
|
|
if ($gruppe !== $ziel) {
|
|
$befehle[] = 'group:removeuser '.escapeshellarg($gruppe).' '
|
|
.escapeshellarg($user).' 2>/dev/null || true';
|
|
}
|
|
}
|
|
|
|
$befehle[] = 'group:adduser '.escapeshellarg($ziel).' '.escapeshellarg($user);
|
|
|
|
// Der Speicherplatz. Siehe ApplyStorageQuota: ein Konto mit
|
|
// EIGENEM Wert folgt der Vorgabe der Instanz nicht mehr. Für
|
|
// readonly ist genau das gewollt; beim VERLASSEN der Rolle muss
|
|
// der eigene Wert deshalb WEG, nicht überschrieben 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 lässt laufende Sitzungen bis zu fünf
|
|
// Minuten weiterleben. Bei jemandem, der gerade gegangen ist,
|
|
// sind fünf Minuten fünf 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 lässt 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
|
|
* Rückruf 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;
|
|
}
|
|
}
|
|
}
|