CluPilotCloud/app/Providers/AppServiceProvider.php

212 lines
10 KiB
PHP

<?php
namespace App\Providers;
use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\EnsureCustomerActive;
use App\Http\Middleware\RestrictAdminHost;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Listeners\RecordSentMail;
use App\Listeners\RecordSignInDevice;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\Transport\MailboxTransport;
use App\Models\Customer;
use App\Models\MaintenanceNotification;
use App\Provisioning\PipelineRegistry;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Dns\FileHostDnsDirectory;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Dns\HostDnsDirectory;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Mail\ImapMailbox;
use App\Services\Mail\InboundMailbox;
use App\Services\Maintenance\MaintenanceNotifier;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Ssh\PhpseclibRemoteShell;
use App\Services\Ssh\RemoteShell;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Tax\VatIdVerifier;
use App\Services\Tax\ViesVatIdVerifier;
use App\Services\Traefik\SshTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Events\Login;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Mail;
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(HostDnsDirectory::class, FileHostDnsDirectory::class);
$this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
$this->app->bind(MonitoringClient::class, HttpMonitoringClient::class);
$this->app->bind(StripeClient::class, HttpStripeClient::class);
$this->app->bind(VatIdVerifier::class, ViesVatIdVerifier::class);
// The support mailbox, read over IMAP. A test puts a FakeMailbox here:
// the socket half cannot be exercised without a mail server, and a test
// that needs one is a test nobody runs. What IS exercised is the
// parsing and every decision the ingest makes afterwards.
$this->app->bind(InboundMailbox::class, ImapMailbox::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Registered as a DRIVER, not as configuration: the closure runs when a
// mailer is first resolved — inside the queue worker, at send time —
// so nothing here reads the database while the application boots.
Mail::extend('mailbox', fn (array $config) => new MailboxTransport($config['purpose']));
// The one thing the portal navigation has to ask that is not a
// permission: the sidebar filters on can(), and a customer holds no
// permissions at all (R21 puts all seventeen on the operator guard).
// The gate delegates rather than deciding — CustomDomainAccess stays
// the single place the rule lives, so the menu entry and the page it
// leads to can never disagree about who may be there.
Gate::define('use-custom-domain', fn ($user) => app(CustomDomainAccess::class)
->allowsCustomer(Customer::forUser($user)));
// One listener for both ways in. The console signs in through
// App\Livewire\Auth\OperatorLogin and the portal through Fortify — two
// paths that share no code but both raise this event, which carries the
// guard that tells the two identities apart (R21). Registered here
// rather than left to listener discovery, so that moving the class
// cannot silently stop sign-ins being recorded.
Event::listen(Login::class, RecordSignInDevice::class);
// Every timestamp that gets shown to somebody goes through ->local()
// first. Storage is UTC and stays UTC; this is the last step before a
// human reads it.
//
// It exists as one macro rather than a convention because the
// convention failed: fourteen views formatted times straight out of
// the database, and two of them converted to the STORAGE zone — which
// reads like "convert to local" and, that zone being UTC, does
// nothing. A no-op in the costume of a fix is worse than no call at
// all, because it stops the next person looking.
//
// copy() is not decoration: Illuminate\Support\Carbon is MUTABLE, so
// setTimezone() on the instance would rewrite the model attribute as
// a side effect of rendering it, and whatever compared or saved that
// value afterwards would be an hour or two out.
// Registered on both classes with the SAME body on purpose: Carbon
// keeps macros in one global table, so a second registration replaces
// the first for every Carbon class. Two different bodies means the
// last one wins for both — which is how the immutable version, safe
// for itself, silently became the mutable one's implementation too.
$local = fn () => $this->copy()->setTimezone(config('app.display_timezone'));
Carbon::macro('local', $local);
CarbonImmutable::macro('local', $local);
// 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 re-applies only the middleware on this list when an action
// posts to /livewire/update. Its own defaults cover `auth` — but not
// ours. Without these two, a signed-in NON-operator could drive console
// components, and a suspended customer could keep driving the portal:
// the page would never load for them, but the page is not where the
// actions run.
Livewire::addPersistentMiddleware([
RestrictAdminHost::class,
RestrictConsoleNetwork::class,
EnsureAdmin::class,
EnsureCustomerActive::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;
});
// The register of what this installation has sent, and to whom. One
// listener rather than a call in each mailable: a register that has to
// be remembered at every call site is a register with holes in it, and
// the holes are the mails nobody thought about.
Event::listen(MessageSent::class, RecordSentMail::class);
// 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),
);
}
});
}
}