VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
351 lines
14 KiB
PHP
351 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Infrastructure;
|
|
|
|
use App\Jobs\ProvisionContaboInfrastructureJob;
|
|
use App\Models\ContaboInfrastructureProvision;
|
|
use App\Models\EmailServer;
|
|
use App\Models\HostingNode;
|
|
use App\Models\User;
|
|
use App\Services\Hosting\Providers\ContaboProvider;
|
|
use App\Services\Mail\EmailServerPoolService;
|
|
use App\Services\Mail\MailServerNfsService;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ContaboInfrastructureProvisioner
|
|
{
|
|
public function __construct(
|
|
private ContaboProvider $contabo,
|
|
private ContaboInfrastructureSshKeyService $sshKeys,
|
|
private ContaboInfrastructureCloudInitBuilder $cloudInit,
|
|
private EmailServerPoolService $emailPool,
|
|
private MailServerNfsService $mailNfs,
|
|
) {}
|
|
|
|
public function isEnabled(): bool
|
|
{
|
|
return (bool) config('infrastructure.contabo.enabled', true)
|
|
&& config('hosting.contabo.client_id')
|
|
&& config('hosting.contabo.client_secret');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
*/
|
|
public function startHostingNode(array $input, ?User $actor = null): ContaboInfrastructureProvision
|
|
{
|
|
$this->assertEnabled();
|
|
|
|
$keys = $this->sshKeys->generate();
|
|
$placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254');
|
|
|
|
$node = HostingNode::create([
|
|
'name' => (string) $input['name'],
|
|
'hostname' => (string) $input['hostname'],
|
|
'ip_address' => $placeholderIp,
|
|
'type' => (string) ($input['type'] ?? HostingNode::TYPE_SHARED),
|
|
'role' => (string) ($input['role'] ?? HostingNode::ROLE_PRIMARY),
|
|
'primary_hosting_node_id' => $input['primary_hosting_node_id'] ?? null,
|
|
'segment' => (string) ($input['segment'] ?? HostingNode::SEGMENT_GENERAL),
|
|
'provider' => HostingNode::PROVIDER_CONTABO,
|
|
'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
|
|
'cpu_cores' => $input['cpu_cores'] ?? null,
|
|
'ram_mb' => $input['ram_mb'] ?? null,
|
|
'disk_gb' => $input['disk_gb'] ?? null,
|
|
'bandwidth_tb' => $input['bandwidth_tb'] ?? null,
|
|
'max_accounts' => (int) ($input['max_accounts'] ?? 100),
|
|
'oversell_ratio' => (float) ($input['oversell_ratio'] ?? 3),
|
|
'ssh_port' => 22,
|
|
'ssh_private_key' => $keys['private_key'],
|
|
'status' => HostingNode::STATUS_PROVISIONING,
|
|
]);
|
|
|
|
$provision = $this->createProvision(
|
|
purpose: ContaboInfrastructureProvision::PURPOSE_HOSTING_NODE,
|
|
name: (string) $input['name'],
|
|
productId: (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.hosting_product_id', 'V97')),
|
|
region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
|
|
imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')),
|
|
payload: $input,
|
|
keys: $keys,
|
|
hostingNodeId: $node->id,
|
|
actor: $actor,
|
|
);
|
|
|
|
ProvisionContaboInfrastructureJob::dispatch($provision);
|
|
|
|
return $provision;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
*/
|
|
public function startEmailServer(array $input, ?User $actor = null): ContaboInfrastructureProvision
|
|
{
|
|
$this->assertEnabled();
|
|
|
|
$isExtension = ($input['role'] ?? EmailServer::ROLE_PRIMARY) === EmailServer::ROLE_EXTENSION;
|
|
$keys = $this->sshKeys->generate();
|
|
$mailHost = (string) ($input['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com'));
|
|
$placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254');
|
|
$mailDbPassword = $isExtension ? null : Str::password(32);
|
|
|
|
$primary = null;
|
|
if ($isExtension) {
|
|
$primary = EmailServer::findOrFail($input['primary_email_server_id']);
|
|
}
|
|
|
|
$server = EmailServer::create([
|
|
'name' => (string) $input['name'],
|
|
'hostname' => (string) ($input['hostname'] ?? $mailHost),
|
|
'ip_address' => $placeholderIp,
|
|
'mail_host' => $mailHost,
|
|
'role' => $isExtension ? EmailServer::ROLE_EXTENSION : EmailServer::ROLE_PRIMARY,
|
|
'primary_email_server_id' => $primary?->id,
|
|
'provider' => EmailServer::PROVIDER_CONTABO,
|
|
'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
|
|
'cpu_cores' => $input['cpu_cores'] ?? null,
|
|
'ram_mb' => $input['ram_mb'] ?? null,
|
|
'disk_gb' => $input['disk_gb'] ?? null,
|
|
'max_domains' => $input['max_domains'] ?? 5000,
|
|
'max_mailboxes' => $input['max_mailboxes'] ?? 50000,
|
|
'db_host' => $isExtension ? $primary?->db_host : config('infrastructure.mail_stack.db_host'),
|
|
'db_port' => $primary?->db_port ?? 3306,
|
|
'db_database' => config('infrastructure.mail_stack.db_database', 'mail'),
|
|
'db_username' => $isExtension ? $primary?->db_username : config('infrastructure.mail_stack.db_username', 'mailuser'),
|
|
'db_password' => $isExtension ? $primary?->db_password : $mailDbPassword,
|
|
'ssh_host' => null,
|
|
'ssh_user' => 'root',
|
|
'ssh_port' => 22,
|
|
'ssh_private_key' => $keys['private_key'],
|
|
'status' => EmailServer::STATUS_PROVISIONING,
|
|
'is_default' => ! $isExtension && (bool) ($input['is_default'] ?? false),
|
|
]);
|
|
|
|
if ($isExtension && $primary) {
|
|
$this->emailPool->syncExtensionFromPrimary($server, $primary);
|
|
}
|
|
|
|
$purpose = $isExtension
|
|
? ContaboInfrastructureProvision::PURPOSE_EMAIL_EXTENSION
|
|
: ContaboInfrastructureProvision::PURPOSE_EMAIL_PRIMARY;
|
|
|
|
$productId = $isExtension
|
|
? (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_extension_product_id', 'V94'))
|
|
: (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_primary_product_id', 'V97'));
|
|
|
|
$provision = $this->createProvision(
|
|
purpose: $purpose,
|
|
name: (string) $input['name'],
|
|
productId: $productId,
|
|
region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
|
|
imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')),
|
|
payload: array_merge($input, [
|
|
'mail_host' => $mailHost,
|
|
'primary_ip' => $primary?->ip_address,
|
|
'primary_email_server_id' => $primary?->id,
|
|
]),
|
|
keys: $keys,
|
|
emailServerId: $server->id,
|
|
mailDbPassword: $mailDbPassword,
|
|
actor: $actor,
|
|
);
|
|
|
|
ProvisionContaboInfrastructureJob::dispatch($provision);
|
|
|
|
return $provision;
|
|
}
|
|
|
|
public function createContaboInstance(ContaboInfrastructureProvision $provision): void
|
|
{
|
|
$provision->update(['status' => ContaboInfrastructureProvision::STATUS_CREATING]);
|
|
$provision->addLog('Creating Contabo compute instance…');
|
|
|
|
$userData = $this->cloudInit->build($provision);
|
|
|
|
$result = $this->contabo->createInstance([
|
|
'product_id' => $provision->contabo_product_id,
|
|
'region' => $provision->region,
|
|
'image_id' => $provision->image_id,
|
|
'display_name' => 'ladill-'.$provision->purpose.'-'.$provision->id,
|
|
'user_data' => $userData,
|
|
'default_user' => 'root',
|
|
]);
|
|
|
|
$provision->update([
|
|
'contabo_instance_id' => $result['instance_id'],
|
|
'status' => ContaboInfrastructureProvision::STATUS_WAITING_IP,
|
|
'instance_created_at' => now(),
|
|
]);
|
|
|
|
if ($provision->hosting_node_id) {
|
|
HostingNode::whereKey($provision->hosting_node_id)->update([
|
|
'provider_instance_id' => $result['instance_id'],
|
|
'cpu_cores' => $result['cpu_cores'] ?? null,
|
|
'ram_mb' => $result['ram_mb'] ?? null,
|
|
'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null,
|
|
]);
|
|
}
|
|
|
|
if ($provision->email_server_id) {
|
|
EmailServer::whereKey($provision->email_server_id)->update([
|
|
'provider_instance_id' => $result['instance_id'],
|
|
'cpu_cores' => $result['cpu_cores'] ?? null,
|
|
'ram_mb' => $result['ram_mb'] ?? null,
|
|
'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null,
|
|
]);
|
|
}
|
|
|
|
$provision->addLog('Instance '.$result['instance_id'].' created; waiting for IP address.');
|
|
}
|
|
|
|
public function pollInstanceIp(ContaboInfrastructureProvision $provision): bool
|
|
{
|
|
if (! $provision->contabo_instance_id) {
|
|
return false;
|
|
}
|
|
|
|
$instance = $this->contabo->getInstance($provision->contabo_instance_id);
|
|
$ip = $instance['ip_address'] ?? null;
|
|
|
|
if (! is_string($ip) || $ip === '' || $ip === '0.0.0.0') {
|
|
return false;
|
|
}
|
|
|
|
$provision->update([
|
|
'status' => ContaboInfrastructureProvision::STATUS_BOOTSTRAPPING,
|
|
'ip_assigned_at' => now(),
|
|
]);
|
|
$provision->addLog("Instance IP assigned: {$ip}");
|
|
|
|
if ($provision->hosting_node_id) {
|
|
HostingNode::whereKey($provision->hosting_node_id)->update([
|
|
'ip_address' => $ip,
|
|
'ipv6_address' => $instance['ipv6_address'] ?? null,
|
|
]);
|
|
}
|
|
|
|
if ($provision->email_server_id) {
|
|
EmailServer::whereKey($provision->email_server_id)->update([
|
|
'ip_address' => $ip,
|
|
'ipv6_address' => $instance['ipv6_address'] ?? null,
|
|
'ssh_host' => $ip,
|
|
]);
|
|
// Note: db_host is intentionally NOT set to the server's own IP.
|
|
// The mail DB is centralized; primaries keep the central db_host.
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $callback
|
|
*/
|
|
public function completeFromCallback(ContaboInfrastructureProvision $provision, array $callback): void
|
|
{
|
|
if ($provision->status === ContaboInfrastructureProvision::STATUS_COMPLETED) {
|
|
return;
|
|
}
|
|
|
|
$provision->addLog('Bootstrap completion callback received.');
|
|
|
|
if ($provision->email_server_id) {
|
|
$server = EmailServer::find($provision->email_server_id);
|
|
if ($server?->isPrimary()) {
|
|
EmailServer::whereKey($server->id)->update([
|
|
// Mail DB is centralized — point at the central host, not the server IP.
|
|
'db_host' => config('infrastructure.mail_stack.db_host'),
|
|
'ssh_host' => $server->ip_address,
|
|
]);
|
|
$this->emailPool->syncExtensionCredentials($server->fresh());
|
|
}
|
|
|
|
if ($server?->isExtension() && $server->primary) {
|
|
$this->mailNfs->refreshPrimaryExports($server->primary);
|
|
}
|
|
}
|
|
|
|
$this->activateResources($provision);
|
|
|
|
$provision->update([
|
|
'status' => ContaboInfrastructureProvision::STATUS_COMPLETED,
|
|
'completed_at' => now(),
|
|
]);
|
|
$provision->addLog('Provisioning completed successfully.');
|
|
}
|
|
|
|
public function activateResources(ContaboInfrastructureProvision $provision): void
|
|
{
|
|
if ($provision->hosting_node_id) {
|
|
HostingNode::whereKey($provision->hosting_node_id)->update([
|
|
'status' => HostingNode::STATUS_ACTIVE,
|
|
]);
|
|
}
|
|
|
|
if ($provision->email_server_id) {
|
|
EmailServer::whereKey($provision->email_server_id)->update([
|
|
'status' => EmailServer::STATUS_ACTIVE,
|
|
]);
|
|
}
|
|
}
|
|
|
|
public function retry(ContaboInfrastructureProvision $provision): void
|
|
{
|
|
if ($provision->status !== ContaboInfrastructureProvision::STATUS_FAILED) {
|
|
return;
|
|
}
|
|
|
|
$provision->update([
|
|
'status' => ContaboInfrastructureProvision::STATUS_PENDING,
|
|
'error_message' => null,
|
|
'failed_at' => null,
|
|
]);
|
|
$provision->addLog('Retry requested.');
|
|
|
|
ProvisionContaboInfrastructureJob::dispatch($provision);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param array{public_key: string, private_key: string} $keys
|
|
*/
|
|
private function createProvision(
|
|
string $purpose,
|
|
string $name,
|
|
string $productId,
|
|
string $region,
|
|
?string $imageId,
|
|
array $payload,
|
|
array $keys,
|
|
?int $hostingNodeId = null,
|
|
?int $emailServerId = null,
|
|
?string $mailDbPassword = null,
|
|
?User $actor = null,
|
|
): ContaboInfrastructureProvision {
|
|
return ContaboInfrastructureProvision::create([
|
|
'purpose' => $purpose,
|
|
'status' => ContaboInfrastructureProvision::STATUS_PENDING,
|
|
'name' => $name,
|
|
'contabo_product_id' => $productId,
|
|
'region' => $region,
|
|
'image_id' => $imageId ?: null,
|
|
'completion_token' => Str::random(48),
|
|
'payload' => $payload,
|
|
'log' => [],
|
|
'ssh_public_key' => $keys['public_key'],
|
|
'ssh_private_key' => $keys['private_key'],
|
|
'generated_mail_db_password' => $mailDbPassword,
|
|
'hosting_node_id' => $hostingNodeId,
|
|
'email_server_id' => $emailServerId,
|
|
'created_by' => $actor?->id,
|
|
]);
|
|
}
|
|
|
|
private function assertEnabled(): void
|
|
{
|
|
if (! $this->isEnabled()) {
|
|
throw new \RuntimeException('Contabo infrastructure provisioning is not configured. Set CONTABO_* credentials in .env.');
|
|
}
|
|
}
|
|
}
|