Extract Ladill Servers as standalone app at servers.ladill.com.

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>
This commit is contained in:
isaacclad
2026-06-06 19:18:30 +00:00
co-authored by Cursor
commit b6c8ac343f
382 changed files with 67315 additions and 0 deletions
@@ -0,0 +1,176 @@
<?php
namespace App\Services\Infrastructure;
use App\Models\ContaboInfrastructureProvision;
use App\Models\EmailServer;
use App\Services\Hosting\Providers\ContaboProvider;
use Illuminate\Support\Facades\File;
class ContaboInfrastructureCloudInitBuilder
{
public function __construct(
private ContaboProvider $contaboProvider,
) {}
public function build(ContaboInfrastructureProvision $provision): string
{
return match ($provision->purpose) {
ContaboInfrastructureProvision::PURPOSE_HOSTING_NODE => $this->buildHostingNode($provision),
ContaboInfrastructureProvision::PURPOSE_EMAIL_PRIMARY => $this->buildMailPrimary($provision),
ContaboInfrastructureProvision::PURPOSE_EMAIL_EXTENSION => $this->buildMailExtension($provision),
default => throw new \InvalidArgumentException("Unknown provision purpose: {$provision->purpose}"),
};
}
private function buildHostingNode(ContaboInfrastructureProvision $provision): string
{
$helperScript = File::get(base_path('deploy/bin/ladill-hosting-admin'));
$sudoers = "www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-admin *\n";
$completionUrl = $provision->completionUrl();
return $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget', 'git', 'unzip', 'nginx', 'php-fpm', 'mysql-client', 'quota'],
'ssh_keys' => array_filter([$provision->ssh_public_key]),
'write_files' => [
[
'path' => '/opt/ladill/ladill-hosting-admin',
'permissions' => '0755',
'owner' => 'root:root',
'content' => $helperScript,
],
[
'path' => '/opt/ladill/ladill-hosting.sudoers',
'permissions' => '0440',
'owner' => 'root:root',
'content' => $sudoers,
],
],
'run_commands' => [
'install -Dm755 /opt/ladill/ladill-hosting-admin /usr/local/bin/ladill-hosting-admin',
'install -Dm440 /opt/ladill/ladill-hosting.sudoers /etc/sudoers.d/ladill-hosting',
'visudo -cf /etc/sudoers.d/ladill-hosting',
$this->completionCurlCommand($completionUrl, ['bootstrap' => 'hosting']),
],
]);
}
private function buildMailPrimary(ContaboInfrastructureProvision $provision): string
{
$payload = (array) ($provision->payload ?? []);
$mailHost = (string) ($payload['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com'));
$dbName = config('infrastructure.mail_stack.db_database', 'mail');
$dbUser = config('infrastructure.mail_stack.db_username', 'mailuser');
$dbPassword = (string) ($provision->generated_mail_db_password ?? '');
$vmailRoot = config('infrastructure.mail_stack.vmail_root', '/var/vmail');
$vmailUid = (string) config('infrastructure.mail_stack.vmail_uid', 5000);
$vmailGid = (string) config('infrastructure.mail_stack.vmail_gid', 5000);
$exportClients = (string) ($payload['export_client_ips'] ?? '');
$bootstrapScript = str_replace(
[
'__MAIL_HOST__', '__DB_PASSWORD__', '__DB_NAME__', '__DB_USER__', '__COMPLETION_URL__',
'__VMAIL_ROOT__', '__VMAIL_UID__', '__VMAIL_GID__', '__EXPORT_CLIENTS__',
],
[
$mailHost, $dbPassword, $dbName, $dbUser, $provision->completionUrl(),
$vmailRoot, $vmailUid, $vmailGid, $exportClients,
],
File::get(resource_path('infrastructure/scripts/mail-primary-bootstrap.sh'))
);
$writeFiles = [
$this->scriptFile('/opt/ladill/nfs-primary-setup.sh', 'infrastructure/scripts/nfs-primary-setup.sh'),
$this->scriptFile('/opt/ladill/vmail-usage-scan.sh', 'infrastructure/scripts/vmail-usage-scan.sh'),
[
'path' => '/opt/ladill/mail-primary-bootstrap.sh',
'permissions' => '0755',
'owner' => 'root:root',
'encoding' => 'b64',
'content' => base64_encode($bootstrapScript),
],
];
return $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget', 'jq', 'ca-certificates', 'sudo'],
'ssh_keys' => array_filter([$provision->ssh_public_key]),
'write_files' => $writeFiles,
'run_commands' => [
'bash /opt/ladill/mail-primary-bootstrap.sh',
],
]);
}
private function buildMailExtension(ContaboInfrastructureProvision $provision): string
{
$payload = (array) ($provision->payload ?? []);
$mailHost = (string) ($payload['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com'));
$primaryIp = (string) ($payload['primary_ip'] ?? '');
$vmailRoot = config('infrastructure.mail_stack.vmail_root', '/var/vmail');
$mountOpts = config('infrastructure.mail_stack.nfs_mount_options', 'nfsvers=4.1,rw,hard,timeo=600,retrans=2,_netdev');
if ($primaryIp === '' && ! empty($payload['primary_email_server_id'])) {
$primary = EmailServer::find($payload['primary_email_server_id']);
$primaryIp = $primary?->ip_address ?? '';
$vmailRoot = $primary?->pool_vmail_root ?: $vmailRoot;
}
$bootstrapScript = str_replace(
[
'__MAIL_HOST__', '__PRIMARY_IP__', '__COMPLETION_URL__',
'__VMAIL_ROOT__', '__MOUNT_OPTS__',
],
[
$mailHost, $primaryIp, $provision->completionUrl(),
$vmailRoot, $mountOpts,
],
File::get(resource_path('infrastructure/scripts/mail-extension-bootstrap.sh'))
);
$writeFiles = [
$this->scriptFile('/opt/ladill/nfs-extension-setup.sh', 'infrastructure/scripts/nfs-extension-setup.sh'),
$this->scriptFile('/opt/ladill/vmail-usage-scan.sh', 'infrastructure/scripts/vmail-usage-scan.sh'),
[
'path' => '/opt/ladill/mail-extension-bootstrap.sh',
'permissions' => '0755',
'owner' => 'root:root',
'encoding' => 'b64',
'content' => base64_encode($bootstrapScript),
],
];
return $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget', 'jq', 'ca-certificates', 'sudo'],
'ssh_keys' => array_filter([$provision->ssh_public_key]),
'write_files' => $writeFiles,
'run_commands' => [
'bash /opt/ladill/mail-extension-bootstrap.sh',
],
]);
}
/**
* @return array<string, mixed>
*/
private function scriptFile(string $remotePath, string $localRelativePath): array
{
return [
'path' => $remotePath,
'permissions' => '0755',
'owner' => 'root:root',
'encoding' => 'b64',
'content' => base64_encode(File::get(resource_path($localRelativePath))),
];
}
/**
* @param array<string, mixed> $extra
*/
private function completionCurlCommand(string $url, array $extra = []): string
{
$payload = json_encode(array_merge(['status' => 'ok'], $extra), JSON_UNESCAPED_SLASHES);
return "curl -fsS -X POST ".escapeshellarg($url)." -H 'Content-Type: application/json' -d ".escapeshellarg($payload)." || true";
}
}
@@ -0,0 +1,350 @@
<?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.');
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Services\Infrastructure;
class ContaboInfrastructureSshKeyService
{
/**
* @return array{public_key: string, private_key: string}
*/
public function generate(): array
{
$resource = openssl_pkey_new([
'private_key_bits' => 4096,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($resource === false) {
throw new \RuntimeException('Failed to generate SSH key pair.');
}
openssl_pkey_export($resource, $privateKey);
$details = openssl_pkey_get_details($resource);
$publicKey = $details['key'] ?? '';
if ($publicKey === '') {
throw new \RuntimeException('Failed to export SSH public key.');
}
return [
'public_key' => trim($publicKey)."\n",
'private_key' => $privateKey,
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Infrastructure;
use App\Models\EmailServer;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\Mailbox;
use App\Models\User;
use Illuminate\Support\Collection;
class InfrastructureUsageReportService
{
/**
* @return Collection<int, HostingAccount>
*/
public function sharedHostingAccountsForNode(HostingNode $node): Collection
{
return HostingAccount::query()
->where('hosting_node_id', $node->id)
->where('type', HostingAccount::TYPE_SHARED)
->with(['user:id,name,email', 'product:id,name'])
->orderByDesc('disk_used_bytes')
->get();
}
/**
* @return Collection<int, object{
* user_id: int,
* name: string|null,
* email: string|null,
* mailbox_count: int,
* allocated_storage_mb: int,
* used_storage_mb: int
* }>
*/
public function mailStorageByUserForServer(EmailServer $server): Collection
{
$memberIds = app(\App\Services\Mail\EmailServerPoolService::class)
->poolMembers($server->poolRoot())
->pluck('id');
$rows = Mailbox::query()
->selectRaw('mailboxes.user_id')
->selectRaw('COUNT(mailboxes.id) as mailbox_count')
->selectRaw('COALESCE(SUM(mailboxes.quota_mb), 0) as allocated_storage_mb')
->selectRaw('COALESCE(SUM(mailboxes.disk_used_bytes), 0) as used_storage_bytes')
->where(function ($query) use ($memberIds) {
$query->whereHas('emailDomain', fn ($q) => $q->whereIn('email_server_id', $memberIds))
->orWhereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds));
})
->groupBy('mailboxes.user_id')
->orderByDesc('used_storage_bytes')
->get();
$users = User::query()
->whereIn('id', $rows->pluck('user_id')->filter())
->get()
->keyBy('id');
return $rows->map(function ($row) use ($users) {
$user = $users->get($row->user_id);
return (object) [
'user_id' => (int) $row->user_id,
'name' => $user?->name,
'email' => $user?->email,
'mailbox_count' => (int) $row->mailbox_count,
'allocated_storage_mb' => (int) $row->allocated_storage_mb,
'used_storage_mb' => (int) ceil(((int) $row->used_storage_bytes) / 1048576),
];
});
}
}