136 lines
5.9 KiB
PHP
136 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace App\Actions;
|
|
|
|
use App\Models\Instance;
|
|
use App\Services\Dns\HetznerDnsClient;
|
|
use App\Services\Traefik\TraefikWriter;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Take an instance's address down when its service has genuinely ended.
|
|
*
|
|
* `TraefikWriter::remove()` had no caller anywhere in the application. Every
|
|
* router this platform ever wrote was written for good: the file stayed on the
|
|
* serving host, the hostname kept resolving, and it kept being routed to a guest
|
|
* address that the host is free to hand to somebody else's VM later. That is not
|
|
* a tidiness problem — it is one customer's hostname pointing at another
|
|
* customer's Nextcloud, with a valid certificate on it.
|
|
*
|
|
* ## What "ended" means here
|
|
*
|
|
* Exactly one thing: the instance is `cancellation_scheduled` AND its
|
|
* `service_ends_at` has passed. Not the moment somebody cancels — a customer who
|
|
* schedules a cancellation on the second of the month has paid to the end of the
|
|
* month and must keep working through it, and pulling their address that
|
|
* afternoon would be a serious fault, worse than the leak this closes. Not a
|
|
* `failed` build either: those never got as far as a router, and the ones that
|
|
* did are being retried from the console. A `cancellation_scheduled` row without
|
|
* a `service_ends_at` at all is left alone on purpose — that is a broken record
|
|
* rather than an expired one, and guessing an end date for it would invent the
|
|
* very moment this action exists to respect.
|
|
*
|
|
* ## The DNS record goes with the route
|
|
*
|
|
* Deliberately, and for the same reason the route does. The A record for
|
|
* `{subdomain}.{zone}` is in OUR zone and points at the serving host's public
|
|
* address — a host that goes on serving other customers. Left published it is a
|
|
* name we own, resolving to a live machine, with nothing behind it that belongs
|
|
* to the person it is named after: the shape of every subdomain-takeover there
|
|
* has ever been. It also keeps the customer's old address looking alive, and it
|
|
* would collide the day the same subdomain is issued again. Removing the route
|
|
* without the record would leave the worse half standing.
|
|
*
|
|
* The CUSTOM domain is the opposite case and nothing is done to it. Its A record
|
|
* lives in the customer's own zone, which we have never had access to and never
|
|
* will; it is theirs, it stops reaching us the moment the router goes, and it is
|
|
* for them to point somewhere else.
|
|
*
|
|
* The virtual machine is NOT touched. The founder's cancellation flow promises a
|
|
* finished data export before deprovisioning, and destroying somebody's disks is
|
|
* not a decision this action gets to make on the way past. It takes the address
|
|
* away — which is what was leaking — and leaves the machine for whatever carries
|
|
* out that flow.
|
|
*/
|
|
class EndInstanceService
|
|
{
|
|
public function __construct(
|
|
private TraefikWriter $traefik,
|
|
private HetznerDnsClient $dns,
|
|
) {}
|
|
|
|
/**
|
|
* Has this instance's paid service actually run out?
|
|
*
|
|
* The rule lives here rather than in the command's query so that anything
|
|
* else that ever ends an instance asks the same question — a second copy of
|
|
* "is it over yet" is how one caller ends up a month out from the other.
|
|
*/
|
|
public function hasEnded(Instance $instance, ?Carbon $at = null): bool
|
|
{
|
|
return $instance->status === 'cancellation_scheduled'
|
|
&& $instance->service_ends_at !== null
|
|
&& $instance->service_ends_at->lessThanOrEqualTo($at ?? now());
|
|
}
|
|
|
|
/**
|
|
* Tear the address down and mark the instance ended.
|
|
*
|
|
* Re-entrant: removing a router file that is not there is `rm -f`, a record
|
|
* id that has already been deleted is deleted again for nothing, and an
|
|
* instance already marked `ended` is returned false straight away. A DNS
|
|
* failure is deliberately not swallowed — the record id survives on its row,
|
|
* so a retry can finish the job, and a provider that is permanently broken
|
|
* becomes a visible failure rather than a silent leak.
|
|
*
|
|
* @return bool whether this call was the one that ended it
|
|
*/
|
|
public function __invoke(Instance $instance): bool
|
|
{
|
|
if (! $this->hasEnded($instance)) {
|
|
return false;
|
|
}
|
|
|
|
$host = $instance->host;
|
|
|
|
if ($host !== null) {
|
|
// The host that actually serves the traffic — DNS points at it, and
|
|
// it is where ConfigureDnsAndTls wrote the file in the first place.
|
|
$this->traefik->remove($host->wg_ip ?? $host->public_ip, $instance->subdomain);
|
|
} else {
|
|
// No host left to reach: the router file went with the machine it
|
|
// lived on. Said out loud rather than passed over, because it means
|
|
// this teardown is only doing half of what it usually does.
|
|
Log::warning('Ending an instance whose host is gone — no router to remove.', [
|
|
'instance' => $instance->uuid,
|
|
]);
|
|
}
|
|
|
|
foreach ($instance->dnsRecords()->get() as $record) {
|
|
$this->dns->deleteRecord($record->record_id);
|
|
$record->delete();
|
|
}
|
|
|
|
$instance->update([
|
|
'status' => 'ended',
|
|
// The address state has to go with the address. Left as it was, a
|
|
// later run would read `route_written` plus a matching hostname list
|
|
// and decide there was nothing to write — so an instance that was
|
|
// ever revived would be announced and routed nowhere.
|
|
'route_written' => false,
|
|
'routed_hostnames' => null,
|
|
'cert_ok' => false,
|
|
'domain_cert_ok' => false,
|
|
]);
|
|
|
|
Log::info('Instance service ended; address withdrawn.', [
|
|
'instance' => $instance->uuid,
|
|
'subdomain' => $instance->subdomain,
|
|
'service_ended_at' => $instance->service_ends_at?->toIso8601String(),
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
}
|