CluPilotCloud/app/Providers/AppServiceProvider.php

122 lines
5.5 KiB
PHP

<?php
namespace App\Providers;
use App\Provisioning\PipelineRegistry;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Ssh\PhpseclibRemoteShell;
use App\Services\Ssh\RemoteShell;
use App\Services\Traefik\SshTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub;
use App\Http\Middleware\RestrictAdminHost;
use App\Mail\MaintenanceCancelledMail;
use App\Models\MaintenanceNotification;
use App\Services\Maintenance\MaintenanceNotifier;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(PipelineRegistry::class, fn () => new PipelineRegistry(
config('provisioning.pipelines', []),
));
// Real I/O implementations; tests swap in fakes via app()->instance().
$this->app->bind(RemoteShell::class, PhpseclibRemoteShell::class);
$this->app->bind(WireguardHub::class, LocalWireguardHub::class);
$this->app->bind(ProxmoxClient::class, HttpProxmoxClient::class);
$this->app->bind(HetznerDnsClient::class, HttpHetznerDnsClient::class);
$this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
$this->app->bind(MonitoringClient::class, HttpMonitoringClient::class);
$this->app->bind(StripeClient::class, HttpStripeClient::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Livewire posts every component action to /livewire/update, which a
// path-based guard would skip. Marking the host restriction persistent
// makes Livewire re-apply it from the component's original route, so an
// admin action cannot be driven through a public hostname.
Livewire::addPersistentMiddleware(RestrictAdminHost::class);
// Send-time guard for maintenance mail (X-CP-Notification carries the
// ledger id). Deliberately READ-ONLY — it never marks the row sent or
// claimed, because returning false makes Laravel treat the send as a
// successful cancellation: pre-claiming here would silently DROP a mail
// whose transport later fails and retries. It only suppresses a send that
// is definitively pointless: the window was cancelled, or a sibling copy
// already delivered (sent_at set). sent_at is recorded on real delivery
// in MessageSent, so nothing is ever lost.
//
// Residual: two jobs whose MessageSending both fire before either's
// MessageSent (true simultaneous multi-worker send of a stale resend) can
// still both deliver. That cannot happen on the current single-worker
// log-mail setup; true exactly-once needs a transactional outbox + a
// dedicated delivery worker keyed on claimed_at, to be added with real mail.
Event::listen(MessageSending::class, function (MessageSending $event) {
$header = $event->message->getHeaders()->get('X-CP-Notification');
if ($header === null) {
return null;
}
$notification = MaintenanceNotification::query()->with('window')->find((int) $header->getBodyAsString());
if ($notification === null) {
return null;
}
if ($notification->sent_at !== null) {
return false; // already delivered — suppress a duplicate copy
}
if ($notification->event === 'announcement' && $notification->window?->state === 'cancelled') {
return false; // window cancelled meanwhile — do not deliver
}
return null;
});
// Stamp a maintenance-notification ledger row as delivered only once the
// mail is actually sent (the X-CP-Notification header carries the id).
// Until then sent_at stays null → the row is a retryable marker. If an
// announcement delivered for an already-cancelled window (the cancel race),
// queue a catch-up cancellation so that customer isn't left mis-informed.
Event::listen(MessageSent::class, function (MessageSent $event) {
$header = $event->message->getHeaders()->get('X-CP-Notification');
if ($header === null) {
return;
}
$notification = MaintenanceNotification::query()->with(['window', 'customer'])->find((int) $header->getBodyAsString());
if ($notification === null) {
return;
}
$notification->update(['sent_at' => now()]);
if ($notification->event === 'announcement' && $notification->window?->state === 'cancelled' && $notification->customer !== null) {
app(MaintenanceNotifier::class)->deliver(
$notification->window,
$notification->customer,
'cancelled',
new MaintenanceCancelledMail($notification->window, $notification->customer),
);
}
});
}
}