Die Kundeninstanz lernt Mail verschicken

Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die
Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort
dort entsteht, wo niemand sonst es sieht.

Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert
mit Grund.

Zwei bestehende Pruefungen mitgezogen: CustomerStepBaseTest erwartete eine
feste Schrittzahl (16 -> 17), und CustomerProvisioningEndToEndTest lief ohne
Mailversand-Fixtures durch die volle Pipeline und scheiterte jetzt genau dort
— beide auf dieselbe Art nachgezogen wie ApplyStorageQuota es vormacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
claude/nice-moser-521659
nexxo 2026-08-03 19:59:44 +02:00
parent 58b82aa8a2
commit 499240dd45
5 changed files with 245 additions and 2 deletions

View File

@ -0,0 +1,96 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Mail\GuestMailConfig;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\NextcloudOcc;
/**
* Bringt der Nextcloud eines Kunden bei, Mail zu verschicken.
*
* Bis hierher konnte sie es NICHT weder die Bereitstellung noch die
* Compose-Datei der Vorlage setzten je einen Versand. Damit verschickte jede
* Kundeninstanz gar nichts: keine Freigabe-Benachrichtigung, keine
* Aktivitaetsmeldung, kein "Passwort vergessen". Still, ohne Fehlermeldung.
*
* Das ist die Voraussetzung fuer die Mitarbeiterverwaltung: die Einladung an
* einen Mitarbeiter verschickt NEXTCLOUD, nicht CluPilot nur so entsteht
* das Passwort dort, wo niemand sonst es zu sehen bekommt.
*
* Ohne Server oder Absenderpostfach wird NICHTS geschrieben und der Schritt
* scheitert mit einem Grund. Eine halb eingetragene Mailkonfiguration waere
* schlimmer als keine.
*
* WO DAS SMTP-PASSWORT UEBERALL SICHTBAR IST ehrlich aufgeschrieben, weil
* ein erster Entwurf es zu verstecken versuchte und das Verstecken nichts
* brachte:
*
* - im Argv des `/bin/sh -c`, das der Gastagent fuer diesen Aufruf startet
* (guestExec baut die ganze Zeile so). Debians `dash` behaelt diesen
* Prozess fuer die volle Dauer des Aufrufs;
* - im Argv von `php occ` im Container;
* - und DAUERHAFT im Klartext in /opt/nextcloud config/config.php, wo
* Nextcloud es selbst ablegt.
*
* Der dritte Punkt erledigt die ersten beiden: wer die Datei lesen kann, muss
* gar nicht erst in eine Prozessliste schauen. Ein Versteck, das dieselbe
* Person nicht aussperrt, ist keines und ein Kommentar, der eines
* behauptet, haelt den Naechsten vom Nachsehen ab.
*
* Die Eingrenzung liegt deshalb in der Infrastruktur, nicht hier: das
* Versandkonto kann nur senden, der Versandport nimmt nur die eigenen
* Hostadressen an, und der Mailserver drosselt je Konto. Was hier gilt und
* geprueft wird, ist etwas anderes: der Wert wird maskiert, damit er keinen
* zweiten Befehl starten kann.
*/
class ConfigureInstanceMail extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'configure_instance_mail';
}
public function maxDuration(): int
{
return 120;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
if ($instance === null) {
return StepResult::fail('instance_missing');
}
$config = GuestMailConfig::for($instance);
if (! $config->available()) {
return StepResult::fail($config->problem() ?? 'mail_unavailable');
}
$pve = $this->pve->forHost($instance->host);
// Der Schluessel ist ein fester occ-Name aus GuestMailConfig::values(),
// keine Benutzereingabe — genauso unmaskiert wie 'files default_quota'
// in ApplyStorageQuota. Maskiert wird nur der WERT, siehe Kopfkommentar.
foreach ($config->values() as $schluessel => $wert) {
$this->guest($pve, $run, NextcloudOcc::command(
'config:system:set '.$schluessel.' --value='.escapeshellarg($wert)
));
}
// Das Passwort wie jeder andere Wert. Verstecken laesst es sich auf
// dieser Maschine nicht — siehe Kopfkommentar.
$this->guest($pve, $run, NextcloudOcc::command(
'config:system:set mail_smtppassword --value='.escapeshellarg($config->password())
));
return StepResult::advance();
}
}

View File

@ -80,6 +80,10 @@ return [
Customer\ConfigureNetwork::class,
Customer\DeployApplicationStack::class,
Customer\ConfigureNextcloud::class,
// Ohne diesen Schritt verschickt die Kundeninstanz ueberhaupt
// keine Mail — auch nicht die Einladung an einen Mitarbeiter, die
// Nextcloud selbst verschickt.
Customer\ConfigureInstanceMail::class,
// The allowance the customer paid for, enforced on the machine.
// It was never applied anywhere before: quota_gb reached the
// instance row and stopped there, so every package delivered the
@ -241,6 +245,15 @@ return [
Host\SecureHostFirewall::class,
],
/*
| Einer bestehenden Instanz den Mailversand nachtragen. Ein Schritt,
| derselbe wie im Aufbau ein zweiter Weg, dieselben Werte zu
| schreiben, wuerde driften.
*/
'instance-mail' => [
Customer\ConfigureInstanceMail::class,
],
/*
| A storage pack bought, or given back.
|

View File

@ -0,0 +1,118 @@
<?php // tests/Feature/Provisioning/ConfigureInstanceMailTest.php
use App\Models\Host;
use App\Models\Instance;
use App\Models\Mailbox;
use App\Models\ProvisioningRun;
use App\Provisioning\Steps\Customer\ConfigureInstanceMail;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\Settings;
function laufMitInstanz(Instance $instance): ProvisioningRun
{
return ProvisioningRun::factory()->create([
'pipeline' => 'instance-mail',
'context' => ['instance_id' => $instance->id, 'node' => 'pve', 'vmid' => 201],
]);
}
/**
* Eine Instanz, die tatsaechlich auf einem Host steht.
*
* Der Schritt ruft ProxmoxClient::forHost($instance->host) auf genau wie
* ApplyStorageQuota, dessen Testaufbau (ApplyStorageQuotasTest::unquotedInstance())
* aus demselben Grund einen Host anlegt. Eine Instanz ohne host_id gibt es in
* diesem Bestand fuer eine Maschine, die tatsaechlich Gastbefehle bekommt,
* nicht FakeProxmoxClient::forHost() verlangt ein echtes Host-Objekt, weil
* der reale Client genauso wenig ohne einen Host wuesste, wohin er soll.
*/
function instanzAufHost(): Instance
{
return Instance::factory()->create(['host_id' => Host::factory()]);
}
function versandbereitFuerSchritt(): void
{
Settings::set('mail.host', 'mail.clupilot.cloud');
Settings::set('mail.port', 587);
Settings::set('mail.encryption', 'tls');
Mailbox::factory()->create([
'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud',
'username' => 'noreply@clupilot.cloud', 'password' => 'geheim',
'active' => true, 'authenticates' => true,
]);
}
it('traegt jeden Wert einzeln in den Gast', function () {
versandbereitFuerSchritt();
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = instanzAufHost();
app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance));
$befehle = implode("\n", $pve->guestCommands);
expect($befehle)->toContain('config:system:set mail_smtphost --value=')
->and($befehle)->toContain('mail.clupilot.cloud')
->and($befehle)->toContain('config:system:set mail_domain --value=')
->and($befehle)->toContain('clupilot.cloud');
});
it('maskiert das Passwort, sodass es keinen zweiten Befehl starten kann', function () {
// Verstecken laesst sich der Wert auf dieser Maschine nicht (siehe
// Kopfkommentar des Schrittes). Was sehr wohl gilt und geprueft gehoert:
// er darf aus seiner Klammerung nicht ausbrechen. Der Befehl laeuft als
// root auf einer Kundenmaschine — ein Semikolon im Passwort waere dort
// ein zweiter Befehl.
Settings::set('mail.host', 'mail.clupilot.cloud');
Settings::set('mail.port', 587);
Mailbox::factory()->create([
'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud',
'username' => 'noreply@clupilot.cloud',
'password' => "boes'; touch /tmp/PWNED; echo '",
'active' => true, 'authenticates' => true,
]);
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
app(ConfigureInstanceMail::class)->execute(laufMitInstanz(instanzAufHost()));
$passwortbefehl = collect($pve->guestCommands)
->first(fn ($b) => str_contains($b, 'mail_smtppassword'));
// Der ganze Wert steht in EINEM maskierten Argument: das Semikolon darf
// nicht ausserhalb der Anfuehrungszeichen stehen.
expect($passwortbefehl)->toContain(escapeshellarg("boes'; touch /tmp/PWNED; echo '"));
});
it('schreibt GAR NICHTS, wenn der Mailserver fehlt', function () {
Settings::set('mail.host', '');
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$ergebnis = app(ConfigureInstanceMail::class)->execute(laufMitInstanz(Instance::factory()->create()));
expect($pve->guestCommands)->toBe([])
->and($ergebnis->type)->toBe(\App\Provisioning\StepResult::FAIL)
->and($ergebnis->reason)->toBe('no_server');
});
it('schreibt beim zweiten Lauf erneut, statt sich mit einem Merker zu sperren', function () {
versandbereitFuerSchritt();
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = instanzAufHost();
app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance));
$ersteRunde = count($pve->guestCommands);
app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance));
// config:system:set ist von sich aus wiederholbar — derselbe Wert zweimal
// geschrieben ist derselbe Wert. Der Schritt darf deshalb ohne Merker
// erneut laufen; das ist bei einer Nachruestung ueber den Bestand der
// Normalfall, nicht die Ausnahme.
expect(count($pve->guestCommands))->toBe($ersteRunde * 2);
});

View File

@ -2,12 +2,14 @@
use App\Models\Host;
use App\Models\Instance;
use App\Models\Mailbox;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\RunResource;
use App\Notifications\CloudReady;
use App\Provisioning\RunRunner;
use App\Provisioning\Steps\Customer\CloneVirtualMachine;
use App\Support\Settings;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
@ -20,6 +22,20 @@ it('provisions a paid order all the way to active (mocked)', function () {
$s['pve']->guestScript('occ status', 0, '{"installed":true,"maintenance":false}'); // deploy health + acceptance probe
// Fake defaults: tasks stopped/OK, guest agent up, cert reachable.
// ConfigureInstanceMail faellt jetzt in dieser Pipeline: ohne Server und
// Absenderpostfach faellt der ganze Lauf (Absicht, siehe GuestMailConfig
// und ConfigureInstanceMail selbst). Ein frischer Betrieb braucht diese
// Einrichtung einmalig, genau wie den Hetzner-DNS-Token oder den
// Stripe-Schluessel — hier nachgestellt, damit dieser Lauf ueberhaupt bis
// zum Ende kommt.
Settings::set('mail.host', 'mail.clupilot.cloud');
Settings::set('mail.port', 587);
Mailbox::factory()->create([
'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud',
'username' => 'noreply@clupilot.cloud', 'password' => 'geheim',
'active' => true, 'authenticates' => true,
]);
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
$order = Order::factory()->withSubscription()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']);
$run = ProvisioningRun::factory()->create([
@ -49,7 +65,7 @@ it('provisions a paid order all the way to active (mocked)', function () {
->and($instance->cert_ok)->toBeTrue()
->and($instance->onboardingTasks()->count())->toBeGreaterThan(0)
// The storage allowance was DELIVERED, not merely walked past. This test
// covered all sixteen steps and asserted nothing about the quota one, so it
// covers every step of the pipeline and asserted nothing about the quota one, so it
// proved that inserting the step did not break the pipeline rather than
// that the step does anything — and quota_applied_gb is written only after
// the guest accepted the figure, which is the whole distinction between an

View File

@ -30,7 +30,7 @@ it('builds every customer machine with the storage allowance that was sold', fun
// that the quota step is IN it: the allowance was applied nowhere for the
// whole life of the pipeline — quota_gb reached the instance row and
// stopped — so every package handed out the whole disk.
expect(config('provisioning.pipelines.customer'))->toHaveCount(16)
expect(config('provisioning.pipelines.customer'))->toHaveCount(17)
->toContain(ApplyStorageQuota::class);
});