fix(admin): never reset a stranger's VM, never leave the console spinning
Two from Codex: - The job accepted any stored instance with a host. A closed one's VMID can have been reused on the same machine, and the reset would then land on another customer's VM. It now requires a live instance, and the action is not offered for anything else. - An unreachable Proxmox or guest agent throws rather than returning an exit code, so nothing was ever written to the handoff and the modal polled forever. The throw is caught, and a failed() handler covers anything that still escapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
aae0457e19
commit
d40113be8e
|
|
@ -54,7 +54,7 @@ class Customers extends Component
|
||||||
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
|
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
|
||||||
'instance' => $instance->subdomain ?? '—',
|
'instance' => $instance->subdomain ?? '—',
|
||||||
// Only an instance that exists can hand out an admin login.
|
// Only an instance that exists can hand out an admin login.
|
||||||
'instance_uuid' => $instance->uuid ?? null,
|
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
|
||||||
'closed' => $c->closed_at !== null || $c->status === 'closed',
|
'closed' => $c->closed_at !== null || $c->status === 'closed',
|
||||||
'suspended' => $c->status === 'suspended',
|
'suspended' => $c->status === 'suspended',
|
||||||
'status' => match (true) {
|
'status' => match (true) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hands an operator working administrator credentials for a customer's
|
* Hands an operator working administrator credentials for a customer's
|
||||||
|
|
@ -41,11 +42,20 @@ class IssueInstanceAdminAccess implements ShouldQueue
|
||||||
$this->onQueue('provisioning');
|
$this->onQueue('provisioning');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anything that escapes handle() still has to reach the waiting console. */
|
||||||
|
public function failed(?Throwable $e): void
|
||||||
|
{
|
||||||
|
ConfigHandoff::put(json_encode(['error' => 'failed']), $this->handoffToken);
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(ProxmoxClient $proxmox): void
|
public function handle(ProxmoxClient $proxmox): void
|
||||||
{
|
{
|
||||||
$instance = Instance::query()->with('host')->where('uuid', $this->instanceUuid)->first();
|
$instance = Instance::query()->with('host')->where('uuid', $this->instanceUuid)->first();
|
||||||
|
|
||||||
if ($instance === null || $instance->host === null || blank($instance->nc_admin_ref)) {
|
// Only a live instance: a closed one's VMID can have been reused on the
|
||||||
|
// same host, and the reset would then land on a stranger's VM.
|
||||||
|
if ($instance === null || $instance->status !== 'active'
|
||||||
|
|| $instance->host === null || blank($instance->vmid) || blank($instance->nc_admin_ref)) {
|
||||||
ConfigHandoff::put(json_encode(['error' => 'unavailable']), $this->handoffToken);
|
ConfigHandoff::put(json_encode(['error' => 'unavailable']), $this->handoffToken);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
@ -54,13 +64,25 @@ class IssueInstanceAdminAccess implements ShouldQueue
|
||||||
$username = $instance->nc_admin_ref;
|
$username = $instance->nc_admin_ref;
|
||||||
$password = Str::password(24, symbols: false);
|
$password = Str::password(24, symbols: false);
|
||||||
|
|
||||||
$result = $proxmox->forHost($instance->host)->guestExec(
|
try {
|
||||||
$instance->host->node ?? 'pve',
|
$result = $proxmox->forHost($instance->host)->guestExec(
|
||||||
(int) $instance->vmid,
|
$instance->host->node ?? 'pve',
|
||||||
'cd /opt/nextcloud && OC_PASS='.escapeshellarg($password).
|
(int) $instance->vmid,
|
||||||
' docker compose exec -T -e OC_PASS app php occ user:resetpassword --password-from-env '.
|
'cd /opt/nextcloud && OC_PASS='.escapeshellarg($password).
|
||||||
escapeshellarg($username),
|
' docker compose exec -T -e OC_PASS app php occ user:resetpassword --password-from-env '.
|
||||||
);
|
escapeshellarg($username),
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// An unreachable Proxmox or guest agent throws rather than
|
||||||
|
// returning a code. Without this the console would poll forever
|
||||||
|
// instead of saying what happened.
|
||||||
|
Log::warning('instance admin access errored', [
|
||||||
|
'instance' => $instance->uuid, 'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
ConfigHandoff::put(json_encode(['error' => 'failed']), $this->handoffToken);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ((int) ($result['exitcode'] ?? 1) !== 0) {
|
if ((int) ($result['exitcode'] ?? 1) !== 0) {
|
||||||
Log::warning('instance admin access failed', [
|
Log::warning('instance admin access failed', [
|
||||||
|
|
|
||||||
|
|
@ -85,3 +85,32 @@ it('reports a silent instance instead of pretending it worked', function () {
|
||||||
|
|
||||||
expect(json_decode(ConfigHandoff::get($token), true))->toBe(['error' => 'failed']);
|
expect(json_decode(ConfigHandoff::get($token), true))->toBe(['error' => 'failed']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses an instance that is no longer live', function () {
|
||||||
|
$pve = new FakeProxmoxClient;
|
||||||
|
app()->instance(ProxmoxClient::class, $pve);
|
||||||
|
$instance = adminAccessInstance();
|
||||||
|
$instance->update(['status' => 'terminated']);
|
||||||
|
|
||||||
|
(new IssueInstanceAdminAccess($instance->uuid, 'handoff-dead', 1))->handle($pve);
|
||||||
|
|
||||||
|
// Its VMID may belong to someone else by now.
|
||||||
|
expect(json_decode(ConfigHandoff::get('handoff-dead'), true))->toBe(['error' => 'unavailable']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the console when Proxmox is unreachable', function () {
|
||||||
|
$pve = new class extends FakeProxmoxClient
|
||||||
|
{
|
||||||
|
public function guestExec(string $node, int $vmid, string $command): array
|
||||||
|
{
|
||||||
|
throw new RuntimeException('no route to host');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app()->instance(ProxmoxClient::class, $pve);
|
||||||
|
$instance = adminAccessInstance();
|
||||||
|
|
||||||
|
(new IssueInstanceAdminAccess($instance->uuid, 'handoff-throw', 1))->handle($pve);
|
||||||
|
|
||||||
|
// Otherwise the modal polls forever with a spinner.
|
||||||
|
expect(json_decode(ConfigHandoff::get('handoff-throw'), true))->toBe(['error' => 'failed']);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue