Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s

Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 16:24:20 +00:00
co-authored by Cursor
commit e251a4cf60
367 changed files with 66268 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia for Ladill Email an AI assistant scoped to buying & managing mailboxes
* and email domains. Self-contained: builds an email-specific system prompt +
* the user's live context and calls the configured LLM (OpenAI or Anthropic).
*/
class AfiaService
{
public function enabled(): bool
{
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
}
/**
* @param array<int,array{role:string,text:string}> $history
* @param array<string,mixed> $context
*/
public function chat(string $message, array $history, array $context): string
{
if (! $this->enabled()) {
throw new RuntimeException('Afia is not configured.');
}
$provider = (string) config('afia.provider', 'openai');
$model = (string) config('afia.model', 'gpt-4o-mini');
$apiKey = (string) config('afia.api_key');
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
foreach (array_slice($history, -8) as $turn) {
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
$text = trim((string) ($turn['text'] ?? ''));
if ($text !== '') {
$messages[] = ['role' => $role, 'content' => $text];
}
}
$messages[] = ['role' => 'user', 'content' => $message];
return $provider === 'anthropic'
? $this->viaAnthropic($model, $apiKey, $messages)
: $this->viaOpenAi($model, $apiKey, $messages);
}
private function viaOpenAi(string $model, string $apiKey, array $messages): string
{
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $model,
'temperature' => 0.3,
'max_tokens' => 600,
'messages' => $messages,
]);
if ($res->failed()) {
throw new RuntimeException('OpenAI request failed: '.$res->status());
}
return trim((string) $res->json('choices.0.message.content', ''));
}
private function viaAnthropic(string $model, string $apiKey, array $messages): string
{
$system = $messages[0]['content'];
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
$res = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
'model' => $model,
'max_tokens' => 600,
'system' => $system,
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
]);
if ($res->failed()) {
throw new RuntimeException('Anthropic request failed: '.$res->status());
}
return trim((string) $res->json('content.0.text', ''));
}
/** @param array<string,mixed> $context */
private function systemPrompt(array $context): string
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return <<<PROMPT
You are Afia, the assistant inside Ladill Email Ladill's mailbox product (email.ladill.com).
Help the user set up email domains, create and manage mailboxes, and understand billing. Be concise,
friendly, and actionable: give short step-by-step answers and point to the relevant section by name.
What Ladill Email does and where things live:
- Domains: add a domain you want email on, then verify it by adding the shown DNS records (MX, SPF,
DKIM, DMARC). Mailboxes can only be created on a verified domain.
- Mailboxes: create a mailbox (address + display name + password + quota) on a verified domain. The
first mailbox is free; extra mailboxes are billed monthly from your Ladill wallet. Manage a mailbox to
reset its password or see IMAP/SMTP connection settings.
- Webmail: read & send from a mailbox at mail.ladill.com (Open Ladill Mail).
- Account: Billing (wallet balance, add funds, monthly mailbox subscriptions), Team (invite teammates to
co-manage), Settings (default quota, notifications), Developers (API tokens for the Email API).
Connection settings: IMAP host mail.<domain> port 993 (SSL); SMTP host mail.<domain> port 587
(STARTTLS); username is the full email address.
Facts: Mail is delivered by Ladill's mail servers. Prices show in GHS. Login/identity is via the Ladill
account (SSO). DNS changes can take time to propagate, so verification may not be instant.
Rules: Only answer questions about Ladill Email domains for email, DNS verification, mailboxes, webmail,
IMAP/SMTP setup, team, and billing for mailboxes. If asked about unrelated topics, briefly redirect.
Never invent passwords, DNS values, or prices tell the user where to find them. If something needs a
verified domain or a funded wallet, say so.
Current user context:
{$ctx}
PROMPT;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; Bird only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
*/
class BillingClient
{
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
private function get(string $path, array $query): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
$res->throw();
return (array) $res->json();
}
public function balanceMinor(string $publicId): int
{
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
}
public function canAfford(string $publicId, int $amountMinor): bool
{
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
}
public function serviceLedger(string $publicId, string $service): array
{
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
*/
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
'source' => $source,
'reference' => $reference,
'service_id' => $serviceId,
'description' => $description,
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
return true;
}
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => $service,
'source' => $source,
'reference' => $reference,
'service_id' => $serviceId,
'description' => $description,
], static fn ($v) => $v !== null));
$res->throw();
return (array) $res->json();
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Billing;
use App\Models\PlatformSetting;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class PaystackService
{
public function publicKey(): string
{
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
}
public function initializeTransaction(array $payload): array
{
return $this->initializeTransactionWithCredentials($payload);
}
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
{
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
->post('/transaction/initialize', $payload);
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
}
public function verifyTransaction(string $reference): array
{
return $this->verifyTransactionWithCredentials($reference);
}
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
{
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
->get('/transaction/verify/' . urlencode($reference));
return $this->extractData($response, 'Failed to verify Paystack transaction.');
}
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
{
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
if ($secret === '') {
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
}
if ($secret === '' || !$signature) {
return false;
}
$computed = hash_hmac('sha512', $rawBody, $secret);
return hash_equals($computed, $signature);
}
protected function request(?string $secretKey = null, ?string $baseUrl = null)
{
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
$secret = $this->normalizeSecretKey(
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
);
return Http::baseUrl($resolvedBaseUrl)
->withToken($secret)
->acceptJson()
->asJson();
}
protected function settingValue(string $key, mixed $fallback = null): mixed
{
try {
if (!Schema::hasTable('platform_settings')) {
return $fallback;
}
$setting = PlatformSetting::query()
->where('key', $key)
->where('is_active', true)
->first();
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
} catch (\Throwable) {
return $fallback;
}
}
protected function extractData(Response $response, string $message): array
{
if ($response->failed()) {
throw new RuntimeException($message);
}
$json = $response->json();
if (!is_array($json) || !($json['status'] ?? false)) {
throw new RuntimeException((string) ($json['message'] ?? $message));
}
$data = $json['data'] ?? null;
return is_array($data) ? $data : [];
}
/**
* List supported banks / mobile money providers.
* @param string $country e.g. 'ghana', 'nigeria'
* @param string $type 'nuban', 'mobile_money', or '' for all
*/
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
{
$query = ['perPage' => 200];
if ($currency !== null && $currency !== '') {
$query['currency'] = strtoupper($currency);
} else {
$query['country'] = $country;
}
if ($type !== '') {
$query['type'] = $type;
}
$response = $this->request()->get('/bank', $query);
$json = $response->json();
return is_array($json['data'] ?? null) ? $json['data'] : [];
}
/**
* Create a transfer recipient (bank account or mobile money).
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
*/
public function createTransferRecipient(array $payload): array
{
$response = $this->request()->post('/transferrecipient', $payload);
return $this->extractData($response, 'Failed to create transfer recipient.');
}
/**
* Initiate a transfer to a recipient.
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
*/
public function initiateTransfer(array $payload): array
{
$response = $this->request()->post('/transfer', $payload);
return $this->extractData($response, 'Failed to initiate transfer.');
}
/**
* Verify a transfer by reference.
*/
public function verifyTransfer(string $reference): array
{
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
return $this->extractData($response, 'Failed to verify transfer.');
}
protected function normalizeSecretKey(string $secret): string
{
$normalized = trim($secret);
if (str_starts_with(strtolower($normalized), 'bearer ')) {
$normalized = trim(substr($normalized, 7));
}
return $normalized;
}
}
@@ -0,0 +1,166 @@
<?php
namespace App\Services\Currency;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CurrencyConverterService
{
private const CACHE_KEY = 'currency:usd_to_ghs';
private const CACHE_KEY_EUR = 'currency:eur_to_ghs';
private const CACHE_TTL_MINUTES = 60;
private const FALLBACK_RATE = 15.50;
private const FALLBACK_EUR_RATE = 16.90;
public function getUsdToGhsRate(): float
{
return Cache::remember(self::CACHE_KEY, now()->addMinutes(self::CACHE_TTL_MINUTES), function () {
return $this->fetchRateFromBase('USD') ?? self::FALLBACK_RATE;
});
}
public function getEurToGhsRate(): float
{
return Cache::remember(self::CACHE_KEY_EUR, now()->addMinutes(self::CACHE_TTL_MINUTES), function () {
return $this->fetchRateFromBase('EUR') ?? self::FALLBACK_EUR_RATE;
});
}
/**
* Convert USD amount to GHS.
*/
public function convertUsdToGhs(float $usdAmount): float
{
return $usdAmount * $this->getUsdToGhsRate();
}
/**
* Convert USD to GHS with markup percentage.
*/
public function convertWithMarkup(float $usdAmount, float $markupPercent = 35): float
{
$ghsAmount = $this->convertUsdToGhs($usdAmount);
return $ghsAmount * (1 + ($markupPercent / 100));
}
/**
* Convert USD cents to GHS pesewas with markup.
*/
public function convertCentsTopesewaswithMarkup(int $usdCents, float $markupPercent = 35): int
{
$usdAmount = $usdCents / 100;
$ghsAmount = $this->convertWithMarkup($usdAmount, $markupPercent);
return (int) round($ghsAmount * 100);
}
private function fetchRateFromBase(string $base): ?float
{
$rate = $this->fetchFromExchangeRateApi($base)
?? $this->fetchFromFreeCurrencyApi($base)
?? $this->fetchFromOpenExchangeRates($base);
if ($rate !== null) {
Log::info('Currency rate fetched', ['base' => $base, 'ghs' => $rate]);
}
return $rate;
}
private function fetchFromExchangeRateApi(string $base): ?float
{
try {
$response = Http::timeout(5)->get("https://api.exchangerate-api.com/v4/latest/{$base}");
if ($response->successful()) {
$data = $response->json();
return (float) ($data['rates']['GHS'] ?? null) ?: null;
}
} catch (\Throwable $e) {
Log::warning('ExchangeRateApi fetch failed', ['base' => $base, 'error' => $e->getMessage()]);
}
return null;
}
private function fetchFromFreeCurrencyApi(string $base): ?float
{
$apiKey = config('services.freecurrencyapi.key');
if (empty($apiKey)) {
return null;
}
try {
$response = Http::timeout(5)->get('https://api.freecurrencyapi.com/v1/latest', [
'apikey' => $apiKey,
'base_currency' => $base,
'currencies' => 'GHS',
]);
if ($response->successful()) {
$data = $response->json();
return (float) ($data['data']['GHS'] ?? null) ?: null;
}
} catch (\Throwable $e) {
Log::warning('FreeCurrencyApi fetch failed', ['base' => $base, 'error' => $e->getMessage()]);
}
return null;
}
private function fetchFromOpenExchangeRates(string $base): ?float
{
$appId = config('services.openexchangerates.app_id');
if (empty($appId)) {
return null;
}
// Free tier only supports USD as base
if ($base !== 'USD') {
return null;
}
try {
$response = Http::timeout(5)->get('https://openexchangerates.org/api/latest.json', [
'app_id' => $appId,
'symbols' => 'GHS',
]);
if ($response->successful()) {
$data = $response->json();
return (float) ($data['rates']['GHS'] ?? null) ?: null;
}
} catch (\Throwable $e) {
Log::warning('OpenExchangeRates fetch failed', ['base' => $base, 'error' => $e->getMessage()]);
}
return null;
}
/**
* Force refresh the cached rate.
*/
public function refreshRate(): float
{
Cache::forget(self::CACHE_KEY);
Cache::forget(self::CACHE_KEY_EUR);
return $this->getUsdToGhsRate();
}
/**
* Get the current cached rate info.
*/
public function getRateInfo(): array
{
return [
'rate' => $this->getUsdToGhsRate(),
'from' => 'USD',
'to' => 'GHS',
'cached' => Cache::has(self::CACHE_KEY),
'fallback' => self::FALLBACK_RATE,
];
}
}
+304
View File
@@ -0,0 +1,304 @@
<?php
namespace App\Services\Dns;
use App\Models\Domain;
use App\Services\Domain\DomainDnsBlueprintService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class PowerDnsClient
{
public function __construct(
private readonly DomainDnsBlueprintService $blueprints,
) {
}
/**
* Ensure the domain is provisioned into PowerDNS.
*
* $selectorOverride and $publicKeyOverride let callers supply the DKIM key directly
* (e.g. for email-only domains that use EmailDomainDkimKey instead of DomainDkimKey).
* $publicKeyOverride must be the raw base64 key NOT the full "v=DKIM1; k=rsa; p=…" string.
*
* @throws \Illuminate\Http\Client\RequestException|\RuntimeException
*/
public function ensureZone(Domain $domain, ?string $selectorOverride = null, ?string $publicKeyOverride = null): array
{
if ($selectorOverride !== null && $publicKeyOverride !== null) {
$selector = $selectorOverride;
$publicKey = $publicKeyOverride;
} else {
$selector = $domain->dkimKeys()
->where('status', 'active')
->latest('id')
->value('selector');
$publicKey = $domain->dkimKeys()
->where('status', 'active')
->latest('id')
->value('public_key_txt');
if (! $selector || ! $publicKey) {
throw new \RuntimeException('Missing DKIM key for '.$domain->host);
}
}
$zoneName = $this->zoneName($domain);
$rrsets = $this->buildRRSets($domain, $selector, $publicKey);
$url = rtrim(config('pdns.api_url'), '/')."/servers/{$this->server()}/zones";
\Illuminate\Support\Facades\Log::info('PDNS API: POST '.$url, [
'zone' => $zoneName,
'rrset_count' => count($rrsets),
]);
$response = $this->httpClient()->post("servers/{$this->server()}/zones", [
'name' => $zoneName,
'kind' => 'Master',
'rrsets' => $rrsets,
]);
\Illuminate\Support\Facades\Log::info('PDNS API: Response', [
'status' => $response->status(),
'body' => mb_substr($response->body(), 0, 500),
]);
if ($response->status() === 409) {
// Zone already exists — patch only the managed records so any
// custom records (e.g. mail A, additional subdomains) are preserved.
return $this->patchZone($zoneName, $rrsets);
}
if ($response->failed()) {
$response->throw();
}
return $response->json() ?? ['status' => 'created'];
}
public function ensureSlaveZone(Domain $domain, array $masters): array
{
if (empty($masters)) {
throw new \RuntimeException('No master IPs configured for slave zone');
}
$slaveApiUrl = config('pdns.slave_api_url');
if (! $slaveApiUrl) {
throw new \RuntimeException('PDNS_SLAVE_API_URL is not configured');
}
$zoneName = $this->zoneName($domain);
$slaveServer = config('pdns.slave_server', 'localhost');
$response = $this->slaveHttpClient()->post("servers/{$slaveServer}/zones", [
'name' => $zoneName,
'kind' => 'Slave',
'masters' => array_values($masters),
]);
if ($response->status() === 409) {
return ['status' => 'already_exists'];
}
if ($response->failed()) {
$response->throw();
}
return $response->json() ?? ['status' => 'created'];
}
/**
* Upsert specific records into an already-existing zone on the master,
* without disturbing any other records in the zone (changetype=REPLACE
* is scoped to each name+type rrset).
*
* @param string $zoneName e.g. "ladill.com" (trailing dot optional)
* @param array<int, array{name: string, type?: string, ttl?: int, contents: array<int, string>}> $records
*
* @throws \Illuminate\Http\Client\RequestException
*/
public function upsertRecords(string $zoneName, array $records): array
{
if ($records === []) {
return ['status' => 'noop'];
}
$zoneName = rtrim($zoneName, '.').'.';
$rrsets = array_map(function (array $record): array {
return [
'name' => rtrim($record['name'], '.').'.',
'type' => strtoupper($record['type'] ?? 'A'),
'ttl' => (int) ($record['ttl'] ?? 3600),
'changetype' => 'REPLACE',
'records' => array_map(
static fn (string $content): array => ['content' => $content, 'disabled' => false],
array_values($record['contents']),
),
];
}, $records);
$response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [
'rrsets' => $rrsets,
]);
if ($response->failed()) {
$response->throw();
}
return ['status' => 'updated', 'count' => count($rrsets)];
}
public function deleteZone(Domain $domain): void
{
$zoneName = $this->zoneName($domain);
$response = $this->httpClient()->delete("servers/{$this->server()}/zones/{$zoneName}");
// 204 = deleted, 404 = already gone — both are fine.
if ($response->failed() && $response->status() !== 404) {
$response->throw();
}
}
private function patchZone(string $zoneName, array $rrsets): array
{
$patchSets = array_map(function (array $rrset) {
$rrset['changetype'] = 'REPLACE';
return $rrset;
}, $rrsets);
$response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [
'rrsets' => $patchSets,
]);
if ($response->failed()) {
$response->throw();
}
return $response->json() ?? ['status' => 'updated'];
}
private function buildRRSets(Domain $domain, string $selector, string $publicKey): array
{
$records = $this->blueprints->managedRecords($domain, $selector, $publicKey);
$owner = rtrim($domain->host, '.').'.';
$nameservers = $this->blueprints->expectedNameservers();
$groups = [];
$soaContent = sprintf(
'ns1.ladill.com. hostmaster.ladill.com. %d 10800 3600 604800 3600',
now()->format('U')
);
$groups[$owner.'|SOA'] = [
'name' => $owner,
'type' => 'SOA',
'ttl' => 3600,
'records' => [
['content' => $soaContent, 'disabled' => false],
],
];
$nsRecords = [];
foreach ($nameservers as $ns) {
$nsRecords[] = ['content' => rtrim($ns, '.').'.', 'disabled' => false];
}
if ($nsRecords !== []) {
$groups[$owner.'|NS'] = [
'name' => $owner,
'type' => 'NS',
'ttl' => 3600,
'records' => $nsRecords,
];
}
foreach ($records as $record) {
$name = $this->fqdn($domain->host, (string) ($record['name'] ?? '@'));
$type = strtoupper((string) ($record['type'] ?? 'TXT'));
$key = $name.'|'.$type;
$groups[$key]['name'] = $name;
$groups[$key]['type'] = $type;
$groups[$key]['ttl'] = (int) ($record['ttl'] ?? 3600);
$value = (string) ($record['value'] ?? '');
if (trim($value) === '@') {
$value = $domain->host;
}
$content = $this->formatContent($value, $type, (int) ($record['priority'] ?? 0));
$groups[$key]['records'][] = [
'content' => $content,
'disabled' => false,
];
}
return array_values($groups);
}
private function formatContent(string $value, string $type, int $priority = 0): string
{
$value = trim($value);
if ($value === '') {
return $value;
}
return match ($type) {
'MX' => $priority.' '.rtrim($value, '.').'.',
'CNAME', 'NS', 'PTR' => rtrim($value, '.').'.',
'TXT' => '"'.trim($value, '"').'"',
default => $value,
};
}
private function fqdn(string $host, string $name): string
{
$name = trim($name);
$zone = rtrim($host, '.').'.';
if ($name === '' || $name === '@') {
return $zone;
}
return trim($name, '.').'.'.$zone;
}
private function zoneName(Domain $domain): string
{
return rtrim($domain->host, '.').'.';
}
private function httpClient()
{
$base = rtrim(config('pdns.api_url'), '/');
// Ensure the /api/v1 prefix is present.
if (! str_contains($base, '/api/v1')) {
$base .= '/api/v1';
}
return Http::withHeaders([
'X-API-Key' => config('pdns.api_key'),
])->baseUrl($base)->timeout(config('pdns.timeout'));
}
private function slaveHttpClient()
{
$base = rtrim(config('pdns.slave_api_url'), '/');
if (! str_contains($base, '/api/v1')) {
$base .= '/api/v1';
}
return Http::withHeaders([
'X-API-Key' => config('pdns.slave_api_key'),
])->baseUrl($base)->timeout(config('pdns.timeout'));
}
private function server(): string
{
return config('pdns.server', 'localhost');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Services\Domain;
use Illuminate\Support\Facades\Http;
/** Optional stub — fetch user domains from the platform Domains API. */
class DomainClient
{
private function base(): string
{
return rtrim((string) config('domain.api_url', env('DOMAIN_API_URL', '')), '/');
}
private function token(): string
{
return (string) config('domain.api_key', env('DOMAIN_API_KEY_HOSTING', ''));
}
/** @return list<array<string, mixed>> */
public function listForUser(string $publicId): array
{
if ($this->base() === '' || $this->token() === '') {
return [];
}
$res = Http::withToken($this->token())->acceptJson()->timeout(10)
->get($this->base().'/domains', ['user' => $publicId]);
if ($res->failed()) {
return [];
}
return (array) ($res->json('data') ?? $res->json() ?? []);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Services\Domain;
use App\Models\Domain;
use App\Models\DomainDkimKey;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class DomainDkimService
{
public function ensureActive(Domain $domain): DomainDkimKey
{
$existing = DomainDkimKey::query()
->where('domain_id', $domain->id)
->where('status', 'active')
->latest('id')
->first();
if ($existing) {
return $existing;
}
$selectorPrefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill');
$selector = Str::lower(trim($selectorPrefix)) ?: 'ladill';
$selector .= '1';
$publicKey = '';
$privateKey = null;
if (function_exists('openssl_pkey_new')) {
$resource = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($resource !== false) {
openssl_pkey_export($resource, $privateOut);
$details = openssl_pkey_get_details($resource);
$publicPem = (string) ($details['key'] ?? '');
$publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicPem) ?: '';
$privateKey = $privateOut ?: null;
}
}
if ($publicKey === '') {
$publicKey = Str::upper(Str::random(360));
}
$privatePath = null;
if (is_string($privateKey) && trim($privateKey) !== '') {
$safeHost = Str::slug($domain->host, '_');
$directory = storage_path('app/mail/dkim');
File::ensureDirectoryExists($directory);
$privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key';
File::put($privatePath, $privateKey);
}
$key = DomainDkimKey::query()->create([
'domain_id' => $domain->id,
'selector' => $selector,
'public_key_txt' => $publicKey,
'private_key_path' => $privatePath,
'status' => 'active',
'generated_at' => now(),
]);
Log::info('Generated DKIM key for domain', ['domain_id' => $domain->id, 'selector' => $selector]);
return $key;
}
public function activeKey(Domain $domain): ?DomainDkimKey
{
return DomainDkimKey::query()
->where('domain_id', $domain->id)
->where('status', 'active')
->latest('id')
->first();
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Services\Domain;
use App\Models\Domain;
class DomainDnsBlueprintService
{
public function expectedNameservers(): array
{
$nameservers = (array) config('mailinfra.nameservers', ['ns1.ladill.com', 'ns2.ladill.com']);
return collect($nameservers)
->map(fn ($value) => strtolower(trim((string) $value, ". \t\n\r\0\x0B")))
->filter()
->unique()
->values()
->all();
}
public function mailSetupInstructions(): array
{
return [
'imap' => [
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
'port' => (int) config('mailinfra.imap_port', 993),
'security' => 'SSL/TLS',
'username' => 'full_email',
],
'smtp' => [
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
'port' => (int) config('mailinfra.smtp_port', 587),
'security' => 'STARTTLS',
'authentication' => true,
'username' => 'full_email',
],
'smtp_ssl' => [
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
'port' => (int) config('mailinfra.smtp_ssl_port', 465),
'security' => 'SSL/TLS',
],
];
}
public function managedRecords(Domain $domain, string $selector, string $publicKey): array
{
$mailHost = strtolower(trim((string) config('mailinfra.mail_host', 'mail.ladill.com')));
$autodiscoverHost = strtolower(trim((string) config('mailinfra.autodiscover_host', 'autodiscover.ladill.com')));
$autoconfigHost = strtolower(trim((string) config('mailinfra.autoconfig_host', 'autoconfig.ladill.com')));
$ttl = (int) config('mailinfra.default_ttl', 3600);
$websiteIp = trim((string) config('mailinfra.website_a_record', ''));
$wwwTarget = trim((string) config('mailinfra.website_www_cname', '@'));
$rua = trim((string) config('mailinfra.dmarc_rua', 'mailto:dmarc@ladill.com'));
$records = [];
// Always include A record for apex domain (use configured IP or placeholder)
$records[] = [
'name' => '@',
'type' => 'A',
'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Website apex record',
];
// Always include www record (CNAME to @ or A record to same IP)
if ($wwwTarget !== '' && $wwwTarget !== '@') {
$records[] = [
'name' => 'www',
'type' => 'CNAME',
'value' => $wwwTarget,
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Website www record',
];
} else {
// Default: www points to same IP as apex
$records[] = [
'name' => 'www',
'type' => 'A',
'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Website www record',
];
}
$records[] = [
'name' => '@',
'type' => 'MX',
'value' => $mailHost.'.',
'ttl' => $ttl,
'priority' => 10,
'source' => 'managed',
'status' => 'active',
'notes' => 'Primary mail exchanger',
];
$records[] = [
'name' => '@',
'type' => 'TXT',
'value' => (string) config('mailinfra.spf_customer_txt'),
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'SPF policy',
];
$records[] = [
'name' => $selector.'._domainkey',
'type' => 'TXT',
'value' => sprintf('v=DKIM1; k=rsa; p=%s', $publicKey),
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'DKIM public key',
];
$records[] = [
'name' => '_dmarc',
'type' => 'TXT',
'value' => sprintf('v=DMARC1; p=none; rua=%s; adkim=s; aspf=s', $rua),
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'DMARC policy',
];
$records[] = [
'name' => 'autodiscover',
'type' => 'CNAME',
'value' => $autodiscoverHost.'.',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Autodiscover helper',
];
$records[] = [
'name' => 'autoconfig',
'type' => 'CNAME',
'value' => $autoconfigHost.'.',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Autoconfig helper',
];
return $records;
}
public function manualDnsPack(Domain $domain, string $selector, string $publicKey): array
{
return collect($this->managedRecords($domain, $selector, $publicKey))
->map(function (array $record) {
$record['source'] = 'required_manual';
$record['status'] = 'pending';
return $record;
})
->values()
->all();
}
}
@@ -0,0 +1,285 @@
<?php
namespace App\Services\Domain;
use App\Models\DomainPricing;
use App\Services\Currency\CurrencyConverterService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class DomainPricingService
{
private const TLD_PRICING_CACHE_KEY = 'dynadot:tld_pricing';
private const LIVE_TLD_PRICING_CACHE_KEY = 'dynadot:tld_pricing:live';
private const TLD_PRICING_CACHE_TTL_HOURS = 6;
public function __construct(
private DynadotService $dynadot,
private CurrencyConverterService $currency,
) {}
/**
* Get the markup percentage from config.
*/
public function getMarkupPercent(): float
{
return (float) config('mailinfra.dynadot_price_markup_percent', 35);
}
/**
* Get pricing for a specific TLD in GHS with markup.
*
* @return array{register: int, renew: int, transfer: int, currency: string, usd_rate: float}|null
*/
public function getPricingForTld(string $tld): ?array
{
$tld = ltrim(strtolower(trim($tld)), '.');
$allPricing = $this->getAllTldPricing();
if (! isset($allPricing[$tld])) {
return null;
}
return $allPricing[$tld];
}
/**
* Get pricing for multiple TLDs.
*
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
*/
public function getPricingForTlds(array $tlds): array
{
$allPricing = $this->getAllTldPricing();
$result = [];
foreach ($tlds as $tld) {
$tld = ltrim(strtolower(trim($tld)), '.');
if (isset($allPricing[$tld])) {
$result[$tld] = $allPricing[$tld];
}
}
return $result;
}
/**
* Get only TLDs that currently have live Dynadot pricing.
*
* @return list<string>
*/
public function getLivePricedTlds(): array
{
return array_keys($this->getLiveDynadotPricing());
}
/**
* Get all TLD pricing with conversion and markup applied.
* Price overrides from DomainPricing model take precedence.
*
* @return array<string, array{register: int, renew: int, transfer: int, currency: string, usd_rate: float, is_override: bool}>
*/
public function getAllTldPricing(): array
{
$cacheKey = self::TLD_PRICING_CACHE_KEY . ':ghs';
return Cache::remember($cacheKey, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () {
$usdPricing = $this->fetchDynadotPricing();
$overrides = DomainPricing::getOverrides();
if (empty($usdPricing) && empty($overrides)) {
return [];
}
$markup = $this->getMarkupPercent();
$usdRate = $this->currency->getUsdToGhsRate();
$converted = [];
// First, add all Dynadot prices with conversion
foreach ($usdPricing as $tld => $prices) {
$converted[$tld] = [
'register' => $this->convertPrice($prices['register'] ?? 0, $markup),
'renew' => $this->convertPrice($prices['renew'] ?? 0, $markup),
'transfer' => $this->convertPrice($prices['transfer'] ?? 0, $markup),
'currency' => 'GHS',
'usd_rate' => $usdRate,
'usd_register' => $prices['register'] ?? 0,
'usd_renew' => $prices['renew'] ?? 0,
'usd_transfer' => $prices['transfer'] ?? 0,
'is_override' => false,
];
}
// Apply overrides (manual GHS prices take precedence)
foreach ($overrides as $tld => $override) {
$converted[$tld] = [
'register' => $override['register'],
'renew' => $override['renew'],
'transfer' => $override['transfer'],
'currency' => 'GHS',
'usd_rate' => $usdRate,
'usd_register' => null,
'usd_renew' => null,
'usd_transfer' => null,
'is_override' => true,
];
}
return $converted;
});
}
/**
* Convert USD cents to GHS pesewas with markup.
*/
private function convertPrice(int $usdCents, float $markupPercent): int
{
return $this->currency->convertCentsTopesewaswithMarkup($usdCents, $markupPercent);
}
/**
* Fetch TLD pricing from Dynadot API.
*
* @return array<string, array{register: int, renew: int, transfer: int}>
*/
private function fetchDynadotPricing(): array
{
$livePricing = $this->getLiveDynadotPricing();
if ($livePricing !== []) {
return $livePricing;
}
return $this->getFallbackPricing();
}
/**
* Fallback pricing in USD cents for common TLDs.
*/
private function getFallbackPricing(): array
{
return [
'com' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099],
'net' => ['register' => 1199, 'renew' => 1199, 'transfer' => 1199],
'org' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099],
'io' => ['register' => 3999, 'renew' => 3999, 'transfer' => 3999],
'co' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
'info' => ['register' => 1899, 'renew' => 1899, 'transfer' => 1899],
'biz' => ['register' => 1699, 'renew' => 1699, 'transfer' => 1699],
'xyz' => ['register' => 1299, 'renew' => 1299, 'transfer' => 1299],
'online' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
'store' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999],
'tech' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999],
'site' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
];
}
/**
* Fetch raw live TLD pricing from Dynadot without fallback expansion.
*
* @return array<string, array{register: int, renew: int, transfer: int}>
*/
private function getLiveDynadotPricing(): array
{
return Cache::remember(self::LIVE_TLD_PRICING_CACHE_KEY, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () {
if (! $this->dynadot->isConfigured()) {
Log::warning('DomainPricingService: Dynadot not configured');
return [];
}
try {
$result = $this->dynadot->getTldPricing();
if (! ($result['success'] ?? false) || empty($result['pricing'])) {
Log::warning('DomainPricingService: Failed to fetch Dynadot pricing', $result);
return [];
}
return $result['pricing'];
} catch (\Throwable $e) {
Log::error('DomainPricingService: Exception fetching pricing', ['error' => $e->getMessage()]);
return [];
}
});
}
/**
* Format price for display.
*/
public function formatPrice(int $pesewas): string
{
return 'GH₵ ' . number_format($pesewas / 100, 2);
}
public function convertRegistrarAmountToGhsMinor(int $amountMinor, string $currency): ?int
{
$currency = strtoupper(trim($currency));
if ($currency === 'GHS') {
return $amountMinor;
}
if ($currency === 'USD') {
return $this->convertPrice($amountMinor, $this->getMarkupPercent());
}
return null;
}
/**
* Get price info for a domain (for checkout/cart).
*/
public function getDomainPrice(string $domain, string $action = 'register'): ?array
{
$parts = explode('.', strtolower($domain), 2);
if (count($parts) < 2) {
return null;
}
$tld = $parts[1];
$pricing = $this->getPricingForTld($tld);
if (! $pricing) {
return null;
}
$priceKey = match ($action) {
'renew' => 'renew',
'transfer' => 'transfer',
default => 'register',
};
return [
'domain' => $domain,
'tld' => $tld,
'action' => $action,
'amount_minor' => $pricing[$priceKey],
'currency' => 'GHS',
'price_label' => $this->formatPrice($pricing[$priceKey]),
];
}
/**
* Clear cached pricing.
*/
public function clearCache(): void
{
Cache::forget(self::TLD_PRICING_CACHE_KEY . ':ghs');
Cache::forget(self::LIVE_TLD_PRICING_CACHE_KEY);
}
/**
* Get current exchange rate info.
*/
public function getExchangeRateInfo(): array
{
return [
...$this->currency->getRateInfo(),
'markup_percent' => $this->getMarkupPercent(),
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
<?php
namespace App\Services\Domain;
use App\Models\Domain;
use App\Models\DomainOrder;
use App\Models\RcServiceOrder;
class DomainRegistryService
{
/**
* Create or update the unified domain registry entry for a registrar purchase.
*/
public function recordPurchasedDomain(
int $userId,
string $host,
string $registrar,
?string $registrarOrderId = null,
bool $verified = true,
array $meta = [],
): Domain {
$host = strtolower(trim($host));
$existing = Domain::query()->where('host', $host)->first();
$verificationMeta = array_merge(
(array) ($existing?->verification_meta ?? []),
$meta,
array_filter([
'registrar' => $registrar,
'registrar_order_id' => $registrarOrderId,
'registered_at' => $verified ? now()->toIso8601String() : null,
]),
);
$attributes = [
'user_id' => $existing?->user_id ?: $userId,
'host' => $host,
'type' => $existing?->type ?: 'custom',
'source' => $this->resolveSource($existing),
'onboarding_mode' => $existing?->onboarding_mode ?: Domain::MODE_RESELLER_AUTO,
'status' => $verified ? 'verified' : ($existing?->status ?: 'pending'),
'onboarding_state' => $verified
? Domain::STATE_ACTIVE
: ($existing?->onboarding_state ?: Domain::STATE_PENDING_NS),
'registrar' => $registrar ?: $existing?->registrar,
'registrar_order_id' => $registrarOrderId ?: $existing?->registrar_order_id,
'verified_at' => $verified ? ($existing?->verified_at ?: now()) : $existing?->verified_at,
'verification_meta' => $verificationMeta,
];
if ($existing) {
$existing->update($attributes);
return $existing->fresh();
}
return Domain::create($attributes);
}
public function recordFromDomainOrder(DomainOrder $order, bool $verified = true): Domain
{
if (! $order->user_id) {
throw new \InvalidArgumentException('Domain order is missing user_id.');
}
return $this->recordPurchasedDomain(
$order->user_id,
$order->domain_name,
(string) ($order->registrar ?: DomainOrder::REGISTRAR_DYNADOT),
$order->registrar_order_id ? (string) $order->registrar_order_id : null,
$verified,
[
'domain_order_id' => $order->id,
'order_type' => $order->order_type,
],
);
}
public function recordFromRcServiceOrder(RcServiceOrder $order, bool $verified = false): ?Domain
{
if (! $order->user_id || ! filled($order->domain_name)) {
return null;
}
if (! in_array($order->order_type, [
RcServiceOrder::TYPE_DOMAIN_REGISTRATION,
RcServiceOrder::TYPE_DOMAIN_TRANSFER,
], true)) {
return null;
}
return $this->recordPurchasedDomain(
$order->user_id,
$order->domain_name,
DomainOrder::REGISTRAR_RESELLERCLUB,
$order->rc_order_id ? (string) $order->rc_order_id : null,
$verified,
[
'rc_service_order_id' => $order->id,
'order_type' => $order->order_type,
],
);
}
public function markVerified(string $host): ?Domain
{
$domain = Domain::query()->where('host', strtolower(trim($host)))->first();
if (! $domain) {
return null;
}
$domain->update([
'status' => 'verified',
'onboarding_state' => Domain::STATE_ACTIVE,
'verified_at' => $domain->verified_at ?: now(),
]);
return $domain->fresh();
}
private function resolveSource(?Domain $existing): string
{
if (! $existing) {
return 'purchased';
}
$source = (string) ($existing->source ?: '');
if (in_array($source, ['website', 'hosting', 'email', 'hosting_email', 'website_email'], true)) {
return $source;
}
return 'purchased';
}
}
@@ -0,0 +1,519 @@
<?php
namespace App\Services\Domain;
use App\Jobs\MailProvisioningJob;
use App\Jobs\ProvisionDomainZoneJob;
use App\Jobs\SslProvisioningJob;
use App\Models\Domain;
use App\Models\DomainDkimKey;
use App\Models\DomainDnsRecord;
use App\Models\DomainNsCheck;
use App\Notifications\DomainVerifiedNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class DomainVerificationService
{
public function __construct(
private readonly DomainDnsBlueprintService $blueprints,
private readonly \App\Services\Dns\PowerDnsClient $pdns,
) {
}
public function verify(Domain $domain): bool
{
$domain->refresh();
$statusBefore = (string) $domain->status;
$stateBefore = (string) ($domain->onboarding_state ?: Domain::STATE_PENDING_NS);
$domain->update([
'status' => 'verifying',
'onboarding_state' => Domain::STATE_VERIFYING_NS,
'ns_checked_at' => now(),
]);
if ((string) $domain->onboarding_mode === Domain::MODE_MANUAL_DNS) {
return $this->verifyManualDns($domain->fresh(), $statusBefore, $stateBefore);
}
return $this->verifyNameserverTakeover($domain->fresh(), $statusBefore, $stateBefore);
}
public function prepareNameserverZone(Domain $domain): void
{
$domain = $domain->fresh();
$this->ensureDkimKeys($domain);
$this->dispatchProvisioningChain($domain->id);
}
public function prepareManualDnsPack(Domain $domain): void
{
$domain = $domain->fresh();
[$selector, $publicKey] = $this->ensureDkimKeys($domain);
$pack = $this->blueprints->manualDnsPack($domain, $selector, $publicKey);
$this->syncDnsRecords($domain, $pack, 'required_manual');
}
private function verifyNameserverTakeover(Domain $domain, string $statusBefore, string $stateBefore): bool
{
$expected = $this->normalizeNameservers((array) ($domain->ns_expected ?? $this->blueprints->expectedNameservers()));
$observed = $this->lookupNameservers($domain->host);
$domain->update([
'ns_expected' => $expected,
'ns_observed' => $observed,
]);
if (! $this->nameserversMatch($expected, $observed)) {
$domain->update([
'status' => 'pending',
'onboarding_state' => Domain::STATE_PENDING_NS,
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'ns_takeover',
'result' => 'pending',
'expected_nameservers' => $expected,
'observed_nameservers' => $observed,
'has_authoritative_answer' => null,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Nameservers do not match expected Ladill nameservers yet.',
]);
return false;
}
$hasAuthoritativeAnswer = $this->zoneHasAuthoritativeAnswer($domain->host);
if (! $hasAuthoritativeAnswer) {
ProvisionDomainZoneJob::dispatch($domain->id);
$domain->update([
'status' => 'verifying',
'onboarding_state' => Domain::STATE_VERIFYING_NS,
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'ns_takeover',
'result' => 'pending',
'expected_nameservers' => $expected,
'observed_nameservers' => $observed,
'has_authoritative_answer' => false,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Nameservers match, but authoritative zone answer is not ready yet.',
]);
return false;
}
return DB::transaction(function () use ($domain, $statusBefore, $stateBefore, $expected, $observed, $hasAuthoritativeAnswer): bool {
$domain->refresh();
$domain->update([
'status' => 'verifying',
'onboarding_state' => Domain::STATE_CONNECTED,
'connected_at' => $domain->connected_at ?? now(),
'dns_mode' => 'managed',
]);
[$selector, $publicKey] = $this->ensureDkimKeys($domain);
$records = $this->blueprints->managedRecords($domain, $selector, $publicKey);
$this->syncDnsRecords($domain, $records, 'managed');
$domain->update([
'onboarding_state' => Domain::STATE_MAIL_READY,
'mail_ready_at' => $domain->mail_ready_at ?? now(),
]);
$domain->update([
'status' => 'verified',
'verified_at' => $domain->verified_at ?? now(),
'onboarding_state' => Domain::STATE_ACTIVE,
'active_at' => $domain->active_at ?? now(),
]);
$domain->refresh();
$this->logCheck($domain, [
'check_type' => 'ns_takeover',
'result' => 'success',
'expected_nameservers' => $expected,
'observed_nameservers' => $observed,
'has_authoritative_answer' => $hasAuthoritativeAnswer,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Nameserver takeover complete. Managed DNS and mail records are provisioned.',
]);
$this->dispatchProvisioningChain($domain->id);
// Notify user that domain is verified
$user = $domain->website?->user;
if ($user) {
$user->notify(new DomainVerifiedNotification($domain));
}
return true;
});
}
private function verifyManualDns(Domain $domain, string $statusBefore, string $stateBefore): bool
{
$this->prepareManualDnsPack($domain);
$domain = $domain->fresh('dnsRecords');
$records = $domain->dnsRecords
->where('source', 'required_manual')
->values();
if ($records->isEmpty()) {
$domain->update([
'status' => 'failed',
'onboarding_state' => Domain::STATE_FAILED,
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'manual_dns',
'result' => 'failed',
'manual_records_total' => 0,
'manual_records_verified' => 0,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Manual DNS pack is empty; cannot verify domain.',
]);
return false;
}
$allVerified = true;
foreach ($records as $record) {
$matched = $this->recordExistsInPublicDns($domain->host, $record);
$record->update([
'status' => $matched ? 'verified' : 'pending',
'last_checked_at' => now(),
]);
if (! $matched) {
$allVerified = false;
}
}
if (! $allVerified) {
$domain->update([
'status' => 'pending',
'onboarding_state' => Domain::STATE_VERIFYING_NS,
'ns_checked_at' => now(),
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'manual_dns',
'result' => 'pending',
'manual_records_total' => $records->count(),
'manual_records_verified' => $records->where('status', 'verified')->count(),
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Manual DNS records are not fully propagated yet.',
]);
return false;
}
$domain->update([
'status' => 'verified',
'verified_at' => $domain->verified_at ?? now(),
'onboarding_state' => Domain::STATE_ACTIVE,
'dns_mode' => 'manual',
'manual_dns_verified_at' => $domain->manual_dns_verified_at ?? now(),
'connected_at' => $domain->connected_at ?? now(),
'mail_ready_at' => $domain->mail_ready_at ?? now(),
'active_at' => $domain->active_at ?? now(),
'ns_checked_at' => now(),
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'manual_dns',
'result' => 'success',
'manual_records_total' => $records->count(),
'manual_records_verified' => $records->where('status', 'verified')->count(),
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'All required manual DNS records are verified.',
]);
$this->dispatchProvisioningChain($domain->id);
// Notify user that domain is verified
$user = $domain->website?->user;
if ($user) {
$user->notify(new DomainVerifiedNotification($domain));
}
return true;
}
public function reprovision(Domain $domain): void
{
$this->dispatchProvisioningChain($domain->id);
}
private function dispatchProvisioningChain(int $domainId): void
{
ProvisionDomainZoneJob::withChain([
new MailProvisioningJob($domainId),
new SslProvisioningJob($domainId),
])->dispatch($domainId);
}
private function ensureDkimKeys(Domain $domain): array
{
$existing = DomainDkimKey::query()
->where('domain_id', $domain->id)
->where('status', 'active')
->latest('id')
->first();
if ($existing) {
return [$existing->selector, $existing->public_key_txt];
}
$prefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill');
$selector = Str::lower(trim($prefix)) ?: 'ladill';
$selector .= '1';
$publicKey = '';
$privateKey = null;
if (function_exists('openssl_pkey_new')) {
$resource = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($resource !== false) {
openssl_pkey_export($resource, $privateOut);
$details = openssl_pkey_get_details($resource);
$publicPem = (string) ($details['key'] ?? '');
$publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicPem) ?: '';
$privateKey = $privateOut ?: null;
}
}
if ($publicKey === '') {
$publicKey = Str::upper(Str::random(360));
}
$privatePath = null;
if (is_string($privateKey) && trim($privateKey) !== '') {
$safeHost = Str::slug($domain->host, '_');
$directory = storage_path('app/mail/dkim');
File::ensureDirectoryExists($directory);
$privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key';
File::put($privatePath, $privateKey);
}
$key = DomainDkimKey::query()->create([
'domain_id' => $domain->id,
'selector' => $selector,
'public_key_txt' => $publicKey,
'private_key_path' => $privatePath,
'status' => 'active',
'generated_at' => now(),
]);
return [$key->selector, $key->public_key_txt];
}
private function syncDnsRecords(Domain $domain, array $records, string $source): void
{
$keepSignatures = [];
foreach ($records as $record) {
if (! is_array($record)) {
continue;
}
$name = (string) ($record['name'] ?? '@');
$type = strtoupper((string) ($record['type'] ?? 'TXT'));
$value = (string) ($record['value'] ?? '');
$keepSignatures[] = strtolower($name.'|'.$type.'|'.$value);
DomainDnsRecord::query()->updateOrCreate(
[
'domain_id' => $domain->id,
'name' => $name,
'type' => $type,
'value' => $value,
],
[
'ttl' => (int) ($record['ttl'] ?? 3600),
'priority' => isset($record['priority']) ? (int) $record['priority'] : null,
'source' => $source,
'status' => (string) ($record['status'] ?? 'pending'),
'notes' => $record['notes'] ?? null,
'last_checked_at' => now(),
]
);
}
DomainDnsRecord::query()
->where('domain_id', $domain->id)
->where('source', $source)
->get()
->each(function (DomainDnsRecord $record) use ($keepSignatures): void {
$signature = strtolower($record->name.'|'.$record->type.'|'.$record->value);
if (! in_array($signature, $keepSignatures, true)) {
$record->delete();
}
});
}
private function lookupNameservers(string $host): array
{
if (! function_exists('dns_get_record')) {
return [];
}
$records = @dns_get_record($host, DNS_NS);
if (! is_array($records)) {
return [];
}
return collect($records)
->map(fn ($record) => strtolower(trim((string) ($record['target'] ?? ''), ". \t\n\r\0\x0B")))
->filter()
->unique()
->values()
->all();
}
private function normalizeNameservers(array $nameservers): array
{
return collect($nameservers)
->map(fn ($record) => strtolower(trim((string) $record, ". \t\n\r\0\x0B")))
->filter()
->unique()
->values()
->all();
}
private function nameserversMatch(array $expected, array $observed): bool
{
$expected = $this->normalizeNameservers($expected);
$observed = $this->normalizeNameservers($observed);
if ($expected === [] || $observed === []) {
return false;
}
return collect($expected)->diff($observed)->isEmpty();
}
private function recordExistsInPublicDns(string $zoneHost, DomainDnsRecord $record): bool
{
if (! function_exists('dns_get_record')) {
return false;
}
$name = trim((string) $record->name);
$fqdn = $name === '@' ? $zoneHost : $name.'.'.$zoneHost;
$type = strtoupper(trim((string) $record->type));
$expectedValue = strtolower(trim((string) $record->value, ". \t\n\r\0\x0B\""));
$flag = match ($type) {
'MX' => DNS_MX,
'CNAME' => DNS_CNAME,
'A' => DNS_A,
default => DNS_TXT,
};
$records = @dns_get_record($fqdn, $flag);
if (! is_array($records) || $records === []) {
return false;
}
foreach ($records as $item) {
if ($type === 'MX') {
$value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B"));
$priority = (int) ($item['pri'] ?? 0);
$expectedPriority = (int) ($record->priority ?? 0);
if ($value === $expectedValue && $priority === $expectedPriority) {
return true;
}
continue;
}
if ($type === 'TXT') {
$value = strtolower(trim((string) ($item['txt'] ?? ''), "\" \t\n\r\0\x0B"));
if ($value === $expectedValue) {
return true;
}
continue;
}
if ($type === 'CNAME') {
$value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B"));
if ($value === $expectedValue) {
return true;
}
continue;
}
if ($type === 'A') {
$value = strtolower(trim((string) ($item['ip'] ?? ''), ". \t\n\r\0\x0B"));
if ($value === $expectedValue) {
return true;
}
}
}
return false;
}
private function zoneHasAuthoritativeAnswer(string $host): bool
{
if (! function_exists('dns_get_record')) {
return false;
}
$soa = @dns_get_record($host, DNS_SOA);
if (is_array($soa) && $soa !== []) {
return true;
}
$ns = @dns_get_record($host, DNS_NS);
return is_array($ns) && $ns !== [];
}
private function logCheck(Domain $domain, array $payload): void
{
DomainNsCheck::query()->create([
'domain_id' => $domain->id,
'check_type' => (string) ($payload['check_type'] ?? 'onboarding'),
'result' => (string) ($payload['result'] ?? 'pending'),
'expected_nameservers' => $payload['expected_nameservers'] ?? null,
'observed_nameservers' => $payload['observed_nameservers'] ?? null,
'has_authoritative_answer' => $payload['has_authoritative_answer'] ?? null,
'manual_records_total' => (int) ($payload['manual_records_total'] ?? 0),
'manual_records_verified' => (int) ($payload['manual_records_verified'] ?? 0),
'status_before' => $payload['status_before'] ?? null,
'status_after' => $payload['status_after'] ?? null,
'state_before' => $payload['state_before'] ?? null,
'state_after' => $payload['state_after'] ?? null,
'notes' => $payload['notes'] ?? null,
'checked_at' => now(),
]);
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\EmailDomain;
use Illuminate\Support\Facades\Http;
/**
* Client for the shared Email-Domain service (§4-D) one verification engine
* (SPF/DKIM/DMARC). The Email app consumes it to render its own in-app domain
* setup. Domains are scoped to the user (public_id) server-side. Summary:
* {id, domain, status, active, spf, dkim, dmarc}; detail adds {dns_records, }.
*/
class EmailDomainClient
{
private function base(): string
{
return rtrim((string) config('emaildomain.api_url'), '/');
}
private function http()
{
return Http::withToken((string) (config('emaildomain.api_key') ?? ''))->acceptJson()->timeout(10);
}
/** All email domains for the user. */
public function forUser(string $publicId): array
{
return $this->data($this->http()->get($this->base(), ['user' => $publicId]));
}
/** Only verified/active domains (eligible for mailboxes). */
public function verified(string $publicId): array
{
return $this->data($this->http()->get($this->base(), ['user' => $publicId, 'status' => 'active']));
}
public function find(string $publicId, int $id): ?array
{
return collect($this->forUser($publicId))->firstWhere('id', $id) ?: null;
}
/** Full detail (incl. DNS records to publish) for one domain. */
public function show(string $publicId, int $id): array
{
return $this->data($this->http()->get($this->base().'/'.$id, ['user' => $publicId]));
}
/** Add an email domain. Returns its detail. */
public function create(string $publicId, string $domain, string $method = 'dns_record'): array
{
$res = $this->http()->post($this->base(), ['user' => $publicId, 'domain' => $domain, 'verification_method' => $method]);
$res->throw();
return (array) $res->json('data');
}
/** Run verification; returns updated detail. */
public function verify(string $publicId, int $id): array
{
$res = $this->http()->post($this->base().'/'.$id.'/verify', ['user' => $publicId]);
$res->throw();
return (array) $res->json('data');
}
public function delete(string $publicId, int $id): void
{
$this->http()->delete($this->base().'/'.$id, ['user' => $publicId])->throw();
}
private function data($res): array
{
$res->throw();
return (array) ($res->json('data') ?? $res->json());
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Services;
use App\Services\Currency\CurrencyConverterService;
class ExchangeRateService
{
public function __construct(
private CurrencyConverterService $currency,
) {}
public function getUsdToGhs(): float
{
return $this->currency->getUsdToGhsRate();
}
public function getEurToGhs(): float
{
return $this->currency->getEurToGhsRate();
}
public function fetchLiveRate(): float
{
return $this->currency->refreshRate();
}
public function refreshRate(): float
{
return $this->currency->refreshRate();
}
public function getCachedRate(): ?float
{
$info = $this->currency->getRateInfo();
return ($info['cached'] ?? false)
? (float) ($info['rate'] ?? 0)
: null;
}
public function getRateInfo(): array
{
$info = $this->currency->getRateInfo();
return [
'rate' => (float) ($info['rate'] ?? 0),
'is_cached' => (bool) ($info['cached'] ?? false),
'fallback_rate' => (float) ($info['fallback'] ?? config('hosting.pricing.fallback_usd_to_ghs_rate', 15.50)),
];
}
}
+464
View File
@@ -0,0 +1,464 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostedSite;
use App\Models\HostedDatabase;
use App\Models\AppInstallation;
use App\Services\Hosting\Contracts\AppInstallerInterface;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class AppInstaller implements AppInstallerInterface
{
private SharedNodeProvider $nodeProvider;
public function __construct(SharedNodeProvider $nodeProvider)
{
$this->nodeProvider = $nodeProvider;
}
public function getSupportedApps(): array
{
return [
'wordpress' => [
'name' => 'WordPress',
'description' => 'Popular blogging and CMS platform',
'icon' => 'wordpress',
'min_php' => '7.4',
'recommended_php' => '8.2',
],
'joomla' => [
'name' => 'Joomla',
'description' => 'Flexible content management system',
'icon' => 'joomla',
'min_php' => '7.4',
'recommended_php' => '8.1',
],
'drupal' => [
'name' => 'Drupal',
'description' => 'Enterprise-grade CMS',
'icon' => 'drupal',
'min_php' => '8.1',
'recommended_php' => '8.2',
],
'opencart' => [
'name' => 'OpenCart',
'description' => 'E-commerce platform',
'icon' => 'opencart',
'min_php' => '8.0',
'recommended_php' => '8.1',
],
'magento' => [
'name' => 'Magento',
'description' => 'Enterprise e-commerce platform',
'icon' => 'magento',
'min_php' => '8.1',
'recommended_php' => '8.2',
'min_ram_mb' => 2048,
],
];
}
public function getAppVersions(string $appType): array
{
return match ($appType) {
'wordpress' => ['6.4', '6.3', '6.2'],
'joomla' => ['5.0', '4.4', '4.3'],
'drupal' => ['10.2', '10.1', '10.0'],
'opencart' => ['4.0', '3.0'],
'magento' => ['2.4.6', '2.4.5'],
default => [],
};
}
public function install(HostedSite $site, string $appType, array $config): AppInstallation
{
$account = $site->account;
$supportedApps = $this->getSupportedApps();
if (!isset($supportedApps[$appType])) {
throw new \InvalidArgumentException("Unsupported app type: {$appType}");
}
// Create database for the app
$dbName = $account->username . '_' . Str::random(4);
$dbUser = $account->username . '_' . Str::random(4);
$dbPassword = Str::password(16);
$database = HostedDatabase::create([
'hosting_account_id' => $account->id,
'hosted_site_id' => $site->id,
'name' => $dbName,
'username' => $dbUser,
'password_encrypted' => encrypt($dbPassword),
'type' => 'mysql',
'status' => 'active',
]);
$this->nodeProvider->createDatabase($database, $dbPassword);
// Create installation record
$installation = AppInstallation::create([
'hosted_site_id' => $site->id,
'app_type' => $appType,
'app_version' => $config['version'] ?? $this->getAppVersions($appType)[0],
'admin_username' => $config['admin_username'] ?? 'admin',
'admin_email' => $config['admin_email'] ?? $account->user->email,
'status' => 'installing',
'config' => [
'db_name' => $dbName,
'db_user' => $dbUser,
'site_title' => $config['site_title'] ?? $site->domain,
],
]);
try {
$result = match ($appType) {
'wordpress' => $this->installWordPress($site, $installation, $database, $dbPassword, $config),
'joomla' => $this->installJoomla($site, $installation, $database, $dbPassword, $config),
'drupal' => $this->installDrupal($site, $installation, $database, $dbPassword, $config),
'opencart' => $this->installOpenCart($site, $installation, $database, $dbPassword, $config),
'magento' => $this->installMagento($site, $installation, $database, $dbPassword, $config),
default => throw new \InvalidArgumentException("No installer for: {$appType}"),
};
$installation->update([
'status' => 'active',
'admin_path' => $result['admin_path'] ?? null,
'installed_at' => now(),
]);
$site->update([
'installed_app' => $appType,
'installed_app_version' => $installation->app_version,
]);
} catch (\Exception $e) {
Log::error("App installation failed", [
'app' => $appType,
'site' => $site->domain,
'error' => $e->getMessage(),
]);
$installation->update([
'status' => 'failed',
'config' => array_merge($installation->config ?? [], ['error' => $e->getMessage()]),
]);
throw $e;
}
return $installation;
}
public function uninstall(AppInstallation $installation): bool
{
$site = $installation->site;
$account = $site->account;
$appType = $installation->app_type;
try {
// For Node.js/Python apps, stop and remove the systemd service first
if (in_array($appType, ['nodejs', 'python'], true)) {
try {
$this->nodeProvider->removeAppService($site);
} catch (\Exception $e) {
Log::warning("Failed to remove app service during uninstall: " . $e->getMessage());
}
// Node.js specific cleanup
if ($appType === 'nodejs') {
$this->nodeProvider->executeCommand(
$account,
"rm -rf {$site->document_root}/node_modules {$site->document_root}/package.json {$site->document_root}/package-lock.json 2>&1 || true"
);
}
// Python specific cleanup
if ($appType === 'python') {
$this->nodeProvider->executeCommand(
$account,
"rm -rf {$site->document_root}/venv {$site->document_root}/__pycache__ {$site->document_root}/.venv 2>&1 || true"
);
$this->nodeProvider->executeCommand(
$account,
"find {$site->document_root} -name '*.pyc' -delete 2>&1 || true"
);
}
}
// For apps with subdirectories (Drupal with web/, Laravel with public/, Magento with pub/)
// we need to clean the parent directory too
$docRoot = $site->document_root;
if (in_array($appType, ['drupal', 'laravel', 'magento'], true)) {
// Try to clean up parent directory if it's a subdirectory installation
$parentDir = dirname($docRoot);
if ($parentDir !== "/home/{$account->username}" && $parentDir !== "/home/{$account->username}/public_html") {
$this->nodeProvider->executeCommandAsRoot(
$account,
"rm -rf " . escapeshellarg($parentDir) . " 2>&1 || true"
);
}
}
// Remove all files from document root (use root to handle permission issues)
$escapedDocRoot = escapeshellarg($docRoot);
$this->nodeProvider->executeCommandAsRoot(
$account,
"rm -rf {$escapedDocRoot}/* {$escapedDocRoot}/.[!.]* {$escapedDocRoot}/..?* 2>&1 || true"
);
// Drop database and database user if exists
$dbName = $installation->config['db_name'] ?? null;
if ($dbName) {
$database = HostedDatabase::where('name', $dbName)->first();
if ($database) {
// Drop both database and user
$dbUser = $database->username;
$this->nodeProvider->executeCommandAsRoot(
$account,
"mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbUser}'@'localhost';\" 2>&1 || true"
);
$database->delete();
} else {
// Database record not found, try cleanup anyway
$this->nodeProvider->executeCommandAsRoot(
$account,
"mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbName}'@'localhost';\" 2>&1 || true"
);
}
}
$installation->update(['status' => 'removed']);
$site->update(['installed_app' => null, 'installed_app_version' => null]);
Log::info("App uninstalled successfully", [
'app' => $appType,
'site' => $site->domain,
'installation_id' => $installation->id,
]);
return true;
} catch (\Exception $e) {
Log::error("Failed to uninstall app: " . $e->getMessage(), [
'app' => $appType,
'site' => $site->domain,
'installation_id' => $installation->id,
]);
throw $e;
}
}
public function update(AppInstallation $installation, ?string $version = null): bool
{
$site = $installation->site;
$account = $site->account;
$installation->update(['status' => 'updating']);
try {
if ($installation->app_type === 'wordpress') {
$this->nodeProvider->executeCommand($account,
"cd {$site->document_root} && wp core update" . ($version ? " --version={$version}" : "")
);
$this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp plugin update --all");
$this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp theme update --all");
}
$installation->update([
'status' => 'active',
'app_version' => $version ?? $installation->app_version,
'last_updated_at' => now(),
]);
return true;
} catch (\Exception $e) {
$installation->update(['status' => 'active']);
throw $e;
}
}
public function getStatus(AppInstallation $installation): array
{
$site = $installation->site;
$account = $site->account;
$status = [
'app_type' => $installation->app_type,
'version' => $installation->app_version,
'status' => $installation->status,
'installed_at' => $installation->installed_at,
];
if ($installation->app_type === 'wordpress') {
$result = $this->nodeProvider->executeCommand($account,
"cd {$site->document_root} && wp core version 2>/dev/null"
);
$status['current_version'] = trim($result['output']);
}
return $status;
}
public function backup(AppInstallation $installation): string
{
$site = $installation->site;
$account = $site->account;
$backupName = "{$installation->app_type}_{$site->domain}_" . date('Y-m-d_His');
$backupPath = "/home/{$account->username}/backups/{$backupName}";
$this->nodeProvider->executeCommand($account, "mkdir -p /home/{$account->username}/backups");
// Backup files
$this->nodeProvider->executeCommand($account,
"tar -czf {$backupPath}_files.tar.gz -C {$site->document_root} ."
);
// Backup database
$dbName = $installation->config['db_name'] ?? null;
if ($dbName) {
$this->nodeProvider->executeCommand($account,
"mysqldump {$dbName} > {$backupPath}_db.sql"
);
}
return $backupPath;
}
public function restore(AppInstallation $installation, string $backupPath): bool
{
$site = $installation->site;
$account = $site->account;
// Restore files
$this->nodeProvider->executeCommand($account,
"rm -rf {$site->document_root}/* && tar -xzf {$backupPath}_files.tar.gz -C {$site->document_root}"
);
// Restore database
$dbName = $installation->config['db_name'] ?? null;
if ($dbName && file_exists("{$backupPath}_db.sql")) {
$this->nodeProvider->executeCommand($account,
"mysql {$dbName} < {$backupPath}_db.sql"
);
}
return true;
}
private function installWordPress(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && wp core download --version={$installation->app_version}",
"cd {$docRoot} && wp config create --dbname={$database->name} --dbuser={$database->username} --dbpass={$dbPassword} --dbhost=localhost",
"cd {$docRoot} && wp core install --url=https://{$site->domain} --title='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin_user={$installation->admin_username} --admin_password={$adminPassword} --admin_email={$installation->admin_email}",
"cd {$docRoot} && wp rewrite structure '/%postname%/'",
"cd {$docRoot} && chmod -R 755 .",
"cd {$docRoot} && chmod -R 775 wp-content/uploads",
];
foreach ($commands as $cmd) {
$result = $this->nodeProvider->executeCommand($account, $cmd);
if ($result['exit_code'] !== 0) {
throw new \RuntimeException("WordPress install failed: " . $result['output']);
}
}
return [
'admin_path' => '/wp-admin',
'admin_password' => $adminPassword,
];
}
private function installJoomla(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && curl -sL https://downloads.joomla.org/cms/joomla5/{$installation->app_version}/Joomla_{$installation->app_version}-Stable-Full_Package.zip -o joomla.zip",
"cd {$docRoot} && unzip -q joomla.zip && rm joomla.zip",
"cd {$docRoot} && php installation/joomla.php install --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin-user={$installation->admin_username} --admin-username={$installation->admin_username} --admin-password={$adminPassword} --admin-email={$installation->admin_email} --db-type=mysqli --db-host=localhost --db-user={$database->username} --db-pass={$dbPassword} --db-name={$database->name} --db-prefix=jos_ --db-encryption=0",
"cd {$docRoot} && rm -rf installation",
"cd {$docRoot} && chmod -R 755 .",
"cd {$docRoot} && chmod 775 .",
"cd {$docRoot} && chmod -R 775 cache tmp administrator/cache administrator/logs",
"cd {$docRoot} && echo '<?php return [];' > administrator/cache/autoload_psr4.php && echo '<?php return [];' > cache/autoload_psr4.php",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
$this->nodeProvider->setWebServerGroupOwnership($account, $docRoot);
return ['admin_path' => '/administrator'];
}
private function installDrupal(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && composer create-project drupal/recommended-project:{$installation->app_version} . --no-interaction",
"cd {$docRoot}/web && ../vendor/bin/drush site:install standard --db-url=mysql://{$database->username}:{$dbPassword}@localhost/{$database->name} --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --account-name={$installation->admin_username} --account-pass={$adminPassword} --account-mail={$installation->admin_email} -y",
"cd {$docRoot} && chmod -R 755 .",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
return ['admin_path' => '/user/login'];
}
private function installOpenCart(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && curl -sL https://github.com/opencart/opencart/releases/download/{$installation->app_version}.0.0/opencart-{$installation->app_version}.0.0.zip -o opencart.zip",
"cd {$docRoot} && unzip -q opencart.zip && mv upload/* . && rm -rf upload opencart.zip",
"cd {$docRoot} && php install/cli_install.php install --db_hostname localhost --db_username {$database->username} --db_password {$dbPassword} --db_database {$database->name} --username {$installation->admin_username} --password {$adminPassword} --email {$installation->admin_email} --http_server https://{$site->domain}/",
"cd {$docRoot} && rm -rf install",
"cd {$docRoot} && chmod -R 755 .",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
return ['admin_path' => '/admin'];
}
private function installMagento(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
{
$account = $site->account;
$docRoot = $site->document_root;
$adminPassword = $config['admin_password'] ?? Str::password(12);
$commands = [
"cd {$docRoot} && composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition={$installation->app_version} . --no-interaction",
"cd {$docRoot} && bin/magento setup:install --base-url=https://{$site->domain}/ --db-host=localhost --db-name={$database->name} --db-user={$database->username} --db-password={$dbPassword} --admin-firstname=Admin --admin-lastname=User --admin-email={$installation->admin_email} --admin-user={$installation->admin_username} --admin-password={$adminPassword} --language=en_US --currency=USD --timezone=UTC --use-rewrites=1",
"cd {$docRoot} && bin/magento deploy:mode:set production",
"cd {$docRoot} && chmod -R 755 .",
];
foreach ($commands as $cmd) {
$this->nodeProvider->executeCommand($account, $cmd);
}
return ['admin_path' => '/admin'];
}
}
@@ -0,0 +1,373 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
class BrowserTerminalSessionManager
{
private const IDLE_TIMEOUT_SECONDS = 120;
public function createSession(HostingAccount $account, int $userId, string $relativePath = '/public_html', int $cols = 120, int $rows = 30): array
{
$sessionId = (string) Str::uuid();
$directory = $this->sessionDirectory($sessionId);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new \RuntimeException('Unable to create browser terminal session directory.');
}
touch($this->eventLogPath($sessionId));
touch($this->outputLogPath($sessionId));
touch($this->heartbeatPath($sessionId));
$metadata = [
'session_id' => $sessionId,
'account_id' => $account->id,
'user_id' => $userId,
'username' => $account->username,
'relative_path' => $relativePath,
'cols' => $this->normalizeColumns($cols),
'rows' => $this->normalizeRows($rows),
'status' => 'starting',
'created_at' => now()->toIso8601String(),
'last_seen_at' => now()->toIso8601String(),
'pid' => null,
'exit_code' => null,
'error' => null,
];
$this->writeMetadata($sessionId, $metadata);
$workerCommand = $this->buildWorkerCommand($sessionId);
$result = Process::timeout(10)->run(['bash', '-lc', $workerCommand]);
$pid = trim($result->output());
if ($result->failed() || $pid === '' || ! ctype_digit($pid)) {
$metadata['status'] = 'failed';
$metadata['error'] = 'Unable to start the browser terminal worker.';
$this->writeMetadata($sessionId, $metadata);
throw new \RuntimeException($metadata['error']);
}
$metadata['pid'] = (int) $pid;
$this->writeMetadata($sessionId, $metadata);
$metadata = $this->waitForWorkerStartup($sessionId, $metadata);
return $metadata;
}
public function readOutput(HostingAccount $account, int $userId, string $sessionId, int $offset = 0): array
{
$metadata = $this->sessionMetadataFor($account, $userId, $sessionId);
$offset = max(0, $offset);
$outputPath = $this->outputLogPath($sessionId);
$size = is_file($outputPath) ? filesize($outputPath) : 0;
$chunk = '';
if ($size > $offset) {
$stream = fopen($outputPath, 'rb');
if ($stream !== false) {
fseek($stream, $offset);
$chunk = (string) stream_get_contents($stream);
fclose($stream);
}
}
$this->touchHeartbeat($sessionId);
return [
'output' => $chunk,
'offset' => $size,
'status' => $metadata['status'] ?? 'closed',
'exit_code' => $metadata['exit_code'] ?? null,
'error' => $metadata['error'] ?? null,
];
}
public function appendInput(HostingAccount $account, int $userId, string $sessionId, string $input): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'input',
'data' => $input,
]);
}
public function resizeSession(HostingAccount $account, int $userId, string $sessionId, int $cols, int $rows): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'resize',
'cols' => $this->normalizeColumns($cols),
'rows' => $this->normalizeRows($rows),
]);
}
public function closeSession(HostingAccount $account, int $userId, string $sessionId): void
{
$this->sessionMetadataFor($account, $userId, $sessionId);
$this->touchHeartbeat($sessionId);
$this->appendEvent($sessionId, [
'type' => 'close',
]);
}
public function readWorkerMetadata(string $sessionId): array
{
$metadata = $this->readMetadata($sessionId);
if ($metadata === null) {
throw new \RuntimeException('Browser terminal session not found.');
}
return $metadata;
}
public function writeWorkerMetadata(string $sessionId, array $metadata): void
{
$this->writeMetadata($sessionId, $metadata);
}
public function appendWorkerOutput(string $sessionId, string $output): void
{
if ($output === '') {
return;
}
file_put_contents($this->outputLogPath($sessionId), $output, FILE_APPEND | LOCK_EX);
}
public function readWorkerEvents(string $sessionId, int &$offset): array
{
$path = $this->eventLogPath($sessionId);
if (! is_file($path)) {
return [];
}
$stream = fopen($path, 'rb');
if ($stream === false) {
return [];
}
fseek($stream, $offset);
$payload = (string) stream_get_contents($stream);
$offset = ftell($stream) ?: $offset;
fclose($stream);
$events = [];
foreach (preg_split("/\r\n|\n|\r/", $payload) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (is_array($decoded)) {
$events[] = $decoded;
}
}
return $events;
}
public function isSessionIdle(array $metadata): bool
{
$heartbeatTime = @filemtime($this->heartbeatPath((string) ($metadata['session_id'] ?? '')));
if ($heartbeatTime === false) {
return false;
}
return (time() - $heartbeatTime) >= self::IDLE_TIMEOUT_SECONDS;
}
private function sessionMetadataFor(HostingAccount $account, int $userId, string $sessionId): array
{
$metadata = $this->readMetadata($sessionId);
if ($metadata === null) {
throw new \RuntimeException('Browser terminal session not found.');
}
if (($metadata['account_id'] ?? null) !== $account->id || ($metadata['user_id'] ?? null) !== $userId) {
throw new \RuntimeException('Browser terminal session not found.');
}
return $metadata;
}
private function appendEvent(string $sessionId, array $payload): void
{
file_put_contents(
$this->eventLogPath($sessionId),
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL,
FILE_APPEND | LOCK_EX
);
}
private function readMetadata(string $sessionId): ?array
{
$path = $this->metadataPath($sessionId);
if (! is_file($path)) {
return null;
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
}
private function writeMetadata(string $sessionId, array $metadata): void
{
file_put_contents(
$this->metadataPath($sessionId),
json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
}
private function metadataPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/meta.json';
}
private function eventLogPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/events.ndjson';
}
private function outputLogPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/output.log';
}
private function heartbeatPath(string $sessionId): string
{
return $this->sessionDirectory($sessionId).'/heartbeat';
}
private function sessionDirectory(string $sessionId): string
{
return storage_path('app/browser-terminal-sessions/'.$sessionId);
}
private function normalizeColumns(int $cols): int
{
return max(40, min($cols, 240));
}
private function normalizeRows(int $rows): int
{
return max(10, min($rows, 80));
}
private function touchHeartbeat(string $sessionId): void
{
touch($this->heartbeatPath($sessionId));
}
private function buildWorkerCommand(string $sessionId): string
{
return sprintf(
'cd %s && nohup %s artisan hosting:terminal-worker %s >> %s 2>&1 < /dev/null & echo $!',
escapeshellarg(base_path()),
escapeshellarg($this->resolvePhpCliBinary()),
escapeshellarg($sessionId),
escapeshellarg($this->outputLogPath($sessionId))
);
}
private function resolvePhpCliBinary(): string
{
$candidates = array_filter([
env('LADILL_PHP_CLI_BINARY'),
$this->isCliBinary(PHP_BINARY) ? PHP_BINARY : null,
$this->joinBinaryPath(PHP_BINDIR, 'php'),
$this->joinBinaryPath(PHP_BINDIR, 'php-cli'),
'/usr/bin/php',
'/usr/local/bin/php',
]);
foreach ($candidates as $candidate) {
if ($this->isCliBinary($candidate)) {
return $candidate;
}
}
return PHP_BINARY;
}
private function joinBinaryPath(string $directory, string $binary): string
{
return rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$binary;
}
private function isCliBinary(?string $path): bool
{
if (! is_string($path) || trim($path) === '') {
return false;
}
$basename = strtolower(basename($path));
if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) {
return false;
}
return is_file($path) && is_executable($path);
}
private function waitForWorkerStartup(string $sessionId, array $metadata): array
{
$deadline = microtime(true) + 2.5;
while (microtime(true) < $deadline) {
usleep(100000);
$current = $this->readWorkerMetadata($sessionId);
if (($current['status'] ?? 'starting') !== 'starting') {
return $current;
}
}
$metadata['status'] = 'failed';
$metadata['exit_code'] = 1;
$metadata['closed_at'] = now()->toIso8601String();
$metadata['error'] = $this->workerBootstrapError($sessionId);
$this->writeMetadata($sessionId, $metadata);
return $metadata;
}
private function workerBootstrapError(string $sessionId): string
{
$output = trim((string) @file_get_contents($this->outputLogPath($sessionId)));
if ($output !== '') {
$lines = preg_split("/\r\n|\n|\r/", $output) ?: [];
$tail = trim((string) end($lines));
if ($tail !== '') {
return 'Terminal worker failed to start: '.$tail;
}
}
return 'Terminal worker failed to start.';
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Hosting\Contracts;
use App\Models\HostedSite;
use App\Models\AppInstallation;
interface AppInstallerInterface
{
public function getSupportedApps(): array;
public function getAppVersions(string $appType): array;
public function install(HostedSite $site, string $appType, array $config): AppInstallation;
public function uninstall(AppInstallation $installation): bool;
public function update(AppInstallation $installation, ?string $version = null): bool;
public function getStatus(AppInstallation $installation): array;
public function backup(AppInstallation $installation): string;
public function restore(AppInstallation $installation, string $backupPath): bool;
}
@@ -0,0 +1,36 @@
<?php
namespace App\Services\Hosting\Contracts;
interface ComputeProviderInterface
{
public function createInstance(array $config): array;
public function getInstance(string $instanceId): ?array;
public function listInstances(array $filters = []): array;
public function startInstance(string $instanceId): bool;
public function stopInstance(string $instanceId): bool;
public function rebootInstance(string $instanceId): bool;
public function deleteInstance(string $instanceId): bool;
public function reinstallInstance(string $instanceId, string $image): bool;
public function createSnapshot(string $instanceId, string $name): array;
public function listSnapshots(string $instanceId): array;
public function deleteSnapshot(string $snapshotId): bool;
public function restoreSnapshot(string $instanceId, string $snapshotId): bool;
public function getAvailableImages(): array;
public function getAvailableRegions(): array;
public function getAvailableProducts(): array;
}
@@ -0,0 +1,79 @@
<?php
namespace App\Services\Hosting\Contracts;
use App\Models\HostedDatabase;
use App\Models\HostedSite;
use App\Models\HostingAccount;
interface PanelRuntimeInterface
{
public function key(): string;
public function label(): string;
/**
* @return array<int, string>
*/
public function capabilities(): array;
public function supports(string $capability): bool;
/**
* @return array<string, mixed>
*/
public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array;
/**
* @return array<string, mixed>
*/
public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array;
/**
* @return array<string, mixed>
*/
public function executeCommandAsRoot(HostingAccount $account, string $command): array;
/**
* @return array<string, mixed>
*/
public function getUsageStats(HostingAccount $account): array;
public function changePassword(HostingAccount $account, string $newPassword): bool;
public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void;
public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void;
/**
* @return array<string, mixed>
*/
public function createDatabase(HostedDatabase $database, string $password): array;
public function deleteDatabase(HostedDatabase $database): bool;
public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool;
/**
* @return array<string, mixed>
*/
public function addSite(HostedSite $site): array;
public function removeSite(HostedSite $site): bool;
public function requestLetsEncryptCertificate(HostedSite $site): bool;
public function ensurePhpFpmPool(HostingAccount $account): void;
public function setPhpVersion(HostedSite $site, string $version): bool;
public function changeAccountPhpVersion(HostingAccount $account, string $version): bool;
public function prepareDirectoryForUser(HostingAccount $account, string $path): void;
public function setWebServerGroupOwnership(HostingAccount $account, string $path): void;
public function removeAppService(HostedSite $site): bool;
public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void;
}
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Hosting\Contracts;
use App\Models\HostingAccount;
use App\Models\HostedSite;
use App\Models\HostedDatabase;
interface SharedHostingProviderInterface
{
public function createAccount(HostingAccount $account): array;
public function suspendAccount(HostingAccount $account, string $reason): bool;
public function unsuspendAccount(HostingAccount $account): bool;
public function terminateAccount(HostingAccount $account): bool;
public function changePassword(HostingAccount $account, string $newPassword): bool;
public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void;
public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void;
public function addSite(HostedSite $site): array;
public function removeSite(HostedSite $site): bool;
public function requestLetsEncryptCertificate(HostedSite $site): bool;
public function createDatabase(HostedDatabase $database, string $password): array;
public function deleteDatabase(HostedDatabase $database): bool;
public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool;
public function setPhpVersion(HostedSite $site, string $version): bool;
public function changeAccountPhpVersion(HostingAccount $account, string $version): bool;
/**
* @return list<string> PHP versions that currently have a pool config for this account.
*/
public function findPhpFpmPoolVersions(HostingAccount $account): array;
public function getUsageStats(HostingAccount $account): array;
public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array;
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Notifications\HostingExpiringNotification;
/** Sends one-time hosting plan expiry reminders. */
class HostingExpiryAlertService
{
/** @return list<int> */
public function milestones(): array
{
return array_values(array_map(
'intval',
(array) config('notifications.hosting_expiry_milestones', [30, 14, 7, 3, 1])
));
}
public function checkAll(): int
{
$sent = 0;
HostingAccount::query()
->where('status', HostingAccount::STATUS_ACTIVE)
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->with('user')
->each(function (HostingAccount $account) use (&$sent): void {
if ($this->checkAccount($account)) {
$sent++;
}
});
return $sent;
}
public function checkAccount(HostingAccount $account): bool
{
if (! $account->expires_at || ! $account->user || $account->expires_at->isPast()) {
return false;
}
$daysRemaining = $this->daysUntilExpiry($account);
if (! in_array($daysRemaining, $this->milestones(), true)) {
return false;
}
$metadata = is_array($account->metadata) ? $account->metadata : [];
$sent = array_map('intval', (array) ($metadata['expiry_alerts_sent'] ?? []));
if (in_array($daysRemaining, $sent, true)) {
return false;
}
$account->user->notify(new HostingExpiringNotification($account, $daysRemaining));
$metadata['expiry_alerts_sent'] = array_values(array_unique([...$sent, $daysRemaining]));
$account->update(['metadata' => $metadata]);
return true;
}
public function resetAlerts(HostingAccount $account): void
{
$metadata = is_array($account->metadata) ? $account->metadata : [];
unset($metadata['expiry_alerts_sent']);
$account->update(['metadata' => $metadata]);
}
private function daysUntilExpiry(HostingAccount $account): int
{
return (int) now()->startOfDay()->diffInDays(
$account->expires_at->copy()->startOfDay(),
false
);
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Services\Hosting;
use App\Jobs\DeactivateMailboxJob;
use App\Jobs\ProvisionMailboxJob;
use App\Services\Mail\MailboxUserProvisioner;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\Mailbox;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class HostingMailboxService
{
public const EXTRA_MAILBOX_PRICE_CEDIS = 5;
public function __construct(
protected MailboxUserProvisioner $mailboxUsers,
) {}
/**
* @return array<string, mixed>
*/
public function usageSummary(HostingAccount $account): array
{
$account->loadMissing(['product', 'mailboxes']);
$freeAllowance = $account->freeEmailAllowance();
$mailboxCount = $account->mailboxes->count();
$paidCount = $account->mailboxes->where('is_paid', true)->count();
$freeUsed = $freeAllowance === null ? $mailboxCount : min($mailboxCount, $freeAllowance);
$extraCount = max($mailboxCount - ($freeAllowance ?? $mailboxCount), 0);
return [
'mailbox_count' => $mailboxCount,
'free_allowance' => $freeAllowance,
'free_used' => $freeUsed,
'paid_count' => $paidCount,
'extra_count' => $extraCount,
'extra_price' => self::EXTRA_MAILBOX_PRICE_CEDIS,
'extra_total' => round($extraCount * self::EXTRA_MAILBOX_PRICE_CEDIS, 2),
'breakdown' => sprintf(
'Extra Emails: %d x GHS %d = GHS %.2f',
$extraCount,
self::EXTRA_MAILBOX_PRICE_CEDIS,
round($extraCount * self::EXTRA_MAILBOX_PRICE_CEDIS, 2)
),
];
}
public function createMailbox(HostingAccount $account, array $input): array
{
$account->loadMissing(['product', 'mailboxes', 'sites']);
$domain = Domain::query()
->whereKey((int) ($input['domain_id'] ?? 0))
->where('hosting_account_id', $account->id)
->first();
if (! $domain) {
throw ValidationException::withMessages([
'domain_id' => 'Select a domain linked to this hosting account.',
]);
}
$localPart = strtolower(trim((string) ($input['local_part'] ?? '')));
$address = "{$localPart}@{$domain->host}";
if (Mailbox::query()->where('address', $address)->exists()) {
throw ValidationException::withMessages([
'local_part' => 'This mailbox address already exists.',
]);
}
$summary = $this->usageSummary($account);
$freeAllowance = $summary['free_allowance'];
$isPaid = $freeAllowance !== null && $summary['mailbox_count'] >= $freeAllowance;
$mailbox = Mailbox::query()->create([
'user_id' => $account->user_id,
'website_id' => null,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => (string) ($input['display_name'] ?? $localPart),
'local_part' => $localPart,
'address' => $address,
'password_hash' => Hash::make((string) $input['password']),
'quota_mb' => (int) ($input['quota_mb'] ?? Mailbox::defaultQuotaMb()),
'status' => 'provisioning',
'is_paid' => $isPaid,
'paid_at' => $isPaid ? now() : null,
'billing_type' => $isPaid ? 'subscription' : 'free',
'monthly_price' => $isPaid ? self::EXTRA_MAILBOX_PRICE_CEDIS : null,
'subscription_started_at' => $isPaid ? now() : null,
'subscription_expires_at' => $isPaid ? now()->addMonth() : null,
'subscription_status' => 'active',
'maildir_path' => sprintf('hosting/%d/%s/%s', $account->id, $domain->host, $localPart),
'credentials_issued_at' => now(),
'incoming_token' => Str::random(64),
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'metadata' => [
'provisioned_by' => 'hosting_account',
'hosting_account_id' => $account->id,
],
]);
$this->mailboxUsers->provisionMailboxSilently($mailbox, (string) $input['password']);
ProvisionMailboxJob::dispatch($mailbox->id, encrypt((string) $input['password']));
$invoice = $this->syncAddonInvoice($account->fresh(['mailboxes']));
return [
'mailbox' => $mailbox->fresh(['domain']),
'usage' => $this->usageSummary($account->fresh(['product', 'mailboxes'])),
'invoice' => $invoice,
];
}
public function deleteMailbox(HostingAccount $account, Mailbox $mailbox): ?HostingBillingInvoice
{
if ((int) $mailbox->hosting_account_id !== (int) $account->id) {
throw ValidationException::withMessages([
'mailbox_id' => 'Mailbox does not belong to this hosting account.',
]);
}
$mailbox->update(['status' => 'deleting']);
DeactivateMailboxJob::dispatch($mailbox->id);
return $this->syncAddonInvoice($account->fresh(['mailboxes']));
}
public function syncAddonInvoice(HostingAccount $account): ?HostingBillingInvoice
{
$account->loadMissing(['product', 'mailboxes']);
$summary = $this->usageSummary($account);
if ($summary['extra_count'] === 0) {
HostingBillingInvoice::query()
->where('hosting_account_id', $account->id)
->where('invoice_type', HostingBillingInvoice::TYPE_EMAIL_ADDON)
->where('status', HostingBillingInvoice::STATUS_PENDING)
->delete();
return null;
}
return HostingBillingInvoice::query()->updateOrCreate(
[
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_EMAIL_ADDON,
'status' => HostingBillingInvoice::STATUS_PENDING,
],
[
'user_id' => $account->user_id,
'currency' => 'GHS',
'subtotal_amount' => $summary['extra_total'],
'credit_amount' => 0,
'total_amount' => $summary['extra_total'],
'description' => sprintf('Extra mailbox charges for %s', $account->primary_domain ?: $account->username),
'line_items' => [[
'code' => 'extra_emails',
'label' => 'Extra Emails',
'quantity' => $summary['extra_count'],
'unit_amount' => self::EXTRA_MAILBOX_PRICE_CEDIS,
'amount' => $summary['extra_total'],
'description' => $summary['breakdown'],
]],
'metadata' => [
'billing_period' => now()->format('Y-m'),
'extra_mailboxes' => $summary['extra_count'],
],
]
);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use Illuminate\Support\Collection;
class HostingNodePoolService
{
public function poolRoot(HostingNode $node): HostingNode
{
return $node->isExtension() && $node->primary
? $node->primary
: $node;
}
/**
* @return Collection<int, HostingNode>
*/
public function poolMembers(HostingNode $node): Collection
{
$root = $this->poolRoot($node);
return HostingNode::query()
->where(function ($query) use ($root) {
$query->where('id', $root->id)
->orWhere('primary_hosting_node_id', $root->id);
})
->where('type', HostingNode::TYPE_SHARED)
->whereNotIn('status', [HostingNode::STATUS_DECOMMISSIONED])
->orderBy('role')
->get();
}
public function poolDiskGb(HostingNode $node): int
{
return (int) $this->poolMembers($node)->sum('disk_gb');
}
public function poolLogicalCapacityGb(HostingNode $node): float
{
return round($this->poolMembers($node)->sum(fn (HostingNode $m) => $m->logicalCapacityGb()), 2);
}
public function poolAllocatedDiskGb(HostingNode $node): int
{
$memberIds = $this->poolMembers($node)->pluck('id');
return (int) HostingAccount::query()
->whereIn('hosting_node_id', $memberIds)
->where('type', HostingAccount::TYPE_SHARED)
->sum('allocated_disk_gb');
}
public function poolUsedDiskGb(HostingNode $node): int
{
$memberIds = $this->poolMembers($node)->pluck('id');
$bytes = (int) HostingAccount::query()
->whereIn('hosting_node_id', $memberIds)
->where('type', HostingAccount::TYPE_SHARED)
->sum('disk_used_bytes');
return (int) ceil($bytes / 1073741824);
}
/**
* Member node with the most room for a new shared account.
*/
public function findProvisioningMember(HostingNode $poolRoot, int $requestedDiskGb = 0, ?string $segment = null): ?HostingNode
{
$members = $this->poolMembers($poolRoot)
->filter(fn (HostingNode $n) => $n->canProvision($requestedDiskGb, $segment));
if ($members->isEmpty()) {
return null;
}
return $members->sortBy(fn (HostingNode $n) => sprintf(
'%012.2f-%012d',
(float) $n->current_load_percent,
$n->used_disk_gb,
))->first();
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Services\Hosting;
use App\Jobs\ProvisionHostingOrderJob;
use App\Models\CustomerHostingOrder;
use Illuminate\Support\Facades\Log;
class HostingOrderFulfillmentService
{
/**
* Approve and queue provisioning. Upstream failures (e.g. Contabo low balance)
* are handled when {@see ProvisionHostingOrderJob} runs.
*
* @return array{fulfilled: bool, reasons: array<int, string>}
*/
public function attemptAutoFulfillment(CustomerHostingOrder $order): array
{
$order->refresh()->loadMissing('product');
if (! $this->isEligibleForAutoFulfillment($order)) {
return ['fulfilled' => false, 'reasons' => ['Order is not eligible for automatic fulfillment.']];
}
$this->approveAndQueue($order);
return ['fulfilled' => true, 'reasons' => []];
}
public function approveAndQueue(CustomerHostingOrder $order): void
{
if ($order->status === CustomerHostingOrder::STATUS_APPROVED) {
ProvisionHostingOrderJob::dispatch($order->fresh());
return;
}
$meta = $order->meta ?? [];
unset($meta['upstream_funds_hold'], $meta['upstream_funds_hold_at'], $meta['provisioning_hold']);
$order->update([
'status' => CustomerHostingOrder::STATUS_APPROVED,
'approved_at' => now(),
'approved_by' => null,
'meta' => $meta,
]);
ProvisionHostingOrderJob::dispatch($order->fresh());
Log::info('Hosting order queued for provisioning', [
'order_id' => $order->id,
'domain_name' => $order->domain_name,
]);
}
private function isEligibleForAutoFulfillment(CustomerHostingOrder $order): bool
{
return in_array($order->status, [
CustomerHostingOrder::STATUS_PENDING_APPROVAL,
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
], true) || (
$order->status === CustomerHostingOrder::STATUS_APPROVED
&& $order->provisioned_at === null
&& $order->hosting_account_id === null
&& $order->vps_instance_id === null
);
}
}
@@ -0,0 +1,322 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\HostingPlanChange;
use App\Models\HostingProduct;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class HostingPlanChangeService
{
public function __construct(
private NodeCapacityService $capacityService,
private HostingResourcePolicyService $resourcePolicyService,
private SharedNodeProvider $sharedNodeProvider,
) {
}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, HostingProduct>
*/
public function availablePlans(): \Illuminate\Database\Eloquent\Collection
{
return HostingProduct::query()
->active()
->visible()
->where('category', HostingProduct::CATEGORY_SHARED)
->ordered()
->get();
}
/**
* @return array<string, mixed>
*/
public function quote(HostingAccount $account, HostingProduct $targetProduct): array
{
$account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']);
$this->assertCanChange($account, $targetProduct);
$currentProduct = $account->product;
$order = $account->latestOrder;
$billingCycle = $order?->billing_cycle ?? CustomerHostingOrder::CYCLE_MONTHLY;
$cycleMonths = $order?->getCycleLengthInMonths() ?? 1;
$currentCyclePrice = $this->cyclePriceFor($currentProduct, $billingCycle);
$targetCyclePrice = $this->cyclePriceFor($targetProduct, $billingCycle);
$remaining = $this->remainingCycleState($account, $order, $cycleMonths);
$priceDelta = round($targetCyclePrice - $currentCyclePrice, 2);
$direction = $priceDelta >= 0
? HostingPlanChange::DIRECTION_UPGRADE
: HostingPlanChange::DIRECTION_DOWNGRADE;
$proratedDelta = round($priceDelta * $remaining['ratio'], 2);
$chargeAmount = $proratedDelta > 0 ? $proratedDelta : 0;
$creditAmount = $proratedDelta < 0 ? abs($proratedDelta) : 0;
return [
'direction' => $direction,
'billing_cycle' => $billingCycle,
'cycle_months' => $cycleMonths,
'remaining_days' => $remaining['days'],
'proration_ratio' => $remaining['ratio'],
'current_cycle_price' => $currentCyclePrice,
'target_cycle_price' => $targetCyclePrice,
'charge_amount' => $chargeAmount,
'credit_amount' => $creditAmount,
'net_amount' => round($chargeAmount - $creditAmount, 2),
'currency' => $targetProduct->display_currency,
'line_items' => $this->lineItems($account, $targetProduct, $direction, $chargeAmount, $creditAmount, $remaining['ratio']),
];
}
/**
* @return array{change: HostingPlanChange, invoice: HostingBillingInvoice, account: HostingAccount, quote: array<string, mixed>}
*/
public function apply(HostingAccount $account, HostingProduct $targetProduct, User $actor): array
{
$account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']);
$quote = $this->quote($account, $targetProduct);
$currentProduct = $account->product;
return DB::transaction(function () use ($account, $targetProduct, $actor, $quote, $currentProduct): array {
$change = HostingPlanChange::query()->create([
'user_id' => $actor->id,
'hosting_account_id' => $account->id,
'from_hosting_product_id' => $currentProduct->id,
'to_hosting_product_id' => $targetProduct->id,
'direction' => $quote['direction'],
'billing_cycle' => $quote['billing_cycle'],
'cycle_months' => $quote['cycle_months'],
'remaining_days' => $quote['remaining_days'],
'proration_ratio' => $quote['proration_ratio'],
'old_cycle_price' => $quote['current_cycle_price'],
'new_cycle_price' => $quote['target_cycle_price'],
'charge_amount' => $quote['charge_amount'],
'credit_amount' => $quote['credit_amount'],
'net_amount' => $quote['net_amount'],
'currency' => $quote['currency'],
'status' => 'applied',
'applied_at' => now(),
'metadata' => [
'actor_id' => $actor->id,
'sites_count' => $account->sites->count(),
'databases_count' => $account->databases->count(),
],
]);
$invoiceStatus = $quote['charge_amount'] > 0
? HostingBillingInvoice::STATUS_PENDING
: ($quote['credit_amount'] > 0
? HostingBillingInvoice::STATUS_CREDITED
: HostingBillingInvoice::STATUS_WAIVED);
$invoice = HostingBillingInvoice::query()->create([
'user_id' => $actor->id,
'hosting_account_id' => $account->id,
'hosting_plan_change_id' => $change->id,
'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE,
'status' => $invoiceStatus,
'currency' => $quote['currency'],
'subtotal_amount' => $quote['charge_amount'],
'credit_amount' => $quote['credit_amount'],
'total_amount' => max($quote['net_amount'], 0),
'description' => sprintf(
'%s %s from %s to %s',
ucfirst($quote['direction']),
$account->primary_domain ?: $account->username,
$currentProduct->name,
$targetProduct->name
),
'paid_at' => $invoiceStatus !== HostingBillingInvoice::STATUS_PENDING ? now() : null,
'line_items' => $quote['line_items'],
'metadata' => [
'direction' => $quote['direction'],
'billing_cycle' => $quote['billing_cycle'],
'remaining_days' => $quote['remaining_days'],
],
]);
$this->applyProductToAccount($account, $targetProduct, $change, $actor);
return [
'change' => $change->fresh(['fromProduct', 'toProduct']),
'invoice' => $invoice->fresh(),
'account' => $account->fresh(['product', 'node']),
'quote' => $quote,
];
});
}
private function assertCanChange(HostingAccount $account, HostingProduct $targetProduct): void
{
if (! $account->product || ! $account->product->isSharedHosting()) {
throw ValidationException::withMessages([
'hosting_account_id' => 'Only shared hosting accounts can change packages.',
]);
}
if (! $targetProduct->isSharedHosting() || ! $targetProduct->is_active || ! $targetProduct->is_visible) {
throw ValidationException::withMessages([
'target_product_id' => 'Selected plan is not available for hosting upgrades.',
]);
}
if ((int) $account->hosting_product_id === (int) $targetProduct->id) {
throw ValidationException::withMessages([
'target_product_id' => 'Account is already on that plan.',
]);
}
$siteLimit = (int) ($targetProduct->max_domains ?? 0);
if ($siteLimit >= 0 && $account->sites->count() > $siteLimit) {
throw ValidationException::withMessages([
'target_product_id' => "The target plan supports {$siteLimit} site(s), but this account already has {$account->sites->count()}.",
]);
}
$databaseLimit = $targetProduct->max_databases;
if ($databaseLimit !== null && $databaseLimit >= 0 && $account->databases->count() > $databaseLimit) {
throw ValidationException::withMessages([
'target_product_id' => "The target plan supports {$databaseLimit} database(s), but this account already has {$account->databases->count()}.",
]);
}
$diskDelta = max((int) ($targetProduct->disk_gb ?? 0) - (int) ($account->allocated_disk_gb ?? 0), 0);
if ($diskDelta > 0 && $account->node && ! $this->capacityService->canProvision($account->node, $diskDelta)) {
throw ValidationException::withMessages([
'target_product_id' => 'The assigned server does not have enough logical capacity for this upgrade.',
]);
}
}
private function cyclePriceFor(?HostingProduct $product, string $billingCycle): float
{
if (! $product) {
return 0;
}
$price = $product->getPriceForCycle($billingCycle);
if ($price === null) {
$price = $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY);
}
return round((float) $price, 2);
}
/**
* @return array{ratio: float, days: int}
*/
private function remainingCycleState(HostingAccount $account, ?CustomerHostingOrder $order, int $cycleMonths): array
{
$periodEnd = $account->expires_at
?? $order?->expires_at
?? now()->copy()->addMonthsNoOverflow($cycleMonths);
if (! $periodEnd || $periodEnd->lte(now())) {
return ['ratio' => 1.0, 'days' => 0];
}
$periodStart = $periodEnd->copy()->subMonthsNoOverflow(max($cycleMonths, 1));
$totalSeconds = max($periodStart->diffInSeconds($periodEnd, false), 1);
$remainingSeconds = max(now()->diffInSeconds($periodEnd, false), 0);
return [
'ratio' => round(min(max($remainingSeconds / $totalSeconds, 0), 1), 4),
'days' => (int) ceil($remainingSeconds / 86400),
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function lineItems(
HostingAccount $account,
HostingProduct $targetProduct,
string $direction,
float $chargeAmount,
float $creditAmount,
float $ratio
): array {
$currentProduct = $account->product;
return array_values(array_filter([
[
'code' => 'plan_change',
'label' => sprintf('%s to %s', ucfirst($direction), $targetProduct->name),
'description' => sprintf('Plan change from %s to %s.', $currentProduct?->name ?? 'Current plan', $targetProduct->name),
'amount' => round($chargeAmount, 2),
],
$creditAmount > 0 ? [
'code' => 'proration_credit',
'label' => 'Unused time credit',
'description' => sprintf('Prorated credit at %.2f%% of the current cycle.', $ratio * 100),
'amount' => round($creditAmount * -1, 2),
] : null,
]));
}
private function applyProductToAccount(HostingAccount $account, HostingProduct $targetProduct, HostingPlanChange $change, User $actor): void
{
$policyLimits = $this->resourcePolicyService->defaultLimitsForProduct($targetProduct);
$resourceLimits = array_merge((array) ($account->resource_limits ?? []), [
'disk_gb' => (int) ($targetProduct->disk_gb ?? 0),
'bandwidth_gb' => $targetProduct->bandwidth_gb,
'max_domains' => $targetProduct->max_domains,
'max_databases' => $targetProduct->max_databases,
'max_email_accounts' => $targetProduct->max_email_accounts,
'max_ftp_accounts' => $targetProduct->max_ftp_accounts,
], $policyLimits);
$metadata = array_merge((array) ($account->metadata ?? []), [
'last_plan_change_id' => $change->id,
'last_plan_change_at' => now()->toIso8601String(),
'last_plan_changed_by' => $actor->id,
]);
$account->update([
'hosting_product_id' => $targetProduct->id,
'allocated_disk_gb' => (int) ($targetProduct->disk_gb ?? 0),
'cpu_limit_percent' => $policyLimits['cpu_limit_percent'] ?? $account->cpu_limit_percent,
'memory_limit_mb' => $policyLimits['memory_limit_mb'] ?? $account->memory_limit_mb,
'process_limit' => $policyLimits['process_limit'] ?? $account->process_limit,
'io_limit_mb' => $policyLimits['io_limit_mb'] ?? $account->io_limit_mb,
'inode_limit' => $policyLimits['inode_limit'] ?? $account->inode_limit,
'resource_limits' => $resourceLimits,
'metadata' => $metadata,
]);
$account->refresh();
$activeOrder = CustomerHostingOrder::query()
->where('hosting_account_id', $account->id)
->whereIn('status', [
CustomerHostingOrder::STATUS_ACTIVE,
CustomerHostingOrder::STATUS_SUSPENDED,
CustomerHostingOrder::STATUS_PROVISIONING,
])
->latest('id')
->first();
if ($activeOrder) {
$activeOrder->update([
'hosting_product_id' => $targetProduct->id,
'currency' => $targetProduct->display_currency,
'meta' => array_merge((array) ($activeOrder->meta ?? []), [
'last_plan_change_id' => $change->id,
'last_plan_change_direction' => $change->direction,
]),
]);
}
$this->sharedNodeProvider->syncAccountPackage($account);
if ($account->node) {
$this->capacityService->refreshNodeResourceTotals($account->node);
}
}
}
@@ -0,0 +1,242 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingProduct;
use App\Services\ExchangeRateService;
use App\Services\Hosting\Providers\ContaboProvider;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class HostingPricingService
{
private array $margins;
private array $contaboBasePrices;
private ExchangeRateService $exchangeRateService;
private ContaboProvider $contaboProvider;
public function __construct(ExchangeRateService $exchangeRateService, ContaboProvider $contaboProvider)
{
$this->exchangeRateService = $exchangeRateService;
$this->contaboProvider = $contaboProvider;
$this->margins = config('hosting.pricing.margins', [
'vps' => 30,
'dedicated' => 30,
]);
$this->contaboBasePrices = config('hosting.pricing.contabo_base_prices', []);
}
public function getExchangeRate(): float
{
return $this->exchangeRateService->getUsdToGhs();
}
public function getMargin(string $category): float
{
return (float) ($this->margins[$category] ?? 30);
}
public function getContaboBasePrice(?string $productId): ?array
{
if ($productId === null) {
return null;
}
$productId = trim($productId);
if ($productId === '') {
return null;
}
$cacheKey = "contabo_product_price_{$productId}";
return Cache::remember($cacheKey, (int) config('hosting.pricing.contabo_price_cache_ttl', 3600), function () use ($productId) {
$liveProduct = $this->getLiveContaboProduct($productId);
if ($liveProduct !== null) {
return $liveProduct + ['source' => 'contabo_api'];
}
$fallback = $this->contaboBasePrices[$productId] ?? null;
return is_array($fallback) ? $fallback + ['source' => 'local_fallback'] : null;
});
}
public function calculatePrice(float $basePriceUsd, string $category): float
{
$margin = $this->getMargin($category);
$exchangeRate = $this->getExchangeRate();
$convertedPrice = $basePriceUsd * $exchangeRate;
$priceWithMargin = $convertedPrice * (1 + ($margin / 100));
// Round to nearest 5 for cleaner pricing
return round($priceWithMargin / 5) * 5;
}
public function calculateQuarterlyPrice(float $monthlyPrice): float
{
return $this->calculateTermPrice($monthlyPrice, 3, 'quarterly');
}
public function calculateSemiannualPrice(float $monthlyPrice): float
{
return $this->calculateTermPrice($monthlyPrice, 6, 'semiannual');
}
public function calculateYearlyPrice(float $monthlyPrice): float
{
return $this->calculateTermPrice($monthlyPrice, 12, 'yearly');
}
public function convertUsdRecurringOption(float $monthlyUsd, string $category): float
{
$margin = $this->getMargin($category);
$exchangeRate = $this->getExchangeRate();
return round($monthlyUsd * $exchangeRate * (1 + ($margin / 100)), 2);
}
private function calculateTermPrice(float $monthlyPrice, int $months, string $cycle): float
{
$discountPercent = (float) config("hosting.pricing.term_discounts.{$cycle}", 0);
$total = $monthlyPrice * $months * (1 - ($discountPercent / 100));
return round($total / 5) * 5;
}
public function getDynamicPriceForProduct(HostingProduct $product): array
{
// Only calculate dynamic pricing for VPS and Dedicated
if (!in_array($product->category, ['vps', 'dedicated'])) {
return [
'monthly' => (float) $product->price_monthly,
'quarterly' => (float) $product->price_quarterly,
'semiannual' => null,
'yearly' => (float) $product->price_yearly,
'currency' => $product->display_currency,
'is_dynamic' => false,
];
}
$exchangeRate = $this->getExchangeRate();
$cacheKey = "hosting_price_{$product->slug}_{$product->contabo_product_id}_{$exchangeRate}";
return Cache::remember($cacheKey, 3600, function () use ($product, $exchangeRate) {
$contaboProduct = $this->getContaboBasePrice($product->contabo_product_id);
if (!$contaboProduct) {
// Fallback to stored prices if no Contabo mapping
return [
'monthly' => (float) $product->price_monthly,
'quarterly' => (float) $product->price_quarterly,
'semiannual' => null,
'yearly' => (float) $product->price_yearly,
'currency' => $product->display_currency,
'is_dynamic' => false,
];
}
$basePriceUsd = $contaboProduct['monthly'];
$monthlyPrice = $this->calculatePrice($basePriceUsd, $product->category);
return [
'monthly' => $monthlyPrice,
'quarterly' => $this->calculateQuarterlyPrice($monthlyPrice),
'semiannual' => $this->calculateSemiannualPrice($monthlyPrice),
'yearly' => $this->calculateYearlyPrice($monthlyPrice),
'currency' => 'GHS',
'is_dynamic' => true,
'base_price_usd' => $basePriceUsd,
'exchange_rate' => $exchangeRate,
'margin_percent' => $this->getMargin($product->category),
];
});
}
public function getPricingBreakdown(HostingProduct $product): array
{
if (!in_array($product->category, ['vps', 'dedicated'])) {
return [];
}
$contaboProduct = $this->getContaboBasePrice($product->contabo_product_id);
if (!$contaboProduct) {
return [];
}
$basePriceUsd = $contaboProduct['monthly'];
$margin = $this->getMargin($product->category);
$exchangeRate = $this->getExchangeRate();
$convertedPrice = $basePriceUsd * $exchangeRate;
$marginAmount = $convertedPrice * ($margin / 100);
$finalPrice = $this->calculatePrice($basePriceUsd, $product->category);
return [
'contabo_price_usd' => $basePriceUsd,
'price_source' => (string) ($contaboProduct['source'] ?? 'unknown'),
'exchange_rate' => $exchangeRate,
'converted_ghs' => round($convertedPrice, 2),
'margin_percent' => $margin,
'margin_amount_ghs' => round($marginAmount, 2),
'final_price_ghs' => $finalPrice,
'profit_per_month_ghs' => round($finalPrice - $convertedPrice, 2),
];
}
public function getExchangeRateInfo(): array
{
return $this->exchangeRateService->getRateInfo();
}
public function refreshExchangeRate(): float
{
return $this->exchangeRateService->refreshRate();
}
private function getLiveContaboProduct(string $productId): ?array
{
try {
foreach ($this->contaboProvider->getAvailableProducts() as $product) {
if ((string) ($product['id'] ?? '') !== $productId) {
continue;
}
$monthlyUsd = $this->extractMonthlyUsdPrice($product);
if ($monthlyUsd === null || $monthlyUsd <= 0) {
return null;
}
return [
'monthly' => $monthlyUsd,
'name' => (string) ($product['name'] ?? $productId),
'cpu' => isset($product['cpu']) ? (int) $product['cpu'] : null,
'ram_gb' => isset($product['ram_gb']) ? (float) $product['ram_gb'] : null,
'disk_gb' => isset($product['disk_gb']) ? (float) $product['disk_gb'] : null,
];
}
} catch (\Throwable $exception) {
Log::warning('Contabo product price lookup failed; falling back to configured prices.', [
'product_id' => $productId,
'message' => $exception->getMessage(),
]);
}
return null;
}
private function extractMonthlyUsdPrice(array $product): ?float
{
foreach (['price_monthly', 'monthly', 'monthly_usd', 'monthlyUsd', 'monthlyPrice', 'price'] as $key) {
if (! isset($product[$key]) || ! is_numeric($product[$key])) {
continue;
}
return (float) $product[$key];
}
return null;
}
}
@@ -0,0 +1,390 @@
<?php
namespace App\Services\Hosting;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostingPlan;
use App\Models\HostedSite;
use App\Models\ProvisioningJob;
use App\Models\User;
use App\Models\VpsInstance;
use App\Services\Hosting\Providers\ContaboProvider;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class HostingProvisioningService
{
public function __construct(
private ContaboProvider $contaboProvider,
private SharedNodeProvider $sharedNodeProvider,
private NodeCapacityService $nodeCapacityService,
private HostingResourcePolicyService $resourcePolicyService,
) {}
public function createSharedHostingAccount(User $user, HostingPlan $plan, string $domain): HostingAccount
{
return DB::transaction(function () use ($user, $plan, $domain) {
// Find available node
$node = $this->nodeCapacityService->findAvailableNode((int) $plan->disk_gb);
$resourceLimits = $this->resourcePolicyService->defaultLimitsForProduct(null);
if (!$node) {
throw new \RuntimeException('No available hosting nodes for shared hosting');
}
// Generate unique username
$username = $this->generateUsername($user, $domain);
// Create account record
$account = HostingAccount::create([
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'hosting_node_id' => $node->id,
'username' => $username,
'primary_domain' => $domain,
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_PROVISIONING,
'allocated_disk_gb' => (int) $plan->disk_gb,
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'php_version' => $plan->php_versions[0] ?? '8.2',
'resource_limits' => [
'disk_gb' => $plan->disk_gb,
'bandwidth_gb' => $plan->bandwidth_gb,
'max_domains' => $plan->max_domains,
'max_databases' => $plan->max_databases,
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
],
]);
// Create provisioning job
$job = ProvisioningJob::create([
'type' => 'create_shared_account',
'provisionable_type' => HostingAccount::class,
'provisionable_id' => $account->id,
'provider' => 'shared_node',
'status' => ProvisioningJob::STATUS_PENDING,
'payload' => [
'username' => $username,
'domain' => $domain,
'node_id' => $node->id,
],
]);
// Dispatch async provisioning
dispatch(function () use ($account, $job, $node, $domain) {
$this->provisionSharedAccount($account, $job, $node, $domain);
})->afterCommit();
return $account;
});
}
public function createVpsInstance(User $user, HostingPlan $plan, array $config): VpsInstance
{
return DB::transaction(function () use ($user, $plan, $config) {
$hostname = $config['hostname'] ?? 'vps-' . Str::random(8) . '.ladill.com';
$instance = VpsInstance::create([
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'name' => $config['name'] ?? $hostname,
'hostname' => $hostname,
'provider' => 'contabo',
'region' => $config['region'] ?? 'EU',
'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'cpu_cores' => $plan->cpu_cores,
'ram_mb' => $plan->ram_mb,
'disk_gb' => $plan->disk_gb,
'status' => 'provisioning',
'ssh_public_key' => $config['ssh_public_key'] ?? null,
'tags' => $config['tags'] ?? [],
]);
$job = ProvisioningJob::create([
'type' => 'create_vps_instance',
'provisionable_type' => VpsInstance::class,
'provisionable_id' => $instance->id,
'provider' => 'contabo',
'status' => ProvisioningJob::STATUS_PENDING,
'payload' => [
'product_id' => $plan->contabo_product_id,
'region' => $config['region'] ?? 'EU',
'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'ssh_keys' => $config['ssh_keys'] ?? [],
'user_data' => $config['user_data'] ?? null,
],
]);
dispatch(function () use ($instance, $job, $config, $plan) {
$this->provisionVpsInstance($instance, $job, $config, $plan);
})->afterCommit();
return $instance;
});
}
public function addSiteToAccount(HostingAccount $account, string $domain, string $type = 'addon'): HostedSite
{
$docRoot = "/home/{$account->username}/public_html/{$domain}";
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => $domain,
'document_root' => $docRoot,
'type' => $type,
'status' => 'pending',
'php_version' => $account->php_version,
]);
try {
$this->sharedNodeProvider->addSite($site);
$site->update(['status' => 'active']);
// Sync to Domain registry
$this->syncToDomainRegistry($account, $domain, $site);
} catch (\Exception $e) {
$site->update(['status' => 'disabled']);
throw $e;
}
return $site;
}
/**
* Sync hosting site to unified Domain registry
*/
private function syncToDomainRegistry(HostingAccount $account, string $host, HostedSite $site): void
{
try {
$domain = Domain::where('host', $host)->first();
if ($domain) {
// Update existing domain to add hosting
$domain->update([
'hosting_account_id' => $account->id,
'source' => $domain->email_domain_id ? 'hosting_email' : 'hosting',
'onboarding_state' => $domain->onboarding_state === 'pending_ns' ? null : $domain->onboarding_state,
]);
} else {
// Create new domain entry
Domain::create([
'user_id' => $account->user_id,
'host' => $host,
'hosting_account_id' => $account->id,
'source' => 'hosting',
'type' => 'custom',
'status' => 'verified',
'onboarding_state' => null,
'verified_at' => now(),
]);
}
Log::info('HostingProvisioningService: Synced to Domain registry', [
'domain' => $host,
'site_id' => $site->id,
]);
} catch (\Throwable $e) {
Log::warning('HostingProvisioningService: Failed to sync to Domain registry', [
'domain' => $host,
'error' => $e->getMessage(),
]);
}
}
public function suspendAccount(HostingAccount $account, string $reason): bool
{
$result = $this->sharedNodeProvider->suspendAccount($account, $reason);
if ($result) {
$account->update([
'status' => HostingAccount::STATUS_SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
]);
}
return $result;
}
public function unsuspendAccount(HostingAccount $account): bool
{
$result = $this->sharedNodeProvider->unsuspendAccount($account);
if ($result) {
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'suspension_reason' => null,
'suspended_at' => null,
]);
}
return $result;
}
public function terminateAccount(HostingAccount $account): bool
{
$result = $this->sharedNodeProvider->terminateAccount($account);
if ($result) {
$account->node?->decrementAccountCount();
$account->update(['status' => HostingAccount::STATUS_TERMINATED]);
$account->delete();
}
return $result;
}
public function controlVps(VpsInstance $instance, string $action): bool
{
return match ($action) {
'start' => $this->contaboProvider->startInstance($instance->provider_instance_id),
'stop' => $this->contaboProvider->stopInstance($instance->provider_instance_id),
'reboot' => $this->contaboProvider->rebootInstance($instance->provider_instance_id),
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
};
}
public function terminateVps(VpsInstance $instance): bool
{
if ($instance->provider_instance_id) {
$this->contaboProvider->deleteInstance($instance->provider_instance_id);
}
$instance->update(['status' => 'terminated']);
$instance->delete();
return true;
}
private function provisionSharedAccount(HostingAccount $account, ProvisioningJob $job, HostingNode $node, string $domain): void
{
$job->markRunning();
try {
$result = $this->sharedNodeProvider->createAccount($account);
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'home_directory' => $result['home_directory'],
'provisioned_at' => now(),
]);
// Create primary site
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => $domain,
'document_root' => $result['document_root'],
'type' => 'primary',
'status' => 'pending',
'php_version' => $account->php_version,
]);
$this->sharedNodeProvider->addSite($site);
$site->update(['status' => 'active']);
// Sync to Domain registry
$this->syncToDomainRegistry($account, $domain, $site);
$node->incrementAccountCount();
$job->markCompleted([
'username' => $result['username'],
'home_directory' => $result['home_directory'],
'initial_password' => $result['password'],
]);
Log::info("Shared hosting account provisioned", [
'account_id' => $account->id,
'username' => $account->username,
]);
} catch (\Exception $e) {
Log::error("Shared hosting provisioning failed", [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);
$account->update(['status' => HostingAccount::STATUS_FAILED]);
$job->markFailed($e->getMessage());
}
}
private function provisionVpsInstance(VpsInstance $instance, ProvisioningJob $job, array $config, HostingPlan $plan): void
{
$job->markRunning();
try {
$rootPassword = Str::password(16);
$result = $this->contaboProvider->createInstance([
'product_id' => $plan->contabo_product_id,
'name' => $instance->name,
'region' => $config['region'] ?? 'EU',
'image_id' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'root_password' => $rootPassword,
'ssh_keys' => $config['ssh_keys'] ?? [],
'user_data' => $config['user_data'] ?? null,
]);
$instance->update([
'provider_instance_id' => $result['instance_id'],
'ip_address' => $result['ip_address'],
'ipv6_address' => $result['ipv6_address'],
'datacenter' => $result['datacenter'],
'status' => 'running',
'power_status' => 'on',
'root_password_encrypted' => encrypt($rootPassword),
'provisioned_at' => now(),
]);
$job->markCompleted([
'instance_id' => $result['instance_id'],
'ip_address' => $result['ip_address'],
'root_password' => $rootPassword,
]);
Log::info("VPS instance provisioned", [
'instance_id' => $instance->id,
'provider_id' => $result['instance_id'],
]);
} catch (\Exception $e) {
Log::error("VPS provisioning failed", [
'instance_id' => $instance->id,
'error' => $e->getMessage(),
]);
$instance->update(['status' => 'failed']);
$job->markFailed($e->getMessage());
}
}
private function generateUsername(User $user, string $domain): string
{
$base = preg_replace('/[^a-z0-9]/', '', strtolower(explode('.', $domain)[0]));
$base = substr($base, 0, 8) ?: 'user';
$username = $base;
$counter = 1;
while (HostingAccount::where('username', $username)->exists()) {
$username = $base . $counter;
$counter++;
}
return $username;
}
}
@@ -0,0 +1,242 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\User;
use App\Services\Billing\PaystackService;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class HostingRenewalCheckoutService
{
public function __construct(
private PaystackService $paystack,
) {
}
/**
* @return array{months:int, amount:float, currency:string, billing_cycle:string, amount_minor:int}
*/
public function renewalQuote(HostingAccount $account, ?int $durationMonths = null): array
{
$account->loadMissing(['product', 'latestOrder']);
if (! $account->canBeRenewed()) {
throw ValidationException::withMessages([
'hosting_account_id' => 'This hosting account does not have a configured renewal duration.',
]);
}
$months = $durationMonths ?? $account->assignedDurationMonths();
$billingCycle = $this->billingCycleForMonths($months);
$amount = $this->renewalAmount($account, $months, $billingCycle);
return [
'months' => $months,
'amount' => round($amount, 2),
'currency' => $account->product?->display_currency ?? 'GHS',
'billing_cycle' => $billingCycle,
'amount_minor' => (int) round($amount * 100),
];
}
/**
* @return array<int, array{months:int, amount:float, currency:string, billing_cycle:string, label:string, current:bool}>
*/
public function availableRenewalOptions(HostingAccount $account): array
{
$account->loadMissing(['product']);
if (! $account->canBeRenewed() || ! $account->product) {
return [];
}
$currency = $account->product->display_currency ?? 'GHS';
$currentMonths = $account->assignedDurationMonths();
$cycles = [
['months' => 1, 'cycle' => CustomerHostingOrder::CYCLE_MONTHLY, 'label' => '1 Month'],
['months' => 3, 'cycle' => CustomerHostingOrder::CYCLE_QUARTERLY, 'label' => '3 Months'],
['months' => 12, 'cycle' => CustomerHostingOrder::CYCLE_YEARLY, 'label' => '1 Year'],
['months' => 24, 'cycle' => CustomerHostingOrder::CYCLE_BIENNIAL, 'label' => '2 Years'],
];
$options = [];
foreach ($cycles as $cycle) {
$amount = $this->renewalAmount($account, $cycle['months'], $cycle['cycle']);
if ($amount <= 0) {
continue;
}
$options[] = [
'months' => $cycle['months'],
'amount' => round($amount, 2),
'currency' => $currency,
'billing_cycle' => $cycle['cycle'],
'label' => $cycle['label'],
'current' => $cycle['months'] === $currentMonths,
];
}
return $options;
}
/**
* @return array{authorization_url:string, invoice:HostingBillingInvoice, quote:array{months:int, amount:float, currency:string, billing_cycle:string, amount_minor:int}}
*/
public function beginCheckout(HostingAccount $account, User $user, string $callbackUrl, ?int $durationMonths = null): array
{
$quote = $this->renewalQuote($account, $durationMonths);
$reference = 'HOST-REN-' . strtoupper(Str::random(16));
$invoice = HostingBillingInvoice::query()->create([
'user_id' => $user->id,
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL,
'status' => HostingBillingInvoice::STATUS_PENDING,
'currency' => $quote['currency'],
'subtotal_amount' => $quote['amount'],
'credit_amount' => 0,
'total_amount' => $quote['amount'],
'description' => sprintf(
'Hosting renewal for %s (%d month%s)',
$account->primary_domain ?: $account->username,
$quote['months'],
$quote['months'] === 1 ? '' : 's'
),
'provider_reference' => $reference,
'line_items' => [[
'code' => 'renewal',
'label' => 'Hosting Renewal',
'quantity' => 1,
'amount' => $quote['amount'],
'description' => sprintf('%d month renewal for %s', $quote['months'], $account->product?->name ?? 'Hosting plan'),
]],
'metadata' => [
'months' => $quote['months'],
'billing_cycle' => $quote['billing_cycle'],
],
]);
$transaction = $this->paystack->initializeTransaction([
'email' => (string) $user->email,
'amount' => $quote['amount_minor'],
'currency' => $quote['currency'],
'reference' => $reference,
'callback_url' => $callbackUrl,
'metadata' => [
'context' => 'hosting_renewal',
'hosting_account_id' => $account->id,
'invoice_id' => $invoice->id,
'user_id' => $user->id,
'months' => $quote['months'],
],
]);
$authorizationUrl = (string) ($transaction['authorization_url'] ?? '');
if ($authorizationUrl === '') {
throw ValidationException::withMessages([
'payment' => 'Paystack did not return a checkout URL for the renewal.',
]);
}
return [
'authorization_url' => $authorizationUrl,
'invoice' => $invoice,
'quote' => $quote,
];
}
public function completeCheckout(HostingAccount $account, User $user, string $reference): HostingBillingInvoice
{
$account->loadMissing(['latestOrder']);
$invoice = HostingBillingInvoice::query()
->where('hosting_account_id', $account->id)
->where('user_id', $user->id)
->where('invoice_type', HostingBillingInvoice::TYPE_RENEWAL)
->where('provider_reference', $reference)
->latest('id')
->first();
if (! $invoice) {
throw ValidationException::withMessages([
'reference' => 'Renewal invoice could not be found for this payment reference.',
]);
}
if ($invoice->status === HostingBillingInvoice::STATUS_PAID) {
return $invoice;
}
$transaction = $this->paystack->verifyTransaction($reference);
$status = strtolower((string) ($transaction['status'] ?? ''));
if ($status !== 'success') {
throw ValidationException::withMessages([
'payment' => 'Renewal payment was not successful.',
]);
}
$months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0);
if ($months < 1) {
throw ValidationException::withMessages([
'payment' => 'Renewal duration is invalid for this hosting account.',
]);
}
$paidAt = now();
$account->renew($months, [
'renewed_by_user' => $user->id,
'renewed_at' => $paidAt->toIso8601String(),
'renewal_invoice_id' => $invoice->id,
]);
if ($account->latestOrder) {
$account->latestOrder->update([
'expires_at' => $account->fresh()->expires_at,
'meta' => array_merge((array) ($account->latestOrder->meta ?? []), [
'renewal_invoice_id' => $invoice->id,
'renewed_at' => $paidAt->toIso8601String(),
]),
]);
}
$invoice->update([
'status' => HostingBillingInvoice::STATUS_PAID,
'paid_at' => $paidAt,
]);
return $invoice->fresh();
}
private function billingCycleForMonths(int $months): string
{
return match ($months) {
1 => CustomerHostingOrder::CYCLE_MONTHLY,
3 => CustomerHostingOrder::CYCLE_QUARTERLY,
12 => CustomerHostingOrder::CYCLE_YEARLY,
24 => CustomerHostingOrder::CYCLE_BIENNIAL,
default => CustomerHostingOrder::CYCLE_MONTHLY,
};
}
private function renewalAmount(HostingAccount $account, int $months, string $billingCycle): float
{
$price = $account->product?->getPriceForCycle($billingCycle);
if ($price !== null && in_array($months, [1, 3, 12, 24], true)) {
return (float) $price;
}
$monthly = (float) ($account->product?->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY) ?? 0);
return $monthly * $months;
}
}
@@ -0,0 +1,642 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingAccountAlert;
use App\Models\HostingProduct;
use App\Notifications\HostingResourceWarningNotification;
use App\Services\Hosting\Providers\SharedNodeProvider;
class HostingResourcePolicyService
{
public const DISK_WARNING_THRESHOLD = 90;
public const INODE_WARNING_THRESHOLD = 90;
private const AUTOMATIC_SUSPENSION_GRACE_HOURS = 24;
private const AUTOMATIC_SUSPENSION_MIN_BREACH_STREAK = 12;
public function __construct(
private SharedNodeProvider $sharedNodeProvider
) {
}
public function defaultLimitsForProduct(?HostingProduct $product): array
{
$defaults = match ($product?->type) {
HostingProduct::TYPE_WORDPRESS => [
'cpu_limit_percent' => 113,
'memory_limit_mb' => 1152,
'process_limit' => 23,
'io_limit_mb' => 12,
'inode_limit' => 450000,
],
HostingProduct::TYPE_MULTI_DOMAIN => [
'cpu_limit_percent' => 150,
'memory_limit_mb' => 1536,
'process_limit' => 30,
'io_limit_mb' => 15,
'inode_limit' => 525000,
],
default => [
'cpu_limit_percent' => 75,
'memory_limit_mb' => 768,
'process_limit' => 15,
'io_limit_mb' => 8,
'inode_limit' => 375000,
],
};
$defaults = array_merge($defaults, $this->productSpecificLimits($product));
$overrides = (array) data_get($product?->features, 'resource_limits', []);
return array_merge($defaults, array_filter($overrides, fn ($value) => $value !== null));
}
private function productSpecificLimits(?HostingProduct $product): array
{
return match ($product?->slug) {
'starter-plan' => [
'inode_limit' => 225000,
],
'basic-plan' => [
'inode_limit' => 450000,
],
'plus-plan' => [
'inode_limit' => 450000,
],
'growth-plan' => [
'inode_limit' => 750000,
],
'pro-plan' => [
'inode_limit' => 1200000,
],
default => [],
};
}
public function applyDefaultLimits(HostingAccount $account, bool $persist = true): array
{
$defaults = $this->defaultLimitsForProduct($account->product);
$resourceLimits = (array) ($account->resource_limits ?? []);
$updates = [];
foreach ($defaults as $column => $value) {
if ($account->{$column} === null) {
$updates[$column] = $value;
}
$resourceLimits[$column] = $account->{$column} ?? $value;
}
if (! isset($resourceLimits['disk_gb'])) {
$resourceLimits['disk_gb'] = $account->requestedDiskGb();
}
if ($persist) {
$updates['resource_limits'] = $resourceLimits;
$updates['resource_status'] = $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE;
if ($updates !== []) {
$account->update($updates);
$account->refresh();
}
} else {
$account->forceFill(array_merge($updates, [
'resource_limits' => $resourceLimits,
'resource_status' => $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE,
]));
}
return $resourceLimits;
}
public function summarize(HostingAccount $account): array
{
$this->applyDefaultLimits($account);
return [
'resource_status' => $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE,
'uploads_restricted' => $account->uploadsAreRestricted(),
'disk_usage_percent' => $account->diskUsagePercent(),
'inode_usage_percent' => $account->inodeUsagePercent(),
'cpu_usage_percent' => (float) ($account->cpu_usage_percent ?? 0),
'memory_used_mb' => (int) ($account->memory_used_mb ?? 0),
'process_count' => (int) ($account->process_count ?? 0),
'io_usage_mb' => $account->io_usage_mb !== null ? (float) $account->io_usage_mb : null,
'cpu_limit_percent' => (int) ($account->cpu_limit_percent ?? 0),
'memory_limit_mb' => (int) ($account->memory_limit_mb ?? 0),
'process_limit' => (int) ($account->process_limit ?? 0),
'io_limit_mb' => (int) ($account->io_limit_mb ?? 0),
'inode_limit' => (int) ($account->inode_limit ?? 0),
'warning_count' => (int) ($account->warning_count ?? 0),
'is_flagged' => (bool) ($account->is_flagged ?? false),
'last_usage_sync_at' => $account->last_usage_sync_at,
'last_warning_at' => $account->last_warning_at,
'throttled_at' => $account->throttled_at,
];
}
public function process(HostingAccount $account): array
{
$account->loadMissing(['product', 'user', 'latestOrder']);
$this->applyDefaultLimits($account);
$evaluation = $this->evaluate($account);
$breaches = $evaluation['breaches'];
$warnings = $evaluation['warnings'];
$warningMessages = $evaluation['warning_messages'];
$primaryReason = $evaluation['primary_reason'];
$this->updateBreachCounters($account, $breaches);
$account->forceFill([
'uploads_restricted' => array_key_exists('inode', $breaches) || $account->inode_count >= (int) $account->inode_limit,
'is_flagged' => $warnings !== [] || $breaches !== [],
'metadata' => $account->metadata, // Save CPU history from evaluation
]);
if ($warnings !== [] || $breaches !== []) {
$account->forceFill([
'warning_count' => (int) $account->warning_count + 1,
'last_warning_at' => now(),
'last_resource_breach_at' => now(),
]);
}
$account->save();
$account->refresh();
foreach ($warningMessages as $type => $message) {
$severity = array_key_exists($type, $breaches) ? 'critical' : 'warning';
$alertType = array_key_exists($type, $breaches)
? HostingAccountAlert::TYPE_LIMIT_EXCEEDED
: HostingAccountAlert::TYPE_WARNING;
$this->recordAlert($account, $alertType, $severity, $message, $evaluation['usage']);
}
if ($breaches !== []) {
if ($account->isThrottled()) {
if ($this->shouldAutomaticallySuspendForSustainedBreach($account, $breaches)) {
$this->suspendAccount($account, $primaryReason, false);
}
} else {
$this->throttleAccount($account, $primaryReason, false);
}
} elseif ($warnings !== []) {
$this->notifyAccount($account, 'Hosting usage warning', implode(' ', array_values($warningMessages)), true);
} else {
$this->clearWarningState($account);
}
if ($breaches === []) {
if ($this->shouldAutomaticallyUnsuspend($account)) {
$this->unsuspendAccount($account, 'Usage returned within allowed thresholds.');
} elseif ($account->isThrottled() && $account->status === HostingAccount::STATUS_ACTIVE) {
$this->restoreAccount($account, 'Usage returned within allowed thresholds.', false);
}
}
return $evaluation;
}
public function throttleAccount(HostingAccount $account, string $reason, bool $manual = true): void
{
$account->loadMissing(['user', 'latestOrder']);
if ($account->status === HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->applyAccountResourceProfile($account, true);
$metadata = array_merge((array) ($account->metadata ?? []), [
'throttle_reason' => $reason,
'throttle_origin' => $manual ? 'manual' : 'automatic',
'throttled_at' => now()->toIso8601String(),
]);
$account->update([
'resource_status' => HostingAccount::RESOURCE_STATUS_THROTTLED,
'throttled_at' => now(),
'metadata' => $metadata,
'uploads_restricted' => $account->uploadsAreRestricted(),
]);
$this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_ACTIVE, $reason);
$this->recordAlert($account, HostingAccountAlert::TYPE_THROTTLED, 'critical', $reason, $this->usageSnapshot($account));
$this->notifyAccount($account, 'Hosting account throttled', $reason, false);
}
public function restoreAccount(HostingAccount $account, ?string $reason = null, bool $manual = true): void
{
$account->loadMissing(['user', 'latestOrder']);
if ($account->status === HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->applyAccountResourceProfile($account, false);
$metadata = (array) ($account->metadata ?? []);
$metadata['restored_at'] = now()->toIso8601String();
$metadata['restore_origin'] = $manual ? 'manual' : 'automatic';
// Clear CPU history on restore
unset($metadata['cpu_history'], $metadata['cpu_averages']);
$account->update([
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'uploads_restricted' => false,
'is_flagged' => false,
'cpu_breach_streak' => 0,
'memory_breach_streak' => 0,
'process_breach_streak' => 0,
'io_breach_streak' => 0,
'inode_breach_streak' => 0,
'throttled_at' => null,
'metadata' => $metadata,
]);
$this->resolveAlerts($account);
if ($reason) {
$this->notifyAccount($account, 'Hosting account restored', $reason, false);
}
}
public function suspendAccount(HostingAccount $account, string $reason, bool $manual = true): void
{
$account->loadMissing(['sites', 'user', 'latestOrder']);
if ($account->status === HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->suspendAccount($account, $reason);
$metadata = array_merge((array) ($account->metadata ?? []), [
'suspension_reason' => $reason,
'suspension_origin' => $manual ? 'manual' : 'automatic',
'suspended_at' => now()->toIso8601String(),
]);
$account->update([
'status' => HostingAccount::STATUS_SUSPENDED,
'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
'is_flagged' => true,
'metadata' => $metadata,
]);
$this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_SUSPENDED, $reason);
$this->recordAlert($account, HostingAccountAlert::TYPE_SUSPENDED, 'critical', $reason, $this->usageSnapshot($account));
}
public function unsuspendAccount(HostingAccount $account, ?string $reason = null): void
{
$account->loadMissing(['sites', 'user', 'latestOrder']);
if ($account->status !== HostingAccount::STATUS_SUSPENDED) {
return;
}
$this->sharedNodeProvider->unsuspendAccount($account);
$this->sharedNodeProvider->applyAccountResourceProfile($account, false);
$metadata = (array) ($account->metadata ?? []);
$metadata['unsuspended_at'] = now()->toIso8601String();
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'suspension_reason' => null,
'suspended_at' => null,
'uploads_restricted' => false,
'metadata' => $metadata,
]);
$this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_ACTIVE, $reason);
$this->resolveAlerts($account);
}
private function evaluate(HostingAccount $account): array
{
$usage = $this->usageSnapshot($account);
$warnings = [];
$breaches = [];
$messages = [];
if ($usage['disk_usage_percent'] >= self::DISK_WARNING_THRESHOLD) {
$warnings['disk'] = true;
$messages['disk'] = sprintf(
'Disk usage is at %.1f%% of the allocated quota.',
$usage['disk_usage_percent']
);
}
if ($usage['inode_usage_percent'] >= self::INODE_WARNING_THRESHOLD) {
$warnings['inode'] = true;
$messages['inode'] = sprintf(
'Inode usage is at %.1f%% of the allowed limit.',
$usage['inode_usage_percent']
);
}
// CPU: Use time-based sustained usage, not instant spikes
if ($account->cpu_limit_percent) {
$cpuEvaluation = $this->evaluateCpuUsage($account, $usage['cpu_usage_percent'], $account->cpu_limit_percent);
if ($cpuEvaluation['is_breach']) {
$breaches['cpu'] = true;
$messages['cpu'] = $cpuEvaluation['message'];
} elseif ($cpuEvaluation['is_warning']) {
$warnings['cpu'] = true;
$messages['cpu'] = $cpuEvaluation['message'];
}
}
if ($account->memory_limit_mb && $usage['memory_used_mb'] > $account->memory_limit_mb) {
$breaches['memory'] = true;
$messages['memory'] = sprintf(
'Memory usage reached %d MB against a %d MB limit.',
$usage['memory_used_mb'],
$account->memory_limit_mb
);
}
if ($account->process_limit && $usage['process_count'] > $account->process_limit) {
$breaches['process'] = true;
$messages['process'] = sprintf(
'Process count reached %d against a %d process limit.',
$usage['process_count'],
$account->process_limit
);
}
if ($account->io_limit_mb && $usage['io_usage_mb'] !== null && $usage['io_usage_mb'] > $account->io_limit_mb) {
$breaches['io'] = true;
$messages['io'] = sprintf(
'I/O usage reached %.1f MB against a %d MB limit.',
$usage['io_usage_mb'],
$account->io_limit_mb
);
}
if ($account->inode_limit && $usage['inode_count'] > $account->inode_limit) {
$breaches['inode'] = true;
$messages['inode_limit'] = sprintf(
'Inode count reached %d against a %d inode limit.',
$usage['inode_count'],
$account->inode_limit
);
}
$primaryReason = $messages[array_key_first($breaches)] ?? implode(' ', array_values($messages)) ?: 'Resource policy triggered.';
return [
'usage' => $usage,
'warnings' => $warnings,
'breaches' => $breaches,
'warning_messages' => $messages,
'primary_reason' => $primaryReason,
];
}
private function updateBreachCounters(HostingAccount $account, array $breaches): void
{
foreach ([
'cpu',
'memory',
'process',
'io',
'inode',
] as $metric) {
$column = "{$metric}_breach_streak";
$account->{$column} = array_key_exists($metric, $breaches)
? ((int) $account->{$column}) + 1
: 0;
}
}
private function evaluateCpuUsage(HostingAccount $account, float $currentCpu, int $limit): array
{
$metadata = (array) ($account->metadata ?? []);
$cpuHistory = (array) ($metadata['cpu_history'] ?? []);
// Add current reading with timestamp
$now = now()->timestamp;
$cpuHistory[] = ['ts' => $now, 'cpu' => $currentCpu];
// Keep only last 30 minutes of data (sync runs every 5 min = ~6 readings)
$cutoff = $now - 1800; // 30 minutes
$cpuHistory = array_filter($cpuHistory, fn($r) => $r['ts'] > $cutoff);
$cpuHistory = array_values($cpuHistory);
// Calculate averages over time windows
$windows = [
'1m' => 60, // 1 minute
'5m' => 300, // 5 minutes
'15m' => 900, // 15 minutes
];
$averages = [];
foreach ($windows as $name => $seconds) {
$windowCutoff = $now - $seconds;
$windowReadings = array_filter($cpuHistory, fn($r) => $r['ts'] > $windowCutoff);
$count = count($windowReadings);
if ($count > 0) {
$sum = array_sum(array_column($windowReadings, 'cpu'));
$averages[$name] = $sum / $count;
} else {
$averages[$name] = $currentCpu;
}
}
// Save updated history
$metadata['cpu_history'] = $cpuHistory;
$metadata['cpu_averages'] = $averages;
$account->metadata = $metadata;
// Evaluation rules:
// - Warning threshold: 80% of limit, sustained for 5 minutes
// - Breach threshold: 100% of limit, sustained for 10 minutes
// - Extreme breach: 120% of limit, sustained for 5 minutes
$warningThreshold = $limit * 0.8;
$breachThreshold = $limit;
$extremeThreshold = $limit * 1.2;
$isWarning = false;
$isBreach = false;
$message = '';
// Check for extreme breach (immediate action)
if ($averages['5m'] >= $extremeThreshold) {
$isBreach = true;
$message = sprintf(
'CPU usage critically high: %.1f%% average over 5 minutes (limit: %d%%).',
$averages['5m'],
$limit
);
}
// Check for sustained breach (10+ minutes at or above limit)
elseif ($averages['15m'] >= $breachThreshold) {
$isBreach = true;
$message = sprintf(
'CPU usage sustained above limit: %.1f%% average over 15 minutes (limit: %d%%).',
$averages['15m'],
$limit
);
}
// Check for sustained warning (5+ minutes at 80%+ of limit)
elseif ($averages['5m'] >= $warningThreshold) {
$isWarning = true;
$message = sprintf(
'CPU usage elevated: %.1f%% average over 5 minutes (limit: %d%%). Monitor closely.',
$averages['5m'],
$limit
);
}
return [
'is_breach' => $isBreach,
'is_warning' => $isWarning,
'message' => $message,
'averages' => $averages,
];
}
private function usageSnapshot(HostingAccount $account): array
{
return [
'disk_used_bytes' => (int) $account->disk_used_bytes,
'disk_usage_percent' => $account->diskUsagePercent(),
'inode_count' => (int) $account->inode_count,
'inode_usage_percent' => $account->inodeUsagePercent(),
'cpu_usage_percent' => (float) ($account->cpu_usage_percent ?? 0),
'memory_used_mb' => (int) ($account->memory_used_mb ?? 0),
'process_count' => (int) ($account->process_count ?? 0),
'io_usage_mb' => $account->io_usage_mb !== null ? (float) $account->io_usage_mb : null,
];
}
private function clearWarningState(HostingAccount $account): void
{
$account->update([
'is_flagged' => false,
'uploads_restricted' => false,
'cpu_breach_streak' => 0,
'memory_breach_streak' => 0,
'process_breach_streak' => 0,
'io_breach_streak' => 0,
'inode_breach_streak' => 0,
]);
$this->resolveAlerts($account);
}
private function shouldAutomaticallyUnsuspend(HostingAccount $account): bool
{
return $account->status === HostingAccount::STATUS_SUSPENDED
&& $account->resource_status === HostingAccount::RESOURCE_STATUS_SUSPENDED
&& data_get($account->metadata, 'suspension_origin') === 'automatic';
}
private function shouldAutomaticallySuspendForSustainedBreach(HostingAccount $account, array $breaches): bool
{
if (! $account->throttled_at || $account->throttled_at->gt(now()->subHours(self::AUTOMATIC_SUSPENSION_GRACE_HOURS))) {
return false;
}
foreach (array_keys($breaches) as $metric) {
$column = "{$metric}_breach_streak";
if ((int) ($account->{$column} ?? 0) >= self::AUTOMATIC_SUSPENSION_MIN_BREACH_STREAK) {
return true;
}
}
return false;
}
private function recordAlert(HostingAccount $account, string $alertType, string $severity, string $message, array $usage): void
{
$existing = $account->alerts()
->where('alert_type', $alertType)
->where('is_resolved', false)
->latest()
->first();
if ($existing) {
$existing->update([
'severity' => $severity,
'message' => $message,
'resource_usage' => $usage,
]);
return;
}
$account->alerts()->create([
'alert_type' => $alertType,
'severity' => $severity,
'message' => $message,
'resource_usage' => $usage,
]);
}
private function resolveAlerts(HostingAccount $account): void
{
$account->alerts()
->unresolved()
->update([
'is_resolved' => true,
'resolved_at' => now(),
]);
}
private function notifyAccount(HostingAccount $account, string $subject, string $message, bool $respectCooldown): void
{
if (! $account->user) {
return;
}
if (
$respectCooldown
&& (int) $account->warning_count > 1
&& $account->last_warning_at
&& $account->last_warning_at->gt(now()->subHours(6))
) {
return;
}
$account->user->notify(new HostingResourceWarningNotification($account, $subject, $message));
}
private function syncLatestOrderState(HostingAccount $account, string $status, ?string $reason = null): void
{
$order = $account->latestOrder;
if (! $order) {
return;
}
if ($status === CustomerHostingOrder::STATUS_SUSPENDED) {
$order->suspend($reason);
return;
}
$meta = array_merge((array) ($order->meta ?? []), [
'resource_policy_note' => $reason,
]);
$order->update([
'status' => $status,
'suspended_at' => $status === CustomerHostingOrder::STATUS_SUSPENDED ? now() : null,
'meta' => $meta,
]);
}
}
@@ -0,0 +1,376 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostingProduct;
use App\Models\NodeCapacityAlert;
class NodeCapacityService
{
public function __construct(
private HostingNodePoolService $nodePool,
) {}
public const WARNING_THRESHOLD = 80;
public const CRITICAL_THRESHOLD = 95;
public const LOCAL_NODE_LIMIT = 50;
private const BYTES_PER_GB = 1073741824;
public function checkAllNodes(): array
{
$alerts = [];
$nodes = HostingNode::query()
->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL])
->where('type', HostingNode::TYPE_SHARED)
->get();
foreach ($nodes as $node) {
$alert = $this->checkNode($node);
if ($alert) {
$alerts[] = $alert;
}
}
return $alerts;
}
public function checkNode(HostingNode $node): ?NodeCapacityAlert
{
$node = $this->refreshNodeResourceTotals($node);
$capacityPercent = $this->nodePressurePercent($node);
$existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id)
->where('is_resolved', false)
->whereIn('alert_type', [
NodeCapacityAlert::TYPE_CAPACITY_WARNING,
NodeCapacityAlert::TYPE_CAPACITY_CRITICAL,
NodeCapacityAlert::TYPE_NEEDS_EXPANSION,
])
->first();
if ($capacityPercent >= self::CRITICAL_THRESHOLD) {
return $this->createOrUpdateAlert(
$node,
NodeCapacityAlert::TYPE_CAPACITY_CRITICAL,
$capacityPercent,
sprintf(
'Node %s is at %.1f%% pressure (%s). Immediate action required.',
$node->name,
$capacityPercent,
$this->dominantPressureLabel($node)
),
$existingAlert
);
}
if ($capacityPercent >= self::WARNING_THRESHOLD) {
return $this->createOrUpdateAlert(
$node,
NodeCapacityAlert::TYPE_CAPACITY_WARNING,
$capacityPercent,
sprintf(
'Node %s is at %.1f%% pressure (%s). Capacity is nearing exhaustion.',
$node->name,
$capacityPercent,
$this->dominantPressureLabel($node)
),
$existingAlert
);
}
if ($existingAlert) {
$existingAlert->update([
'is_resolved' => true,
'resolved_at' => now(),
'resolution_notes' => 'Auto-resolved: Node pressure dropped below warning threshold.',
]);
}
return null;
}
public function canProvision(HostingNode|int $node, int $requestedDiskGb): bool
{
$node = $node instanceof HostingNode ? $node : HostingNode::query()->findOrFail($node);
$node = $this->refreshNodeResourceTotals($node);
return $node->canProvision($requestedDiskGb);
}
public function checkCapacityForProduct(HostingProduct $product): array
{
$requestedDiskGb = (int) ($product->disk_gb ?? 0);
$segment = $product->preferredNodeSegment();
$nodes = $this->preparedCandidateNodes($segment)
->map(fn (HostingNode $node): array => $this->nodeSnapshot($node, $requestedDiskGb, $segment))
->values();
$selected = $this->findAvailableNodeForProduct($product);
return [
'available' => $selected !== null,
'requested_disk_gb' => $requestedDiskGb,
'segment' => $segment,
'selected_node_id' => $selected?->id,
'selected_node_name' => $selected?->name,
'nodes' => $nodes,
];
}
public function findAvailableNode(int $requestedDiskGb = 0, ?string $segment = null): ?HostingNode
{
$primaries = $this->preparedCandidateNodes($segment)
->filter(fn (HostingNode $node) => $node->isPrimary());
$bestMember = null;
$bestLoad = PHP_FLOAT_MAX;
foreach ($primaries as $primary) {
$member = $this->nodePool->findProvisioningMember($primary, $requestedDiskGb, $segment);
if (! $member) {
continue;
}
if ($this->isLocalNode($member) && $member->accountCapacityPercent() >= self::LOCAL_NODE_LIMIT) {
continue;
}
$load = (float) $member->current_load_percent;
if ($load < $bestLoad) {
$bestLoad = $load;
$bestMember = $member;
}
}
if ($bestMember) {
return $bestMember;
}
foreach ($this->preparedCandidateNodes($segment) as $node) {
if (! $node->canProvision($requestedDiskGb, $segment)) {
continue;
}
if ($this->isLocalNode($node) && $node->accountCapacityPercent() >= self::LOCAL_NODE_LIMIT) {
continue;
}
return $node;
}
return null;
}
public function findAvailableNodeForProduct(HostingProduct $product): ?HostingNode
{
return $this->findAvailableNode(
(int) ($product->disk_gb ?? 0),
$product->preferredNodeSegment()
);
}
public function needsNewNode(): bool
{
return $this->findAvailableNode() === null;
}
public function getCapacitySummary(): array
{
$nodes = HostingNode::query()
->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL])
->where('type', HostingNode::TYPE_SHARED)
->get()
->map(fn (HostingNode $node): HostingNode => $this->refreshNodeResourceTotals($node));
$totalDisk = $nodes->sum('disk_gb');
$totalLogicalCapacity = $nodes->sum(fn (HostingNode $node): float => $node->logicalCapacityGb());
$totalAllocated = $nodes->sum('allocated_disk_gb');
$totalUsed = $nodes->sum('used_disk_gb');
$totalAccountCapacity = $nodes->sum('max_accounts');
$totalAccounts = $nodes->sum('current_accounts');
return [
'total_nodes' => $nodes->count(),
'total_capacity' => $totalAccountCapacity,
'total_used' => $totalAccounts,
'available_slots' => max($totalAccountCapacity - $totalAccounts, 0),
'overall_percent' => $totalAccountCapacity > 0 ? round(($totalAccounts / $totalAccountCapacity) * 100, 1) : 0,
'physical_disk_gb' => $totalDisk,
'logical_disk_gb' => round($totalLogicalCapacity, 2),
'allocated_disk_gb' => $totalAllocated,
'used_disk_gb' => $totalUsed,
'logical_disk_percent' => $totalDisk > 0 ? round(($totalUsed / $totalDisk) * 100, 1) : 0,
'physical_disk_percent' => $totalDisk > 0 ? round(($totalUsed / $totalDisk) * 100, 1) : 0,
'needs_expansion' => $this->needsNewNode(),
'unresolved_alerts' => NodeCapacityAlert::unresolved()->count(),
];
}
public function refreshNodeResourceTotals(HostingNode $node): HostingNode
{
$aggregate = HostingAccount::query()
->where('hosting_node_id', $node->id)
->where('type', HostingAccount::TYPE_SHARED)
->selectRaw('COUNT(*) as account_count')
->selectRaw('COALESCE(SUM(allocated_disk_gb), 0) as allocated_disk_gb')
->selectRaw('COALESCE(SUM(disk_used_bytes), 0) as used_disk_bytes')
->first();
$currentAccounts = (int) ($aggregate->account_count ?? 0);
$allocatedDiskGb = (int) ($aggregate->allocated_disk_gb ?? 0);
$usedDiskGb = (int) ceil(((int) ($aggregate->used_disk_bytes ?? 0)) / self::BYTES_PER_GB);
$logicalCapacity = $node->disk_gb && $node->disk_gb > 0
? $node->disk_gb * max((float) ($node->oversell_ratio ?: 1), 1)
: 0;
$physicalPercent = $node->disk_gb && $node->disk_gb > 0 ? ($usedDiskGb / $node->disk_gb) * 100 : 0;
$accountPercent = $node->max_accounts && $node->max_accounts > 0 ? ($currentAccounts / $node->max_accounts) * 100 : 0;
$currentLoadPercent = round(max($physicalPercent, $accountPercent), 2);
$updates = [
'current_accounts' => $currentAccounts,
'allocated_disk_gb' => $allocatedDiskGb,
'used_disk_gb' => $usedDiskGb,
'current_load_percent' => $currentLoadPercent,
];
if (in_array($node->status, [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL], true)) {
$updates['status'] = $this->shouldMarkFull($node, $currentAccounts, $usedDiskGb)
? HostingNode::STATUS_FULL
: HostingNode::STATUS_ACTIVE;
}
$node->forceFill($updates);
if ($node->exists && $node->isDirty()) {
$node->save();
}
return $node->refresh();
}
private function candidateNodes(?string $segment)
{
return HostingNode::query()
->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL])
->where('type', HostingNode::TYPE_SHARED)
->when($segment, function ($query) use ($segment) {
$query->whereIn('segment', [$segment, HostingNode::SEGMENT_GENERAL]);
})
->get();
}
private function preparedCandidateNodes(?string $segment)
{
return $this->candidateNodes($segment)
->map(fn (HostingNode $node): HostingNode => $this->refreshNodeResourceTotals($node))
->sortBy(fn (HostingNode $node) => sprintf(
'%012.2f-%012d-%012d',
(float) $node->current_load_percent,
$node->allocated_disk_gb,
$node->current_accounts
))
->values();
}
private function shouldMarkFull(HostingNode $node, int $currentAccounts, int $usedDiskGb): bool
{
if ($node->max_accounts && $currentAccounts >= $node->max_accounts) {
return true;
}
if ($node->disk_gb && $node->disk_gb > 0) {
return $usedDiskGb >= $node->disk_gb;
}
return false;
}
private function nodePressurePercent(HostingNode $node): float
{
return round(max(
$node->physicalDiskPercent(),
$node->accountCapacityPercent()
), 2);
}
private function dominantPressureLabel(HostingNode $node): string
{
$pressures = [
'disk usage' => $node->physicalDiskPercent(),
'account slots' => $node->accountCapacityPercent(),
];
arsort($pressures);
$label = array_key_first($pressures);
return sprintf('%s %.1f%%', $label, $pressures[$label] ?? 0);
}
private function nodeSnapshot(HostingNode $node, int $requestedDiskGb, ?string $segment = null): array
{
return [
'id' => $node->id,
'name' => $node->name,
'segment' => $node->segment,
'can_provision' => $node->canProvision($requestedDiskGb, $segment),
'requested_disk_gb' => $requestedDiskGb,
'physical_disk_gb' => $node->disk_gb,
'logical_disk_gb' => $node->logicalCapacityGb(),
'allocated_disk_gb' => $node->allocated_disk_gb,
'used_disk_gb' => $node->used_disk_gb,
'available_logical_disk_gb' => $node->availableLogicalDiskGb(),
'oversell_ratio' => (float) $node->oversell_ratio,
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts,
'logical_capacity_percent' => $node->logicalCapacityPercent(),
'physical_disk_percent' => $node->physicalDiskPercent(),
'account_capacity_percent' => $node->accountCapacityPercent(),
'current_load_percent' => (float) $node->current_load_percent,
];
}
private function isLocalNode(HostingNode $node): bool
{
return $node->provider === HostingNode::PROVIDER_LOCAL
|| $node->ip_address === '127.0.0.1'
|| $node->ip_address === 'localhost';
}
private function createOrUpdateAlert(
HostingNode $node,
string $type,
float $capacityPercent,
string $message,
?NodeCapacityAlert $existingAlert
): NodeCapacityAlert {
$data = [
'hosting_node_id' => $node->id,
'alert_type' => $type,
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts ?? 0,
'capacity_percent' => $capacityPercent,
'resource_usage' => [
'logical_capacity_percent' => $node->logicalCapacityPercent(),
'physical_disk_percent' => $node->physicalDiskPercent(),
'account_capacity_percent' => $node->accountCapacityPercent(),
'allocated_disk_gb' => $node->allocated_disk_gb,
'used_disk_gb' => $node->used_disk_gb,
'logical_disk_gb' => $node->logicalCapacityGb(),
],
'message' => $message,
];
if ($existingAlert) {
$existingAlert->update($data);
return $existingAlert;
}
return NodeCapacityAlert::create($data);
}
}
@@ -0,0 +1,497 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingNode;
use App\Models\NodeCapacityAlert;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SSH2;
class NodeMonitoringService
{
private ?SSH2 $ssh = null;
private bool $isLocalNode = false;
public const HEALTH_OK = 'healthy';
public const HEALTH_WARNING = 'warning';
public const HEALTH_CRITICAL = 'critical';
public const HEALTH_UNREACHABLE = 'unreachable';
public const CPU_WARNING_THRESHOLD = 75;
public const CPU_CRITICAL_THRESHOLD = 90;
public const RAM_WARNING_THRESHOLD = 80;
public const RAM_CRITICAL_THRESHOLD = 95;
public const DISK_WARNING_THRESHOLD = 80;
public const DISK_CRITICAL_THRESHOLD = 90;
public const LOAD_WARNING_MULTIPLIER = 1.5;
public const LOAD_CRITICAL_MULTIPLIER = 2.0;
public function checkAllNodes(): array
{
$results = [];
$nodes = HostingNode::whereIn('status', [
HostingNode::STATUS_ACTIVE,
HostingNode::STATUS_FULL,
HostingNode::STATUS_MAINTENANCE,
])->get();
foreach ($nodes as $node) {
$results[$node->id] = $this->checkNode($node);
}
return $results;
}
public function checkNode(HostingNode $node): array
{
$startTime = microtime(true);
try {
$ssh = $this->connect($node);
$metrics = $this->collectMetrics($ssh, $node);
$metrics['response_time_ms'] = round((microtime(true) - $startTime) * 1000);
$metrics['checked_at'] = now()->toIso8601String();
$healthStatus = $this->evaluateHealth($metrics, $node);
$metrics['health'] = $healthStatus;
$node->update([
'health_status' => $metrics,
'last_health_check_at' => now(),
]);
$this->processAlerts($node, $metrics, $healthStatus);
Log::info("Node health check completed", [
'node_id' => $node->id,
'node_name' => $node->name,
'health' => $healthStatus,
]);
return $metrics;
} catch (\Exception $e) {
$metrics = [
'health' => self::HEALTH_UNREACHABLE,
'error' => $e->getMessage(),
'checked_at' => now()->toIso8601String(),
'response_time_ms' => round((microtime(true) - $startTime) * 1000),
];
$node->update([
'health_status' => $metrics,
'last_health_check_at' => now(),
]);
$this->createUnreachableAlert($node, $e->getMessage());
Log::error("Node health check failed", [
'node_id' => $node->id,
'node_name' => $node->name,
'error' => $e->getMessage(),
]);
return $metrics;
} finally {
$this->disconnect();
}
}
private function isLocal(HostingNode $node): bool
{
return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost')
&& blank($node->ssh_private_key);
}
private function connect(HostingNode $node): ?SSH2
{
if ($this->isLocal($node)) {
$this->isLocalNode = true;
return null;
}
$this->isLocalNode = false;
// For local nodes with SSH keys, connect to 127.0.0.1
$host = $this->isLocalIp($node) ? '127.0.0.1' : $node->ip_address;
$this->ssh = new SSH2($host, (int) ($node->ssh_port ?: 22));
$this->ssh->setTimeout(30);
// Load the private key properly for phpseclib3
$credential = $node->ssh_private_key;
if (str_contains($credential, '-----BEGIN') || str_contains($credential, 'PRIVATE KEY')) {
try {
$credential = PublicKeyLoader::load($credential);
} catch (\Throwable $e) {
throw new \RuntimeException("Invalid SSH key for node {$node->hostname}: " . $e->getMessage());
}
}
if (!$this->ssh->login('root', $credential)) {
throw new \RuntimeException("SSH authentication failed for node: {$node->hostname}");
}
return $this->ssh;
}
private function isLocalIp(HostingNode $node): bool
{
return $node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost';
}
private function disconnect(): void
{
if ($this->ssh) {
$this->ssh->disconnect();
$this->ssh = null;
}
$this->isLocalNode = false;
}
private function exec(?SSH2 $ssh, string $command): string
{
if ($this->isLocalNode) {
$result = Process::run($command);
return trim($result->output() . $result->errorOutput());
}
return trim($ssh->exec($command));
}
private function collectMetrics(?SSH2 $ssh, HostingNode $node): array
{
return [
'cpu' => $this->getCpuMetrics($ssh),
'memory' => $this->getMemoryMetrics($ssh),
'disk' => $this->getDiskMetrics($ssh),
'load' => $this->getLoadMetrics($ssh, $node),
'uptime' => $this->getUptime($ssh),
'processes' => $this->getProcessCount($ssh),
'services' => $this->checkServices($ssh),
'network' => $this->getNetworkMetrics($ssh),
];
}
private function getCpuMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "top -bn1 | grep 'Cpu(s)' | awk '{print \$2 + \$4}'");
$usagePercent = is_numeric($output) ? (float) $output : 0;
$coresOutput = $this->exec($ssh, "nproc");
$cores = is_numeric($coresOutput) ? (int) $coresOutput : 1;
return [
'usage_percent' => round($usagePercent, 1),
'cores' => $cores,
];
}
private function getMemoryMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "free -b | grep Mem");
$parts = preg_split('/\s+/', trim($output));
$total = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0;
$used = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : 0;
$free = isset($parts[3]) && is_numeric($parts[3]) ? (int) $parts[3] : 0;
$available = isset($parts[6]) && is_numeric($parts[6]) ? (int) $parts[6] : $free;
$usagePercent = $total > 0 ? (($total - $available) / $total) * 100 : 0;
return [
'total_bytes' => $total,
'used_bytes' => $used,
'available_bytes' => $available,
'usage_percent' => round($usagePercent, 1),
];
}
private function getDiskMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "df -B1 / | tail -1");
$parts = preg_split('/\s+/', trim($output));
$total = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0;
$used = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : 0;
$available = isset($parts[3]) && is_numeric($parts[3]) ? (int) $parts[3] : 0;
$usagePercent = isset($parts[4]) ? (float) rtrim($parts[4], '%') : 0;
$homeOutput = $this->exec($ssh, "df -B1 /home 2>/dev/null | tail -1");
$homeParts = preg_split('/\s+/', trim($homeOutput));
$homeUsagePercent = isset($homeParts[4]) ? (float) rtrim($homeParts[4], '%') : $usagePercent;
return [
'root' => [
'total_bytes' => $total,
'used_bytes' => $used,
'available_bytes' => $available,
'usage_percent' => $usagePercent,
],
'home_usage_percent' => $homeUsagePercent,
];
}
private function getLoadMetrics(?SSH2 $ssh, HostingNode $node): array
{
$output = $this->exec($ssh, "cat /proc/loadavg");
$parts = explode(' ', $output);
$load1 = isset($parts[0]) && is_numeric($parts[0]) ? (float) $parts[0] : 0;
$load5 = isset($parts[1]) && is_numeric($parts[1]) ? (float) $parts[1] : 0;
$load15 = isset($parts[2]) && is_numeric($parts[2]) ? (float) $parts[2] : 0;
$cores = $node->cpu_cores ?: 1;
$loadPerCore = $load1 / $cores;
return [
'load_1' => $load1,
'load_5' => $load5,
'load_15' => $load15,
'load_per_core' => round($loadPerCore, 2),
'cores' => $cores,
];
}
private function getUptime(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "cat /proc/uptime | awk '{print \$1}'");
$uptimeSeconds = is_numeric($output) ? (int) $output : 0;
$days = floor($uptimeSeconds / 86400);
$hours = floor(($uptimeSeconds % 86400) / 3600);
$minutes = floor(($uptimeSeconds % 3600) / 60);
return [
'seconds' => $uptimeSeconds,
'formatted' => "{$days}d {$hours}h {$minutes}m",
];
}
private function getProcessCount(?SSH2 $ssh): array
{
$total = (int) $this->exec($ssh, "ps aux | wc -l") - 1;
$running = (int) $this->exec($ssh, "ps aux | grep -c ' R'");
return [
'total' => $total,
'running' => $running,
];
}
private function checkServices(?SSH2 $ssh): array
{
// Check for PHP-FPM with dynamic version detection
$phpVersion = $this->exec($ssh, "php -v | head -n1 | cut -d' ' -f2 | cut -d'.' -f1,2");
$phpVersion = preg_match('/^\d+\.\d+$/', $phpVersion) ? $phpVersion : '8.2';
$phpFpmService = "php{$phpVersion}-fpm";
$services = ['nginx', $phpFpmService, 'mysql', 'ssh'];
$status = [];
foreach ($services as $service) {
$output = $this->exec($ssh, "systemctl is-active {$service} 2>/dev/null || echo 'inactive'");
$status[$service] = $output === 'active';
}
return $status;
}
private function getNetworkMetrics(?SSH2 $ssh): array
{
$output = $this->exec($ssh, "cat /proc/net/dev | grep -E 'eth0|ens|enp' | head -1");
$parts = preg_split('/\s+/', trim($output));
$rxBytes = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0;
$txBytes = isset($parts[9]) && is_numeric($parts[9]) ? (int) $parts[9] : 0;
$connections = (int) $this->exec($ssh, "ss -tun | wc -l") - 1;
return [
'rx_bytes_total' => $rxBytes,
'tx_bytes_total' => $txBytes,
'active_connections' => max(0, $connections),
];
}
private function evaluateHealth(array $metrics, HostingNode $node): string
{
$cpuUsage = $metrics['cpu']['usage_percent'] ?? 0;
$ramUsage = $metrics['memory']['usage_percent'] ?? 0;
$diskUsage = $metrics['disk']['root']['usage_percent'] ?? 0;
$loadPerCore = $metrics['load']['load_per_core'] ?? 0;
$services = $metrics['services'] ?? [];
$criticalServices = ['nginx', 'php8.2-fpm'];
foreach ($criticalServices as $service) {
if (isset($services[$service]) && !$services[$service]) {
return self::HEALTH_CRITICAL;
}
}
if (
$cpuUsage >= self::CPU_CRITICAL_THRESHOLD ||
$ramUsage >= self::RAM_CRITICAL_THRESHOLD ||
$diskUsage >= self::DISK_CRITICAL_THRESHOLD ||
$loadPerCore >= self::LOAD_CRITICAL_MULTIPLIER
) {
return self::HEALTH_CRITICAL;
}
if (
$cpuUsage >= self::CPU_WARNING_THRESHOLD ||
$ramUsage >= self::RAM_WARNING_THRESHOLD ||
$diskUsage >= self::DISK_WARNING_THRESHOLD ||
$loadPerCore >= self::LOAD_WARNING_MULTIPLIER
) {
return self::HEALTH_WARNING;
}
return self::HEALTH_OK;
}
private function processAlerts(HostingNode $node, array $metrics, string $healthStatus): void
{
$existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id)
->where('is_resolved', false)
->whereIn('alert_type', ['resource_high', 'service_down'])
->first();
if ($healthStatus === self::HEALTH_OK) {
if ($existingAlert) {
$existingAlert->update([
'is_resolved' => true,
'resolved_at' => now(),
'resolution_notes' => 'Auto-resolved: Node health returned to normal.',
]);
}
return;
}
$issues = $this->identifyIssues($metrics);
if (empty($issues)) {
return;
}
$message = "Node {$node->name} health issues: " . implode(', ', $issues);
if ($existingAlert) {
$existingAlert->update([
'alert_type' => $healthStatus === self::HEALTH_CRITICAL ? 'resource_high' : 'capacity_warning',
'resource_usage' => $metrics,
'message' => $message,
]);
} else {
NodeCapacityAlert::create([
'hosting_node_id' => $node->id,
'alert_type' => $healthStatus === self::HEALTH_CRITICAL ? 'resource_high' : 'capacity_warning',
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts ?? 0,
'capacity_percent' => $node->max_accounts > 0
? ($node->current_accounts / $node->max_accounts) * 100
: 0,
'resource_usage' => $metrics,
'message' => $message,
]);
}
}
private function identifyIssues(array $metrics): array
{
$issues = [];
$cpuUsage = $metrics['cpu']['usage_percent'] ?? 0;
if ($cpuUsage >= self::CPU_WARNING_THRESHOLD) {
$issues[] = "CPU at {$cpuUsage}%";
}
$ramUsage = $metrics['memory']['usage_percent'] ?? 0;
if ($ramUsage >= self::RAM_WARNING_THRESHOLD) {
$issues[] = "RAM at {$ramUsage}%";
}
$diskUsage = $metrics['disk']['root']['usage_percent'] ?? 0;
if ($diskUsage >= self::DISK_WARNING_THRESHOLD) {
$issues[] = "Disk at {$diskUsage}%";
}
$loadPerCore = $metrics['load']['load_per_core'] ?? 0;
if ($loadPerCore >= self::LOAD_WARNING_MULTIPLIER) {
$issues[] = "Load per core: {$loadPerCore}";
}
$services = $metrics['services'] ?? [];
foreach ($services as $service => $running) {
if (!$running) {
$issues[] = "{$service} is down";
}
}
return $issues;
}
private function createUnreachableAlert(HostingNode $node, string $error): void
{
$existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id)
->where('is_resolved', false)
->where('alert_type', 'resource_high')
->first();
$message = "Node {$node->name} is unreachable: {$error}";
if ($existingAlert) {
$existingAlert->update([
'message' => $message,
'resource_usage' => ['error' => $error],
]);
} else {
NodeCapacityAlert::create([
'hosting_node_id' => $node->id,
'alert_type' => 'resource_high',
'current_accounts' => $node->current_accounts,
'max_accounts' => $node->max_accounts ?? 0,
'capacity_percent' => $node->max_accounts > 0
? ($node->current_accounts / $node->max_accounts) * 100
: 0,
'resource_usage' => ['error' => $error],
'message' => $message,
]);
}
}
public function getNodeSummary(HostingNode $node): array
{
$healthStatus = $node->health_status ?? [];
return [
'id' => $node->id,
'name' => $node->name,
'hostname' => $node->hostname,
'ip_address' => $node->ip_address,
'status' => $node->status,
'health' => $healthStatus['health'] ?? 'unknown',
'last_check' => $node->last_health_check_at?->diffForHumans() ?? 'Never',
'cpu_usage' => $healthStatus['cpu']['usage_percent'] ?? null,
'ram_usage' => $healthStatus['memory']['usage_percent'] ?? null,
'disk_usage' => $healthStatus['disk']['root']['usage_percent'] ?? null,
'load' => $healthStatus['load']['load_1'] ?? null,
'uptime' => $healthStatus['uptime']['formatted'] ?? null,
'logical_capacity_percent' => $node->logicalCapacityPercent(),
'current_load_percent' => (float) $node->current_load_percent,
'allocated_disk_gb' => $node->allocated_disk_gb,
'logical_disk_gb' => $node->logicalCapacityGb(),
'accounts' => [
'current' => $node->current_accounts,
'max' => $node->max_accounts,
'percent' => $node->max_accounts > 0
? round(($node->current_accounts / $node->max_accounts) * 100, 1)
: 0,
],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
use App\Services\Hosting\PanelRuntimes\SharedHostingPanelRuntime;
use InvalidArgumentException;
use RuntimeException;
class PanelRuntimeResolver
{
public function __construct(
private SharedHostingPanelRuntime $sharedHostingRuntime,
) {}
public function forSubject(mixed $subject): PanelRuntimeInterface
{
if ($subject instanceof HostingAccount) {
return $this->sharedHostingRuntime;
}
if ($subject instanceof CustomerHostingOrder) {
throw new RuntimeException('Server-backed hosting panel runtime is not enabled yet for this order.');
}
throw new InvalidArgumentException('Unsupported panel runtime subject.');
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Services\Hosting\PanelRuntimes;
use App\Models\CustomerHostingOrder;
use App\Models\ServerAgent;
use App\Models\ServerTask;
use App\Services\Hosting\ServerAgentBootstrapService;
use Illuminate\Support\Str;
use RuntimeException;
class ServerAgentPanelRuntime
{
public function __construct(
private ServerAgentBootstrapService $bootstrap,
) {}
public function label(): string
{
return 'Ladill Hosting Panel';
}
public function supports(CustomerHostingOrder $order): bool
{
return (bool) data_get($order->meta, 'server_panel_runtime.managed_stack_candidate', false);
}
public function ensureAgent(CustomerHostingOrder $order): ServerAgent
{
if (! $this->supports($order)) {
throw new RuntimeException('This server image is not enabled for agent-backed Ladill hosting tasks.');
}
return $this->bootstrap->ensureBootstrap($order)['agent'];
}
public function queueDomainTask(CustomerHostingOrder $order, string $domain, ?string $documentRoot = null, ?string $phpVersion = null): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_DOMAIN_ADD, array_filter([
'domain' => strtolower($domain),
'document_root' => $documentRoot,
'php_version' => $phpVersion,
], static fn ($value): bool => $value !== null && $value !== ''));
}
public function queueSslTask(CustomerHostingOrder $order, string $domain): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_SSL_REQUEST, [
'domain' => strtolower($domain),
]);
}
public function queueDatabaseTask(CustomerHostingOrder $order, string $name): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_DATABASE_CREATE, [
'name' => Str::lower($name),
]);
}
public function queueSiteDeleteTask(CustomerHostingOrder $order, string $domain): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_SITE_DELETE, [
'domain' => strtolower($domain),
]);
}
public function queuePhpTask(CustomerHostingOrder $order, string $domain, string $phpVersion): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_PHP_CONFIGURE, [
'domain' => strtolower($domain),
'php_version' => $phpVersion,
]);
}
public function queueCronTask(
CustomerHostingOrder $order,
string $operation,
?string $name = null,
?string $expression = null,
?string $command = null,
?string $user = null,
): ServerTask {
$taskType = match ($operation) {
'list' => ServerTask::TYPE_CRON_LIST,
'upsert' => ServerTask::TYPE_CRON_UPSERT,
'remove' => ServerTask::TYPE_CRON_REMOVE,
default => throw new RuntimeException('Unsupported cron operation.'),
};
return $this->queueTask($order, $taskType, array_filter([
'name' => $name,
'expression' => $expression,
'command' => $command,
'user' => $user,
], static fn ($value): bool => $value !== null && $value !== ''));
}
public function queueLogTask(CustomerHostingOrder $order, string $target, ?string $domain = null, int $lines = 120): ServerTask
{
return $this->queueTask($order, ServerTask::TYPE_LOG_READ, [
'target' => $target,
'domain' => $domain ? strtolower($domain) : null,
'lines' => max(20, min(500, $lines)),
]);
}
public function queueFileTask(CustomerHostingOrder $order, string $operation, string $path, ?string $content = null): ServerTask
{
$taskType = match ($operation) {
'list' => ServerTask::TYPE_FILE_LIST,
'read' => ServerTask::TYPE_FILE_READ,
'write' => ServerTask::TYPE_FILE_WRITE,
default => throw new RuntimeException('Unsupported file operation.'),
};
return $this->queueTask($order, $taskType, array_filter([
'path' => $path,
'content' => $content,
], static fn ($value): bool => $value !== null));
}
private function queueTask(CustomerHostingOrder $order, string $type, array $payload): ServerTask
{
$agent = $this->ensureAgent($order);
return ServerTask::create([
'customer_hosting_order_id' => $order->id,
'server_agent_id' => $agent->id,
'type' => $type,
'status' => ServerTask::STATUS_QUEUED,
'payload' => $payload,
'progress' => 0,
'max_attempts' => 3,
'retry_backoff_seconds' => 60,
'stale_after_seconds' => 120,
'queued_at' => now(),
'available_at' => now(),
]);
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Services\Hosting\PanelRuntimes;
use App\Models\HostedDatabase;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
use App\Services\Hosting\Providers\SharedNodeProvider;
class SharedHostingPanelRuntime implements PanelRuntimeInterface
{
private const CAPABILITIES = [
'files',
'databases',
'domains',
'terminal',
'php',
'ssl',
'cron',
'logs',
'apps',
'settings',
];
public function __construct(
private SharedNodeProvider $provider,
) {}
public function key(): string
{
return 'shared_hosting';
}
public function label(): string
{
return 'Ladill Hosting Panel';
}
public function capabilities(): array
{
return self::CAPABILITIES;
}
public function supports(string $capability): bool
{
return in_array($capability, self::CAPABILITIES, true);
}
public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array
{
return $this->provider->executeCommand($account, $command, $timeout);
}
public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array
{
return $this->provider->executeTerminalCommand($account, $command, $workingDirectory, $timeout);
}
public function executeCommandAsRoot(HostingAccount $account, string $command): array
{
return $this->provider->executeCommandAsRoot($account, $command);
}
public function getUsageStats(HostingAccount $account): array
{
return $this->provider->getUsageStats($account);
}
public function changePassword(HostingAccount $account, string $newPassword): bool
{
return $this->provider->changePassword($account, $newPassword);
}
public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void
{
$this->provider->installTeamMemberSshKey($account, $marker, $publicKey);
}
public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void
{
$this->provider->removeTeamMemberSshKey($account, $marker);
}
public function createDatabase(HostedDatabase $database, string $password): array
{
return $this->provider->createDatabase($database, $password);
}
public function deleteDatabase(HostedDatabase $database): bool
{
return $this->provider->deleteDatabase($database);
}
public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool
{
return $this->provider->changeDatabasePassword($database, $newPassword);
}
public function addSite(HostedSite $site): array
{
return $this->provider->addSite($site);
}
public function removeSite(HostedSite $site): bool
{
return $this->provider->removeSite($site);
}
public function requestLetsEncryptCertificate(HostedSite $site): bool
{
return $this->provider->requestLetsEncryptCertificate($site);
}
public function ensurePhpFpmPool(HostingAccount $account): void
{
$this->provider->ensurePhpFpmPool($account);
}
public function setPhpVersion(HostedSite $site, string $version): bool
{
return $this->provider->setPhpVersion($site, $version);
}
public function changeAccountPhpVersion(HostingAccount $account, string $version): bool
{
return $this->provider->changeAccountPhpVersion($account, $version);
}
public function prepareDirectoryForUser(HostingAccount $account, string $path): void
{
$this->provider->prepareDirectoryForUser($account, $path);
}
public function setWebServerGroupOwnership(HostingAccount $account, string $path): void
{
$this->provider->setWebServerGroupOwnership($account, $path);
}
public function removeAppService(HostedSite $site): bool
{
return $this->provider->removeAppService($site);
}
public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void
{
$this->provider->restoreDefaultSiteConfig($account, $site, $docRoot);
}
}
@@ -0,0 +1,553 @@
<?php
namespace App\Services\Hosting\Providers;
use App\Exceptions\ContaboApiException;
use App\Services\Hosting\Contracts\ComputeProviderInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ContaboProvider implements ComputeProviderInterface
{
private string $clientId;
private string $clientSecret;
private string $apiUser;
private string $apiPassword;
private ?string $productCatalogEndpoint;
private string $baseUrl = 'https://api.contabo.com/v1';
private string $authUrl = 'https://auth.contabo.com/auth/realms/contabo/protocol/openid-connect/token';
public function __construct()
{
$this->clientId = config('hosting.contabo.client_id');
$this->clientSecret = config('hosting.contabo.client_secret');
$this->apiUser = config('hosting.contabo.api_user');
$this->apiPassword = config('hosting.contabo.api_password');
$this->productCatalogEndpoint = config('hosting.contabo.product_catalog_endpoint');
}
private function getAccessToken(): string
{
return Cache::remember('contabo_access_token', 290, function () {
$response = Http::asForm()->post($this->authUrl, [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'username' => $this->apiUser,
'password' => $this->apiPassword,
'grant_type' => 'password',
]);
if (!$response->successful()) {
Log::error('Contabo auth failed', ['response' => $response->body()]);
throw new \RuntimeException('Failed to authenticate with Contabo API');
}
return $response->json('access_token');
});
}
private function client(): PendingRequest
{
return Http::withToken($this->getAccessToken())
->baseUrl($this->baseUrl)
->withHeaders([
'x-request-id' => Str::uuid()->toString(),
])
->timeout(60);
}
public function createInstance(array $config): array
{
$payload = [
'productId' => $config['product_id'], // e.g., V45
'region' => $config['region'] ?? 'EU',
'displayName' => $config['display_name'] ?? $config['name'] ?? 'ladill-vps-' . uniqid(),
'period' => (int) ($config['period'] ?? 1),
];
if (!empty($config['image_id'])) {
$payload['imageId'] = $config['image_id'];
}
if (!empty($config['ssh_keys'])) {
$payload['sshKeys'] = $config['ssh_keys'];
}
if (!empty($config['root_password_secret_id'])) {
$payload['rootPassword'] = (int) $config['root_password_secret_id'];
} elseif (!empty($config['root_password'])) {
$payload['rootPassword'] = $this->createPasswordSecret(
(string) ($config['display_name'] ?? $config['name'] ?? 'server-password'),
(string) $config['root_password']
);
}
if (!empty($config['user_data'])) {
$payload['userData'] = base64_encode($config['user_data']);
}
if (!empty($config['license'])) {
$payload['license'] = $config['license'];
}
if (!empty($config['default_user'])) {
$payload['defaultUser'] = $config['default_user'];
}
if (!empty($config['application_id'])) {
$payload['applicationId'] = $config['application_id'];
}
if (!empty($config['add_ons'])) {
$payload['addOns'] = $config['add_ons'];
}
$response = $this->client()->post('/compute/instances', $payload);
if (! $response->successful()) {
Log::error('Contabo create instance failed', [
'payload' => $payload,
'status' => $response->status(),
'response' => $response->body(),
]);
throw ContaboApiException::fromResponse($response->status(), (string) $response->body());
}
$data = $response->json('data.0');
return [
'instance_id' => $data['instanceId'],
'name' => $data['displayName'],
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null,
'status' => $this->mapStatus($data['status']),
'region' => $data['region'],
'datacenter' => $data['dataCenter'],
'image' => $data['imageId'],
'product_id' => $data['productId'],
'cpu_cores' => $data['cpuCores'] ?? null,
'ram_mb' => $data['ramMb'] ?? null,
'disk_mb' => $data['diskMb'] ?? null,
];
}
public function getInstance(string $instanceId): ?array
{
$response = $this->client()->get("/compute/instances/{$instanceId}");
if (!$response->successful()) {
if ($response->status() === 404) {
return null;
}
throw new \RuntimeException('Failed to get Contabo instance: ' . $response->body());
}
$data = $response->json('data.0');
return [
'instance_id' => $data['instanceId'],
'name' => $data['displayName'],
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null,
'status' => $this->mapStatus($data['status']),
'power_status' => $data['status'] === 'running' ? 'on' : 'off',
'region' => $data['region'],
'datacenter' => $data['dataCenter'],
'image' => $data['imageId'],
'product_id' => $data['productId'],
'cpu_cores' => $data['cpuCores'] ?? null,
'ram_mb' => $data['ramMb'] ?? null,
'disk_mb' => $data['diskMb'] ?? null,
'created_at' => $data['createdDate'] ?? null,
];
}
public function listInstances(array $filters = []): array
{
$query = array_filter([
'page' => $filters['page'] ?? 1,
'size' => $filters['per_page'] ?? 100,
'name' => $filters['name'] ?? null,
'region' => $filters['region'] ?? null,
'status' => $filters['status'] ?? null,
]);
$response = $this->client()->get('/compute/instances', $query);
if (!$response->successful()) {
throw new \RuntimeException('Failed to list Contabo instances: ' . $response->body());
}
$instances = [];
foreach ($response->json('data', []) as $data) {
$instances[] = [
'instance_id' => $data['instanceId'],
'name' => $data['displayName'],
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
'status' => $this->mapStatus($data['status']),
'region' => $data['region'],
];
}
return [
'instances' => $instances,
'total' => $response->json('_pagination.totalElements', count($instances)),
];
}
public function startInstance(string $instanceId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/start");
return $response->successful();
}
public function stopInstance(string $instanceId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/stop");
return $response->successful();
}
public function rebootInstance(string $instanceId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/restart");
return $response->successful();
}
public function deleteInstance(string $instanceId): bool
{
$response = $this->client()->delete("/compute/instances/{$instanceId}");
return $response->successful();
}
public function reinstallInstance(string $instanceId, string $image): bool
{
$response = $this->client()->put("/compute/instances/{$instanceId}", [
'imageId' => $image,
]);
return $response->successful();
}
public function createSnapshot(string $instanceId, string $name): array
{
$response = $this->client()->post("/compute/instances/{$instanceId}/snapshots", [
'name' => $name,
'description' => "Snapshot created by Ladill on " . now()->toDateTimeString(),
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to create snapshot: ' . $response->body());
}
$data = $response->json('data.0');
return [
'snapshot_id' => $data['snapshotId'],
'name' => $data['name'],
'status' => $data['status'] ?? 'creating',
];
}
public function listSnapshots(string $instanceId): array
{
$response = $this->client()->get("/compute/instances/{$instanceId}/snapshots");
if (!$response->successful()) {
throw new \RuntimeException('Failed to list snapshots: ' . $response->body());
}
$snapshots = [];
foreach ($response->json('data', []) as $data) {
$snapshots[] = [
'snapshot_id' => $data['snapshotId'],
'name' => $data['name'],
'status' => $data['status'] ?? 'available',
'created_at' => $data['createdDate'] ?? null,
];
}
return $snapshots;
}
public function deleteSnapshot(string $snapshotId): bool
{
$response = $this->client()->delete("/compute/snapshots/{$snapshotId}");
return $response->successful();
}
public function restoreSnapshot(string $instanceId, string $snapshotId): bool
{
$response = $this->client()->post("/compute/instances/{$instanceId}/snapshots/{$snapshotId}/rollback");
return $response->successful();
}
public function getAvailableImages(): array
{
$images = [];
$page = 1;
$pageSize = 100;
do {
$response = $this->client()->get('/compute/images', [
'page' => $page,
'size' => $pageSize,
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to get images: ' . $response->body());
}
$batch = $response->json('data', []);
foreach ($batch as $data) {
$images[] = [
'id' => $data['imageId'],
'name' => $data['name'],
'description' => $data['description'] ?? '',
'os_type' => $data['osType'] ?? 'linux',
'standard_image' => $data['standardImage'] ?? true,
];
}
$page++;
} while (count($batch) === $pageSize);
if ($images === []) {
throw new \RuntimeException('Failed to get images: empty response');
}
return $images;
}
public function getAvailableRegions(): array
{
return [
['id' => 'EU', 'name' => 'European Union', 'country' => 'DE'],
['id' => 'US-central', 'name' => 'United States Central', 'country' => 'US'],
['id' => 'US-east', 'name' => 'United States East', 'country' => 'US'],
['id' => 'US-west', 'name' => 'United States West', 'country' => 'US'],
['id' => 'SIN', 'name' => 'Singapore', 'country' => 'SG'],
['id' => 'UK', 'name' => 'United Kingdom', 'country' => 'GB'],
['id' => 'AUS', 'name' => 'Australia', 'country' => 'AU'],
['id' => 'JPN', 'name' => 'Japan', 'country' => 'JP'],
];
}
public function getAvailableApplications(): array
{
return Cache::remember('contabo_applications', 3600, function () {
$applications = [];
$page = 1;
$pageSize = 100;
do {
$response = $this->client()->get('/compute/applications', [
'page' => $page,
'size' => $pageSize,
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to get applications: ' . $response->body());
}
$batch = $response->json('data', []);
foreach ($batch as $data) {
$applications[] = [
'id' => $data['applicationId'],
'name' => $data['name'],
'description' => $data['description'] ?? '',
'version' => $data['version'] ?? null,
'url' => $data['url'] ?? null,
];
}
$page++;
} while (count($batch) === $pageSize);
return $applications;
});
}
public function getAvailableProducts(): array
{
return Cache::remember('contabo_products', 3600, function () {
$liveProducts = $this->fetchProductCatalog();
return $liveProducts !== [] ? $liveProducts : $this->fallbackProducts();
});
}
private function fetchProductCatalog(): array
{
$endpoint = trim((string) $this->productCatalogEndpoint);
if ($endpoint === '') {
return [];
}
$response = str_starts_with($endpoint, 'http')
? Http::withToken($this->getAccessToken())
->withHeaders(['x-request-id' => Str::uuid()->toString()])
->timeout(60)
->get($endpoint)
: $this->client()->get($endpoint);
if (! $response->successful()) {
Log::warning('Contabo product catalog lookup failed', [
'endpoint' => $endpoint,
'status' => $response->status(),
'response' => Str::limit($response->body(), 500),
]);
return [];
}
$rows = $response->json('data', $response->json('products', $response->json()));
if (! is_array($rows)) {
return [];
}
$products = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$product = $this->normalizeProductCatalogRow($row);
if ($product !== null) {
$products[] = $product;
}
}
return $products;
}
private function normalizeProductCatalogRow(array $row): ?array
{
$id = (string) ($row['id'] ?? $row['productId'] ?? $row['product_id'] ?? '');
if ($id === '') {
return null;
}
$monthlyPrice = $row['price_monthly']
?? $row['monthly']
?? $row['monthly_usd']
?? $row['monthlyUsd']
?? $row['monthlyPrice']
?? $row['price']
?? null;
if (! is_numeric($monthlyPrice)) {
return null;
}
return [
'id' => $id,
'name' => (string) ($row['name'] ?? $row['product'] ?? $id),
'cpu' => isset($row['cpu']) ? (int) $row['cpu'] : (isset($row['cpuCores']) ? (int) $row['cpuCores'] : null),
'ram_gb' => isset($row['ram_gb']) ? (float) $row['ram_gb'] : (isset($row['ramGb']) ? (float) $row['ramGb'] : null),
'disk_gb' => isset($row['disk_gb']) ? (float) $row['disk_gb'] : (isset($row['diskGb']) ? (float) $row['diskGb'] : null),
'price_monthly' => (float) $monthlyPrice,
];
}
private function fallbackProducts(): array
{
return [
['id' => 'V91', 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75, 'price_monthly' => 4.99],
['id' => 'V94', 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100, 'price_monthly' => 7.00],
['id' => 'V97', 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200, 'price_monthly' => 14.00],
['id' => 'V100', 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250, 'price_monthly' => 25.00],
['id' => 'V45', 'name' => 'Legacy VPS S SSD', 'cpu' => 4, 'ram_gb' => 8, 'disk_gb' => 200, 'price_monthly' => 6.99],
['id' => 'V47', 'name' => 'Legacy VPS M SSD', 'cpu' => 6, 'ram_gb' => 16, 'disk_gb' => 400, 'price_monthly' => 12.99],
['id' => 'V46', 'name' => 'Legacy VPS L SSD', 'cpu' => 8, 'ram_gb' => 30, 'disk_gb' => 800, 'price_monthly' => 22.99],
['id' => 'V48', 'name' => 'Legacy VPS XL SSD', 'cpu' => 10, 'ram_gb' => 60, 'disk_gb' => 1600, 'price_monthly' => 44.99],
];
}
private function mapStatus(string $contaboStatus): string
{
return match ($contaboStatus) {
'running' => 'running',
'stopped' => 'stopped',
'provisioning', 'installing' => 'provisioning',
'error' => 'failed',
default => 'unknown',
};
}
public function createPasswordSecret(string $displayName, string $password): int
{
$response = $this->client()->post('/secrets', [
'name' => Str::limit($displayName, 200, '').'-password',
'value' => $password,
'type' => 'password',
]);
if (! $response->successful()) {
Log::error('Contabo secret creation failed', ['response' => $response->body()]);
throw new \RuntimeException('Failed to create Contabo password secret: '.$response->body());
}
return (int) $response->json('data.0.secretId');
}
public function generateCloudInit(array $config): string
{
$packages = $config['packages'] ?? ['curl', 'wget', 'git', 'unzip'];
$sshKeys = $config['ssh_keys'] ?? [];
$writeFiles = $config['write_files'] ?? [];
$runCommands = $config['run_commands'] ?? [];
$cloudInit = [
'#cloud-config',
'package_update: true',
'package_upgrade: true',
'packages:',
];
foreach ($packages as $package) {
$cloudInit[] = " - {$package}";
}
if (!empty($sshKeys)) {
$cloudInit[] = 'ssh_authorized_keys:';
foreach ($sshKeys as $key) {
$cloudInit[] = " - {$key}";
}
}
if (! empty($writeFiles)) {
$cloudInit[] = 'write_files:';
foreach ($writeFiles as $file) {
$cloudInit[] = ' - path: ' . ($file['path'] ?? '/tmp/ladill-file');
if (! empty($file['owner'])) {
$cloudInit[] = ' owner: ' . $file['owner'];
}
if (! empty($file['permissions'])) {
$cloudInit[] = ' permissions: "' . $file['permissions'] . '"';
}
if (! empty($file['encoding'])) {
$cloudInit[] = ' encoding: ' . $file['encoding'];
}
$cloudInit[] = ' content: |';
foreach (preg_split("/\r\n|\n|\r/", (string) ($file['content'] ?? '')) ?: [] as $contentLine) {
$cloudInit[] = ' ' . $contentLine;
}
}
}
if (!empty($runCommands)) {
$cloudInit[] = 'runcmd:';
foreach ($runCommands as $cmd) {
$cloudInit[] = " - {$cmd}";
}
}
return implode("\n", $cloudInit);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,662 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingOrder;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubHostingService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private ?string $lastSyncError = null;
private string $apiUrl;
private string $hostingEndpoint;
private string $authUserId;
private string $apiKey;
private string $customerId;
public function __construct()
{
$this->apiUrl = rtrim($this->configuredApiUrl(), '/');
$this->hostingEndpoint = trim((string) config('mailinfra.hosting_endpoint', 'singledomainhosting/linux/us'), '/');
$this->authUserId = (string) config('mailinfra.registrar_auth_userid');
$this->apiKey = (string) config('mailinfra.registrar_api_key');
$this->customerId = (string) config('mailinfra.registrar_customer_id');
}
public function isConfigured(): bool
{
return $this->authUserId !== '' && $this->apiKey !== '';
}
public function lastSyncError(): ?string
{
return $this->lastSyncError;
}
/**
* Build a URL with all parameters as query string values, including auth.
*
* @param array<string, mixed> $params
*/
private function buildQueryUrl(string $endpoint, array $params = []): string
{
$all = array_merge([
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
], $params);
$separator = str_contains($endpoint, '?') ? '&' : '?';
return $endpoint.$separator.http_build_query($all);
}
/**
* Authenticate a ResellerClub customer by email and password.
* Returns the customer ID on success, or null on failure.
*
* @return array{success: bool, customer_id?: string, message?: string}
*/
public function authenticateCustomer(string $email, string $password): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->apiUrl.'/customers/authenticate.json', [
'username' => $email,
'passwd' => $password,
]));
$data = $response->json();
if ($response->failed()) {
$message = $data['message'] ?? $data['error'] ?? 'Authentication failed.';
return ['success' => false, 'message' => $message];
}
// RC returns the customer ID directly as the response body on success
$customerId = is_array($data) ? (string) ($data['customerid'] ?? '') : (string) $data;
if ($customerId === '' || $customerId === 'false') {
return ['success' => false, 'message' => 'Invalid email or password.'];
}
return ['success' => true, 'customer_id' => $customerId];
} catch (\Throwable $e) {
Log::error('HostingService: authenticateCustomer failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not verify ResellerClub credentials.'];
}
}
/**
* Get customer details by customer ID.
*
* @return array{success: bool, customer?: array, message?: string}
*/
public function getCustomerDetails(string $customerId): array
{
try {
$response = Http::timeout(15)->get($this->apiUrl.'/customers/details-by-id.json', [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'customer-id' => $customerId,
]);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
return ['success' => false, 'message' => 'Could not fetch customer details.'];
}
return ['success' => true, 'customer' => $data];
} catch (\Throwable $e) {
Log::error('HostingService: getCustomerDetails failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not fetch customer details.'];
}
}
/**
* Search hosting orders for a specific customer.
*
* @return array{success: bool, orders?: array, total?: int, message?: string}
*/
public function searchOrdersByCustomer(string $customerId, int $page = 1, int $perPage = 50): array
{
$this->lastSyncError = null;
try {
$params = [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'no-of-records' => $perPage,
'page-no' => $page,
'customer-id' => $customerId,
];
$response = Http::timeout(15)->get($this->endpoint('search'), $params);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
$message = $this->responseMessage($response, $data, 'Failed to search hosting orders.');
$this->lastSyncError = $message;
Log::warning('HostingService: hosting search unsuccessful response', [
'customer_id' => $customerId,
'status' => $response->status(),
'body' => $this->sanitizeErrorText((string) $response->body()),
]);
return ['success' => false, 'message' => $message];
}
$total = (int) ($data['recsonpage'] ?? 0);
$orders = [];
$recordCount = (int) ($data['recsindb'] ?? 0);
for ($i = 1; $i <= $total; $i++) {
if (isset($data[(string) $i])) {
$orders[] = $this->normalizeOrderData($data[(string) $i]);
}
}
return [
'success' => true,
'orders' => $orders,
'total' => $recordCount,
];
} catch (\Throwable $e) {
Log::error('HostingService: searchOrdersByCustomer failed', ['error' => $e->getMessage()]);
$message = $this->syncErrorMessage($e, 'Could not fetch hosting orders.');
$this->lastSyncError = $message;
return ['success' => false, 'message' => $message];
}
}
/**
* Sync all orders for a specific RC customer into a Ladill user account.
*
* @return int Number of orders synced.
*/
public function syncCustomerOrders(string $customerId, int $userId): int
{
$this->lastSyncError = null;
$page = 1;
$synced = 0;
do {
$result = $this->searchOrdersByCustomer($customerId, $page, 50);
if (! ($result['success'] ?? false) || empty($result['orders'])) {
break;
}
foreach ($result['orders'] as $order) {
$payload = is_array($order) ? ($order['raw'] ?? $order) : [];
$orderId = (string) ($payload['orderid'] ?? $payload['entityid'] ?? $order['order_id'] ?? '');
if ($orderId !== '') {
$details = $this->getOrderDetails($orderId);
if (($details['success'] ?? false) && is_array($details['order'] ?? null)) {
$payload = (array) ($details['order']['raw'] ?? $details['order']);
}
}
$this->syncOrderToLocal((array) $payload, $userId);
$synced++;
}
$page++;
} while ($synced < ($result['total'] ?? 0));
return $synced;
}
/**
* Search hosting orders under the reseller account.
*
* @return array{success: bool, orders?: array, total?: int, message?: string}
*/
public function searchOrders(int $page = 1, int $perPage = 25, ?string $domainName = null, ?string $status = null): array
{
try {
$params = [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'no-of-records' => $perPage,
'page-no' => $page,
];
if ($domainName) {
$params['domain-name'] = $domainName;
}
if ($status) {
$params['status'] = $status;
}
if ($this->customerId) {
$params['customer-id'] = $this->customerId;
}
$response = Http::timeout(15)->get($this->endpoint('search'), $params);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
$message = $this->responseMessage($response, $data, 'Failed to search hosting orders.');
Log::warning('HostingService: hosting search unsuccessful response', [
'status' => $response->status(),
'body' => $this->sanitizeErrorText((string) $response->body()),
]);
return ['success' => false, 'message' => $message];
}
$total = (int) ($data['recsonpage'] ?? 0);
$orders = [];
// RC returns numbered keys for each record
$recordCount = (int) ($data['recsindb'] ?? 0);
for ($i = 1; $i <= $total; $i++) {
if (isset($data[(string) $i])) {
$orders[] = $this->normalizeOrderData($data[(string) $i]);
}
}
return [
'success' => true,
'orders' => $orders,
'total' => $recordCount,
];
} catch (\Throwable $e) {
Log::error('HostingService: searchOrders failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not fetch hosting orders.'];
}
}
/**
* Get details of a specific hosting order by order ID.
*
* @return array{success: bool, order?: array, message?: string}
*/
public function getOrderDetails(string $orderId): array
{
try {
$response = Http::timeout(15)->get($this->endpoint('details'), [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'order-id' => $orderId,
]);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
return ['success' => false, 'message' => 'Failed to fetch order details.'];
}
return [
'success' => true,
'order' => $this->normalizeOrderData($data),
];
} catch (\Throwable $e) {
Log::error('HostingService: getOrderDetails failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not fetch order details.'];
}
}
private function configuredApiUrl(): string
{
$value = trim((string) config('mailinfra.hosting_api_url'));
return $value !== '' ? $value : self::DEFAULT_API_URL;
}
private function endpoint(string $action): string
{
return sprintf('%s/%s/%s.json', $this->apiUrl, $this->hostingEndpoint, $action);
}
/**
* Order a new single-domain Linux hosting plan.
*
* @return array{success: bool, order_id?: string, message?: string}
*/
public function orderHosting(string $domainName, string $planId, string $customerId, int $months = 1): array
{
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('add'), [
'domain-name' => $domainName,
'customer-id' => $customerId,
'months' => $months,
'plan-id' => $planId,
'invoice-option' => 'NoInvoice',
]));
$data = $response->json();
if ($response->failed()) {
$message = $data['message'] ?? $data['error'] ?? 'Hosting order failed.';
Log::warning('HostingService: orderHosting failed', [
'domain' => $domainName,
'status' => $response->status(),
'response' => $data,
]);
return ['success' => false, 'message' => $message];
}
Log::info('HostingService: hosting ordered', [
'domain' => $domainName,
'order_id' => $data['entityid'] ?? null,
]);
return [
'success' => true,
'order_id' => (string) ($data['entityid'] ?? ''),
];
} catch (\Throwable $e) {
Log::error('HostingService: orderHosting exception', [
'domain' => $domainName,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Hosting order failed due to a network error.'];
}
}
/**
* Renew a hosting order.
*
* @return array{success: bool, message?: string}
*/
public function renewOrder(string $orderId, int $months = 1, string $invoiceOption = 'NoInvoice'): array
{
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('renew'), [
'order-id' => $orderId,
'months' => $months,
'invoice-option' => $invoiceOption,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? 'Renewal failed.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: renewOrder failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Renewal failed due to a network error.'];
}
}
/**
* @return array{success: bool, message?: string}
*/
public function changePanelPassword(string $orderId, string $password): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('change-password'), [
'order-id' => $orderId,
'new-passwd' => $password,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? $data['error'] ?? 'Could not update the hosting panel password.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: changePanelPassword failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not update the hosting panel password.'];
}
}
/**
* Delete (cancel) a hosting order.
*
* @return array{success: bool, message?: string}
*/
public function deleteOrder(string $orderId): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('delete'), [
'order-id' => $orderId,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? 'Cancellation failed.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: deleteOrder failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Cancellation failed due to a network error.'];
}
}
/**
* Sync a hosting order from ResellerClub into the local database.
*/
public function syncOrderToLocal(array $rcOrder, int $userId, ?int $domainId = null): HostingOrder
{
$raw = is_array($rcOrder['raw'] ?? null) ? (array) $rcOrder['raw'] : [];
return HostingOrder::query()->updateOrCreate(
['rc_order_id' => (string) ($rcOrder['orderid'] ?? $rcOrder['order_id'] ?? $rcOrder['entityid'] ?? Arr::get($raw, 'orders.orderid') ?? Arr::get($raw, 'entity.entityid') ?? $raw['orderid'] ?? $raw['entityid'] ?? '')],
[
'user_id' => $userId,
'domain_id' => $domainId,
'domain_name' => $rcOrder['domain_name'] ?? $rcOrder['domainname'] ?? Arr::get($raw, 'entity.description') ?? Arr::get($raw, 'domainname') ?? Arr::get($raw, 'domain-name') ?? '',
'rc_customer_id' => (string) ($rcOrder['customerid'] ?? $rcOrder['customer_id'] ?? $rcOrder['customer-id'] ?? Arr::get($raw, 'entity.customerid') ?? $raw['customerid'] ?? $raw['customer-id'] ?? ''),
'plan_name' => $rcOrder['plan_name'] ?? $rcOrder['planname'] ?? $rcOrder['productcategory'] ?? Arr::get($raw, 'entitytype.entitytypename') ?? Arr::get($raw, 'entitytype.entitytypekey') ?? $raw['planname'] ?? $raw['productcategory'] ?? null,
'hosting_type' => HostingOrder::TYPE_SINGLE_DOMAIN,
'status' => $this->mapRcStatus($rcOrder['currentstatus'] ?? $rcOrder['orderstatus'] ?? $rcOrder['status'] ?? Arr::get($raw, 'entity.currentstatus') ?? Arr::get($raw, 'orders.orderstatus') ?? 'pending'),
'ip_address' => $rcOrder['ipaddress'] ?? $rcOrder['ip'] ?? $rcOrder['ip_address'] ?? Arr::get($raw, 'server.ipaddress') ?? Arr::get($raw, 'server.ip') ?? $raw['ipaddress'] ?? $raw['ip'] ?? null,
'cpanel_url' => $rcOrder['cpanelurl'] ?? $rcOrder['cpanel_url'] ?? $rcOrder['tempurl'] ?? Arr::get($raw, 'server.cpanelurl') ?? Arr::get($raw, 'server.controlpanelurl') ?? Arr::get($raw, 'server.tempurl') ?? $raw['cpanelurl'] ?? $raw['tempurl'] ?? null,
'cpanel_username' => $rcOrder['cpanelusername'] ?? $rcOrder['cpanel_username'] ?? Arr::get($raw, 'server.cpanelusername') ?? Arr::get($raw, 'server.username') ?? $raw['cpanelusername'] ?? null,
'bandwidth_mb' => $this->unsignedLimitOrNull($rcOrder['bandwidth'] ?? Arr::get($raw, 'plan.bandwidth') ?? $raw['bandwidth'] ?? null),
'disk_space_mb' => $this->unsignedLimitOrNull($rcOrder['diskspace'] ?? Arr::get($raw, 'plan.diskspace') ?? $raw['diskspace'] ?? null),
'expires_at' => $this->hostingTimestamp($rcOrder['endtime'] ?? $rcOrder['end_time'] ?? Arr::get($raw, 'orders.endtime') ?? $raw['endtime'] ?? null),
'provisioned_at' => $this->hostingTimestamp($rcOrder['creationtime'] ?? $rcOrder['creation_time'] ?? Arr::get($raw, 'orders.creationtime') ?? $raw['creationtime'] ?? null),
'meta' => $raw !== [] ? $raw : $rcOrder,
]
);
}
/**
* Sync all orders from ResellerClub for a user.
* If user has a linked RC account, only syncs that customer's orders.
* Otherwise syncs all orders under the reseller account.
*
* @return int Number of orders synced.
*/
public function syncAllOrders(int $userId, ?string $rcCustomerId = null): int
{
if ($rcCustomerId) {
return $this->syncCustomerOrders($rcCustomerId, $userId);
}
$page = 1;
$synced = 0;
do {
$result = $this->searchOrders($page, 50);
if (! ($result['success'] ?? false) || empty($result['orders'])) {
break;
}
foreach ($result['orders'] as $order) {
$this->syncOrderToLocal($order, $userId);
$synced++;
}
$page++;
} while ($synced < ($result['total'] ?? 0));
return $synced;
}
private function normalizeOrderData(array $data): array
{
return [
'order_id' => (string) ($data['orderid'] ?? $data['entityid'] ?? $data['order-id'] ?? $data['entity-id'] ?? Arr::get($data, 'orders.orderid') ?? Arr::get($data, 'entity.entityid') ?? ''),
'domain_name' => $data['domainname'] ?? $data['domain-name'] ?? $data['domain_name'] ?? $data['domain'] ?? $data['hostname'] ?? $data['description'] ?? Arr::get($data, 'entity.description') ?? '',
'customer_id' => (string) ($data['customerid'] ?? $data['customer-id'] ?? $data['customer_id'] ?? Arr::get($data, 'entity.customerid') ?? ''),
'plan_name' => $data['planname'] ?? $data['plan_name'] ?? $data['productcategory'] ?? $data['productkey'] ?? $data['description'] ?? Arr::get($data, 'entitytype.entitytypename') ?? Arr::get($data, 'entitytype.entitytypekey') ?? '',
'status' => $data['currentstatus'] ?? $data['orderstatus'] ?? $data['status'] ?? Arr::get($data, 'entity.currentstatus') ?? Arr::get($data, 'orders.orderstatus') ?? 'Unknown',
'ip_address' => $data['ipaddress'] ?? $data['ip_address'] ?? $data['ip'] ?? Arr::get($data, 'server.ipaddress') ?? Arr::get($data, 'server.ip') ?? null,
'cpanel_url' => $data['cpanelurl'] ?? $data['cpanel_url'] ?? $data['controlpanelurl'] ?? $data['tempurl'] ?? Arr::get($data, 'server.cpanelurl') ?? Arr::get($data, 'server.controlpanelurl') ?? Arr::get($data, 'server.tempurl') ?? null,
'cpanel_username' => $data['cpanelusername'] ?? $data['cpanel_username'] ?? $data['username'] ?? Arr::get($data, 'server.cpanelusername') ?? Arr::get($data, 'server.username') ?? null,
'bandwidth' => $data['bandwidth'] ?? $data['bandwidth_mb'] ?? Arr::get($data, 'plan.bandwidth') ?? null,
'disk_space' => $data['diskspace'] ?? $data['disk_space_mb'] ?? Arr::get($data, 'plan.diskspace') ?? null,
'creation_time' => $data['creationtime'] ?? $data['creation_time'] ?? Arr::get($data, 'orders.creationtime') ?? null,
'end_time' => $data['endtime'] ?? $data['end_time'] ?? $data['expirytime'] ?? Arr::get($data, 'orders.endtime') ?? null,
'raw' => $data,
];
}
private function mapRcStatus(string $rcStatus): string
{
return match (strtolower($rcStatus)) {
'active', 'active (paid)' => HostingOrder::STATUS_ACTIVE,
'suspended' => HostingOrder::STATUS_SUSPENDED,
'deleted', 'cancelled' => HostingOrder::STATUS_CANCELLED,
'expired' => HostingOrder::STATUS_EXPIRED,
'pending', 'inactive' => HostingOrder::STATUS_PENDING,
default => HostingOrder::STATUS_PENDING,
};
}
private function syncErrorMessage(\Throwable $e, string $fallback): string
{
$message = $e->getMessage();
if (str_contains($message, 'Could not resolve host')) {
return 'Could not reach the ResellerClub API host. Check server DNS/network access to httpapi.com.';
}
return $fallback;
}
/**
* @param mixed $data
*/
private function responseMessage(Response $response, mixed $data, string $fallback): string
{
if ($this->looksLikeHtmlError($response)) {
return 'The upstream provisioning service temporarily blocked or rejected the request. Please try again in a moment.';
}
if (is_array($data)) {
foreach (['message', 'error', 'description', 'msg'] as $key) {
$value = $this->sanitizeErrorText((string) ($data[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
}
$rawBody = $this->sanitizeErrorText((string) $response->body());
return $rawBody !== '' ? $rawBody : $fallback;
}
private function sanitizeErrorText(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($this->looksLikeHtmlDocument($value)) {
return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.';
}
return trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? '');
}
private function looksLikeHtmlError(Response $response): bool
{
$contentType = strtolower((string) $response->header('Content-Type'));
$body = trim((string) $response->body());
return str_contains($contentType, 'text/html') || $this->looksLikeHtmlDocument($body);
}
private function looksLikeHtmlDocument(string $value): bool
{
$normalized = strtolower(ltrim($value));
return str_starts_with($normalized, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, '<title>')
|| str_contains($normalized, 'cloudflare');
}
private function hostingTimestamp(mixed $value): ?\Carbon\Carbon
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return \Carbon\Carbon::createFromTimestamp((int) $value);
}
try {
return \Carbon\Carbon::parse((string) $value);
} catch (\Throwable) {
return null;
}
}
private function unsignedLimitOrNull(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$normalized = (int) $value;
return $normalized >= 0 ? $normalized : null;
}
}
@@ -0,0 +1,202 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\ServerAgent;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ServerAgentBootstrapService
{
/**
* @return array{agent: ServerAgent, bootstrap_token: string, registration_url: string, heartbeat_url: string}
*/
public function ensureBootstrap(CustomerHostingOrder $order): array
{
$order->loadMissing('serverAgent');
$meta = (array) ($order->meta ?? []);
$agentMeta = (array) ($meta['server_agent'] ?? []);
$bootstrapToken = $this->decryptBootstrapToken((string) ($agentMeta['bootstrap_token_encrypted'] ?? ''));
if ($bootstrapToken === null) {
$bootstrapToken = Str::random(64);
}
$agent = $order->serverAgent ?: new ServerAgent([
'customer_hosting_order_id' => $order->id,
'uuid' => (string) Str::uuid(),
'status' => ServerAgent::STATUS_PENDING_REGISTRATION,
]);
$agent->fill([
'bootstrap_token_hash' => hash('sha256', $bootstrapToken),
]);
$agent->save();
$agentMeta = array_merge($agentMeta, [
'agent_id' => $agent->id,
'agent_uuid' => $agent->uuid,
'bootstrap_token_encrypted' => encrypt($bootstrapToken),
'registration_url' => route('api.server-agents.register'),
'heartbeat_url' => route('api.server-agents.heartbeat'),
'task_progress_url_template' => url('/api/server-agents/tasks/{task}/progress'),
'task_complete_url_template' => url('/api/server-agents/tasks/{task}/complete'),
'task_fail_url_template' => url('/api/server-agents/tasks/{task}/fail'),
]);
$meta['server_agent'] = $agentMeta;
$meta['server_panel_runtime'] = $this->withRuntimeAgentState(
(array) ($meta['server_panel_runtime'] ?? []),
$agent
);
$order->update(['meta' => $meta]);
return [
'agent' => $agent->fresh(),
'bootstrap_token' => $bootstrapToken,
'registration_url' => $agentMeta['registration_url'],
'heartbeat_url' => $agentMeta['heartbeat_url'],
];
}
/**
* @param array<string, mixed> $attributes
* @return array{agent: ServerAgent, access_token: string}
*/
public function register(CustomerHostingOrder $order, string $bootstrapToken, array $attributes): array
{
$bootstrap = $this->ensureBootstrap($order);
$agent = $bootstrap['agent'];
if (! hash_equals((string) $agent->bootstrap_token_hash, hash('sha256', $bootstrapToken))) {
throw ValidationException::withMessages([
'bootstrap_token' => 'The bootstrap token is invalid.',
]);
}
$accessToken = Str::random(80);
$agent->fill([
'status' => ServerAgent::STATUS_ONLINE,
'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname),
'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version),
'platform' => (string) ($attributes['platform'] ?? $agent->platform),
'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? []))),
'metadata' => (array) ($attributes['metadata'] ?? $agent->metadata ?? []),
'access_token_hash' => hash('sha256', $accessToken),
'access_token_issued_at' => now(),
'registered_at' => $agent->registered_at ?? now(),
'last_heartbeat_at' => now(),
'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip),
]);
$agent->save();
$this->syncOrderRuntime($order->fresh(), $agent->fresh());
return [
'agent' => $agent->fresh(),
'access_token' => $accessToken,
];
}
/**
* @param array<string, mixed> $attributes
*/
public function recordHeartbeat(ServerAgent $agent, array $attributes = []): ServerAgent
{
$agent->fill([
'status' => ServerAgent::STATUS_ONLINE,
'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname),
'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version),
'platform' => (string) ($attributes['platform'] ?? $agent->platform),
'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? $agent->capabilities ?? []))),
'metadata' => array_replace((array) ($agent->metadata ?? []), (array) ($attributes['metadata'] ?? [])),
'last_heartbeat_at' => now(),
'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip),
]);
$agent->save();
if ($agent->relationLoaded('order')) {
$this->syncOrderRuntime($agent->order, $agent);
} else {
$this->syncOrderRuntime($agent->order()->firstOrFail(), $agent);
}
return $agent->fresh();
}
public function syncOrderRuntime(CustomerHostingOrder $order, ServerAgent $agent): void
{
$meta = (array) ($order->meta ?? []);
$meta['server_agent'] = array_merge((array) ($meta['server_agent'] ?? []), [
'agent_id' => $agent->id,
'agent_uuid' => $agent->uuid,
'status' => $agent->status,
'hostname' => $agent->hostname,
'agent_version' => $agent->agent_version,
'platform' => $agent->platform,
'capabilities' => (array) ($agent->capabilities ?? []),
'registered_at' => optional($agent->registered_at)->toIso8601String(),
'last_heartbeat_at' => optional($agent->last_heartbeat_at)->toIso8601String(),
]);
$meta['server_panel_runtime'] = $this->withRuntimeAgentState(
(array) ($meta['server_panel_runtime'] ?? []),
$agent
);
$order->update(['meta' => $meta]);
}
/**
* @param array<string, mixed> $runtime
* @return array<string, mixed>
*/
private function withRuntimeAgentState(array $runtime, ServerAgent $agent): array
{
$current = array_values(array_unique(array_merge(
(array) ($runtime['current_capabilities'] ?? []),
['Agent registration', 'Async task dispatch']
)));
if ($agent->status === ServerAgent::STATUS_ONLINE) {
$current = array_values(array_unique(array_merge($current, [
'Files',
'Databases',
'Domains',
'SSL',
'PHP',
'Cron',
'Logs',
'Site management',
])));
}
$runtime['hosting_panel_ready'] = $agent->registered_at !== null;
$runtime['future_hosting_panel_status'] = $agent->registered_at !== null ? 'agent_registered' : ($runtime['future_hosting_panel_status'] ?? 'planned');
$runtime['agent_status'] = $agent->status;
$runtime['current_capabilities'] = $current;
$runtime['secondary_message'] = $agent->registered_at !== null
? 'The server agent is registered. Managed tasks for files, databases, domains, SSL, PHP, cron, logs, and site actions are now queued through Ladill.'
: ($runtime['secondary_message'] ?? null);
return $runtime;
}
private function decryptBootstrapToken(string $encryptedToken): ?string
{
if ($encryptedToken === '') {
return null;
}
try {
$token = decrypt($encryptedToken);
} catch (\Throwable) {
return null;
}
return is_string($token) && $token !== '' ? $token : null;
}
}
@@ -0,0 +1,229 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Services\Hosting\Providers\ContaboProvider;
class ServerAgentInstallerService
{
public function __construct(
private ContaboProvider $contaboProvider,
private ServerAgentReleaseService $releaseService,
) {}
public function augmentProvisioningConfig(CustomerHostingOrder $order, array $provisioning, array $bootstrap): array
{
if (! (bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
return $provisioning;
}
$provisioning['user_data'] = $this->buildCloudInit(
$order,
$bootstrap,
(string) ($provisioning['user_data'] ?? '')
);
return $provisioning;
}
public function buildCloudInit(CustomerHostingOrder $order, array $bootstrap, string $existingCloudInit = ''): string
{
$parsed = $this->parseGeneratedCloudInit($existingCloudInit);
$release = $this->releaseService->ensureRelease();
$releaseUrl = $this->releaseService->temporaryDownloadUrl($release['version']);
$envFile = implode("\n", [
'ORDER_ID='.$order->id,
'BOOTSTRAP_TOKEN='.$bootstrap['bootstrap_token'],
'REGISTRATION_URL='.$bootstrap['registration_url'],
'HEARTBEAT_URL='.$bootstrap['heartbeat_url'],
'AGENT_RELEASE_VERSION='.$release['version'],
'',
]);
$packages = array_values(array_unique(array_merge($parsed['packages'], [
'curl',
'wget',
'jq',
'ca-certificates',
'git',
'unzip',
'zip',
])));
$runCommands = array_merge([
$this->managedStackInstallCommand($releaseUrl, $release['checksum_sha256']),
'bash -lc \'mkdir -p /etc/ladill /var/lib/ladill-server-agent /var/www /tmp/ladill-server-agent-release\'',
'bash -lc \'cd /tmp/ladill-server-agent-release && bash install.sh\'',
'bash -lc \'chmod 0600 /etc/ladill/server-agent.env\'',
$this->managedStackEnableServicesCommand(),
], $parsed['run_commands']);
return $this->contaboProvider->generateCloudInit([
'packages' => $packages,
'write_files' => [
[
'path' => '/etc/ladill/server-agent.env',
'permissions' => '0600',
'owner' => 'root:root',
'encoding' => 'b64',
'content' => base64_encode($envFile),
],
],
'run_commands' => $runCommands,
]);
}
/**
* @return array{packages: array<int, string>, run_commands: array<int, string>}
*/
private function parseGeneratedCloudInit(string $cloudInit): array
{
$packages = [];
$runCommands = [];
$section = null;
foreach (preg_split("/\r\n|\n|\r/", $cloudInit) ?: [] as $line) {
$trimmed = trim($line);
if ($trimmed === '' || $trimmed === '#cloud-config') {
continue;
}
if (! str_starts_with($line, ' ')) {
$section = match ($trimmed) {
'packages:' => 'packages',
'runcmd:' => 'runcmd',
default => null,
};
continue;
}
if ($section === 'packages' && str_starts_with($trimmed, '- ')) {
$packages[] = trim(substr($trimmed, 2));
}
if ($section === 'runcmd' && str_starts_with($trimmed, '- ')) {
$runCommands[] = trim(substr($trimmed, 2));
}
}
return [
'packages' => array_values(array_filter($packages)),
'run_commands' => array_values(array_filter($runCommands)),
];
}
private function managedStackInstallCommand(string $releaseUrl, string $checksum): string
{
$script = str_replace(
['__RELEASE_URL__', '__CHECKSUM__'],
[$releaseUrl, $checksum],
<<<'BASH'
set -e
detect_pm() {
if command -v apt-get >/dev/null 2>&1; then
echo apt-get
return 0
fi
if command -v dnf >/dev/null 2>&1; then
echo dnf
return 0
fi
if command -v yum >/dev/null 2>&1; then
echo yum
return 0
fi
if command -v zypper >/dev/null 2>&1; then
echo zypper
return 0
fi
return 1
}
install_composer_if_missing() {
if command -v composer >/dev/null 2>&1; then
return 0
fi
curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php
php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm -f /tmp/composer-setup.php
}
pm="$(detect_pm || true)"
if [ -z "${pm}" ]; then
echo "Ladill managed stack currently supports apt, dnf, yum, or zypper based Linux distributions only." >&2
exit 1
fi
case "${pm}" in
apt-get)
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y nginx mariadb-server php-fpm php-cli php-mysql php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath certbot jq curl wget ca-certificates cron composer git unzip zip bubblewrap
;;
dnf)
dnf install -y epel-release || true
dnf install -y nginx mariadb-server php-fpm php-cli php-mysqlnd certbot jq curl wget ca-certificates cronie git unzip zip bubblewrap
dnf install -y php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath || true
;;
yum)
yum install -y epel-release || true
yum install -y nginx mariadb-server php-fpm php-cli php-mysqlnd certbot jq curl wget ca-certificates cronie git unzip zip bubblewrap
yum install -y php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath || true
;;
zypper)
zypper --non-interactive refresh
zypper --non-interactive install -y nginx mariadb php8 php8-fpm php8-mysql certbot jq curl wget ca-certificates cron git unzip zip bubblewrap
zypper --non-interactive install -y php8-curl php8-xmlreader php8-xmlwriter php8-zip php8-mbstring php8-gd php8-intl php8-bcmath || true
;;
esac
install_composer_if_missing
mkdir -p /etc/ladill /var/lib/ladill-server-agent /var/www /tmp/ladill-server-agent-release
curl -fsSL "__RELEASE_URL__" -o /tmp/ladill-server-agent-release/agent.tar.gz
echo "__CHECKSUM__ /tmp/ladill-server-agent-release/agent.tar.gz" | sha256sum -c -
tar -xzf /tmp/ladill-server-agent-release/agent.tar.gz -C /tmp/ladill-server-agent-release
BASH
);
return 'bash -lc '.escapeshellarg($script);
}
private function managedStackEnableServicesCommand(): string
{
return 'bash -lc '.escapeshellarg(<<<'BASH'
set -e
service_exists() {
systemctl list-unit-files "$1.service" --no-legend 2>/dev/null | grep -q "^$1\.service"
}
enable_if_present() {
for service in "$@"; do
if [ -n "$service" ] && service_exists "$service"; then
systemctl enable --now "$service" >/dev/null 2>&1 || true
return 0
fi
done
return 1
}
chmod 0600 /etc/ladill/server-agent.env
systemctl daemon-reload
enable_if_present nginx
enable_if_present mariadb mysql
enable_if_present cron crond
enable_if_present php-fpm php8-fpm php8.4-fpm php8.3-fpm php8.2-fpm php8.1-fpm php8.0-fpm
enable_if_present ladill-server-agent
BASH
);
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Services\Hosting;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\URL;
use Phar;
use PharData;
use RuntimeException;
class ServerAgentReleaseService
{
public function currentVersion(): string
{
return (string) config('hosting.server_agent.release_version', '0.2.0');
}
/**
* @return array{version: string, archive_path: string, checksum_sha256: string, filename: string}
*/
public function ensureRelease(?string $version = null): array
{
$version ??= $this->currentVersion();
$releaseDirectory = storage_path('app/server-agent/releases/'.$version);
$packageDirectory = $releaseDirectory.'/package';
$archiveFilename = "ladill-server-agent-{$version}.tar.gz";
$archivePath = $releaseDirectory.'/'.$archiveFilename;
$tarPath = $releaseDirectory."/ladill-server-agent-{$version}.tar";
File::ensureDirectoryExists($packageDirectory);
File::ensureDirectoryExists($releaseDirectory);
$renderedFiles = [
'install.sh' => $this->renderInstallScript(),
'ladill-server-agent' => $this->renderAgentScript($version),
'ladill-server-agent.service' => $this->renderTemplate(resource_path('server-agent/ladill-server-agent.service.tpl')),
'manifest.json' => json_encode([
'name' => 'ladill-server-agent',
'version' => $version,
'built_at' => now()->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n",
];
foreach ($renderedFiles as $filename => $contents) {
File::put($packageDirectory.'/'.$filename, $contents);
}
if (! File::exists($archivePath)) {
if (File::exists($tarPath)) {
File::delete($tarPath);
}
try {
$phar = new PharData($tarPath);
foreach (array_keys($renderedFiles) as $filename) {
$phar->addFile($packageDirectory.'/'.$filename, $filename);
}
$phar->compress(Phar::GZ);
unset($phar);
} catch (\Throwable $e) {
throw new RuntimeException('Unable to build the server agent release archive.', previous: $e);
} finally {
if (File::exists($tarPath)) {
File::delete($tarPath);
}
}
}
if (! File::exists($archivePath)) {
throw new RuntimeException('The server agent release archive was not created.');
}
return [
'version' => $version,
'archive_path' => $archivePath,
'checksum_sha256' => hash_file('sha256', $archivePath),
'filename' => $archiveFilename,
];
}
public function temporaryDownloadUrl(?string $version = null, ?\DateTimeInterface $expiresAt = null): string
{
$version ??= $this->currentVersion();
$expiresAt ??= now()->addMinutes((int) config('hosting.server_agent.signed_release_ttl_minutes', 10080));
return URL::temporarySignedRoute('server-agent.releases.download', $expiresAt, [
'version' => $version,
]);
}
private function renderAgentScript(string $version): string
{
return str_replace(
['{{AGENT_VERSION}}', '{{HEARTBEAT_INTERVAL_SECONDS}}'],
[
$version,
(string) config('hosting.server_agent.heartbeat_interval_seconds', 15),
],
$this->renderTemplate(resource_path('server-agent/ladill-server-agent.sh.tpl'))
);
}
private function renderInstallScript(): string
{
return $this->renderTemplate(resource_path('server-agent/install.sh.tpl'));
}
private function renderTemplate(string $path): string
{
$contents = @file_get_contents($path);
if ($contents === false) {
throw new RuntimeException("Unable to load server agent release template: {$path}");
}
return $contents;
}
}
+942
View File
@@ -0,0 +1,942 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingProduct;
use App\Services\Hosting\Providers\ContaboProvider;
use Illuminate\Validation\ValidationException;
class ServerOrderService
{
private const INSTALL_LATER_IMAGE = '__install_later__';
private const PASSWORD_PATTERN = '/^((?=.*?[A-Z])(?=.*?[a-z]))(((?=(?:.*\d){1})(?=(?:.*[!@#$^&*?_~]){2,}))|((?=(?:.*\d){3})(?=.*?[!@#$^&*?_~]))).{8,}$/';
private const APPLICATION_NAME_MAPPINGS = [
'ipfs_node' => ['IPFS', 'ipfs'],
'flux_node' => ['Flux', 'flux', 'FluxOS'],
'horizon_node' => ['Horizen', 'horizon', 'Zen'],
'ethereum_node' => ['Ethereum', 'eth-docker'],
'bitcoin_node' => ['Bitcoin'],
];
private ?array $availableImages = null;
public function __construct(
private HostingPricingService $pricing,
private ContaboProvider $contaboProvider,
private ServerPanelCapabilityService $panelCapabilities,
) {}
public function passwordPattern(): string
{
return self::PASSWORD_PATTERN;
}
/**
* @return array<string, mixed>
*/
public function frontendPayload(HostingProduct $product): array
{
$defaults = $this->defaultSelections($product);
return [
'id' => $product->id,
'name' => $product->name,
'currency' => $product->display_currency,
'prices' => $this->baseCyclePrices($product),
'setup_fees' => $this->setupFeesByCycle($product),
'catalog' => $this->catalogForProduct($product),
'defaults' => $defaults,
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $defaults, $this->catalogForProduct($product)),
];
}
/**
* @return array<string, mixed>
*/
public function catalogForProduct(HostingProduct $product): array
{
$images = $this->imageOptions($product);
$defaultImage = $this->defaultImageOption($images)['value'] ?? null;
$defaultImageMeta = $defaultImage ? $this->findOption($images, $defaultImage) : null;
return [
'images' => $images,
'regions' => $this->optionCollection($product, (array) config('hosting.server_order.regions', [])),
'licenses' => $this->optionCollection($product, (array) config('hosting.server_order.licenses', [])),
'applications' => $this->applicationOptions($product),
'additional_ip' => $this->optionCollection($product, (array) config('hosting.server_order.additional_ip', [])),
'private_networking' => $this->optionCollection($product, (array) config('hosting.server_order.private_networking', [])),
'storage_types' => $this->optionCollection($product, (array) config('hosting.server_order.storage_types', [])),
'object_storage' => $this->optionCollection($product, (array) config('hosting.server_order.object_storage', [])),
'linux_default_users' => $this->defaultUserOptions($product, 'linux'),
'windows_default_users' => $this->defaultUserOptions($product, 'windows'),
'default_image_os_family' => $defaultImageMeta['os_family'] ?? 'linux',
];
}
/**
* @param array<string, mixed> $input
* @return array{selection: array<string, mixed>, quote: array<string, mixed>}
*/
public function selectionAndQuote(HostingProduct $product, array $input): array
{
$catalog = $this->catalogForProduct($product);
$defaults = $this->defaultSelections($product);
$billingCycle = (string) ($input['billing_cycle'] ?? CustomerHostingOrder::CYCLE_MONTHLY);
$selection = [
'image' => $this->validatedOptionValue($catalog['images'], (string) ($input['image'] ?? $defaults['image'])),
'region' => $this->validatedOptionValue($catalog['regions'], (string) ($input['region'] ?? $defaults['region'])),
'license' => $this->validatedOptionValue($catalog['licenses'], (string) ($input['license'] ?? $defaults['license'])),
'application' => $this->validatedOptionValue($catalog['applications'], (string) ($input['application'] ?? $defaults['application'])),
'additional_ip' => $this->validatedOptionValue($catalog['additional_ip'], (string) ($input['additional_ip'] ?? $defaults['additional_ip'])),
'private_networking' => $this->validatedOptionValue($catalog['private_networking'], (string) ($input['private_networking'] ?? $defaults['private_networking'])),
'storage_type' => $this->validatedOptionValue($catalog['storage_types'], (string) ($input['storage_type'] ?? $defaults['storage_type'])),
'object_storage' => $this->validatedOptionValue($catalog['object_storage'], (string) ($input['object_storage'] ?? $defaults['object_storage'])),
];
$image = $this->findOption($catalog['images'], $selection['image']);
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
$selection['default_user'] = $this->validatedOptionValue(
$defaultUsers,
(string) ($input['default_user'] ?? $image['default_user'] ?? array_key_first($defaultUsers))
);
$this->assertCompatibleSelections($selection, $catalog);
$quote = $this->quote($product, $selection, $billingCycle, $catalog);
return [
'selection' => $selection,
'quote' => $quote,
'selection_summary' => $this->selectionSummary($product, $selection, $catalog),
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
];
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed>|null $catalog
* @return array<string, mixed>
*/
public function quote(HostingProduct $product, array $selection, string $billingCycle, ?array $catalog = null): array
{
$catalog ??= $this->catalogForProduct($product);
$basePrices = $this->baseCyclePrices($product);
$baseTotal = (float) ($basePrices[$billingCycle] ?? 0);
$lines = [[
'code' => 'base_plan',
'label' => $product->name,
'amount' => round($baseTotal, 2),
'automated' => true,
]];
$manualFollowUp = [];
$setupFee = $this->setupFeeForCycle($product, $billingCycle);
if ($setupFee > 0) {
$lines[] = [
'code' => 'setup_fee',
'label' => $this->setupFeeLabel($product, $billingCycle),
'amount' => round($setupFee, 2),
'automated' => true,
'one_time' => true,
];
}
foreach ([
'image' => 'images',
'region' => 'regions',
'license' => 'licenses',
'application' => 'applications',
'additional_ip' => 'additional_ip',
'private_networking' => 'private_networking',
'storage_type' => 'storage_types',
'object_storage' => 'object_storage',
] as $selectionKey => $catalogKey) {
$value = (string) ($selection[$selectionKey] ?? '');
if ($value === '') {
continue;
}
$option = $this->findOption($catalog[$catalogKey], $value);
if (! $option) {
continue;
}
$amount = (float) ($option['prices'][$billingCycle] ?? 0);
if ($amount <= 0) {
continue;
}
$lines[] = [
'code' => $selectionKey,
'label' => (string) ($option['label'] ?? $value),
'amount' => round($amount, 2),
'automated' => (bool) ($option['automated'] ?? false),
];
if (! ($option['automated'] ?? false)) {
$manualFollowUp[] = (string) ($option['label'] ?? $value);
}
}
$total = round(collect($lines)->sum('amount'), 2);
return [
'currency' => $product->display_currency,
'billing_cycle' => $billingCycle,
'months' => $this->cycleMonths($billingCycle),
'base_total' => round($baseTotal, 2),
'line_items' => $lines,
'total' => $total,
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
];
}
/**
* @param array<string, mixed> $selection
* @return array<string, mixed>
*/
public function provisioningConfig(HostingProduct $product, array $selection, string $serverName, string $billingCycle): array
{
$catalog = $this->catalogForProduct($product);
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
$region = $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''));
$additionalIp = $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''));
$privateNetworking = $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''));
$storageType = $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''));
$objectStorage = $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''));
$addOns = [];
foreach ([$image, $additionalIp, $privateNetworking, $storageType] as $option) {
$addOnKey = (string) ($option['add_on'] ?? '');
if ($addOnKey !== '') {
$addOns[$addOnKey] = new \stdClass();
}
}
$manualFollowUp = [];
foreach ([$license, $application, $storageType, $objectStorage] as $option) {
if ($option && ! ($option['automated'] ?? false)) {
$manualFollowUp[] = (string) ($option['label'] ?? $option['value'] ?? 'Option');
}
}
$applicationId = $application['application_id'] ?? null;
$userData = $this->cloudInitForOption((array) $application, (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')));
return [
'product_id' => $product->contabo_product_id,
'image_id' => ! ($image['is_install_later'] ?? false) ? ($image['image_id'] ?? $selection['image'] ?? config('hosting.vps.default_image')) : null,
'region' => (string) ($region['value'] ?? $selection['region'] ?? $product->contabo_region ?? config('hosting.vps.default_region', 'EU')),
'display_name' => $serverName,
'default_user' => (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')),
'license' => $license['license'] ?? null,
'panel' => $license['panel'] ?? null,
'application_id' => is_string($applicationId) && $applicationId !== '' ? $applicationId : null,
'user_data' => $userData,
'period' => $this->cycleMonths($billingCycle),
'add_ons' => $addOns,
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
];
}
/**
* @return array<string, mixed>
*/
public function defaultSelections(HostingProduct $product): array
{
$catalog = $this->catalogForProduct($product);
$defaultImage = $catalog['images'][0]['value'] ?? null;
$defaultImageMeta = $defaultImage ? $this->findOption($catalog['images'], $defaultImage) : null;
$defaultUserOptions = $this->defaultUserOptions($product, (string) ($defaultImageMeta['os_family'] ?? 'linux'));
return [
'image' => $defaultImage,
'region' => (string) ($catalog['regions'][0]['value'] ?? 'EU'),
'license' => 'none',
'application' => 'none',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
'default_user' => (string) ($defaultUserOptions[0]['value'] ?? 'root'),
];
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed>|null $catalog
* @return array<int, array<string, mixed>>
*/
public function selectionSummary(HostingProduct $product, array $selection, ?array $catalog = null): array
{
$catalog ??= $this->catalogForProduct($product);
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
return array_values(array_filter([
$this->selectionSummaryItem('image', 'Image', $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''))),
$this->selectionSummaryItem('region', 'Region', $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''))),
$this->selectionSummaryItem('license', 'License', $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''))),
$this->selectionSummaryItem('application', 'Preinstalled App', $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''))),
$this->selectionSummaryItem('default_user', 'Default User', $this->findOption($defaultUsers, (string) ($selection['default_user'] ?? '')), true),
$this->selectionSummaryItem('additional_ip', 'IP Address', $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''))),
$this->selectionSummaryItem('private_networking', 'Private Networking', $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''))),
$this->selectionSummaryItem('storage_type', 'Storage Type', $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''))),
$this->selectionSummaryItem('object_storage', 'Object Storage', $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''))),
]));
}
/**
* @return array<string, float>
*/
public function baseCyclePrices(HostingProduct $product): array
{
$cycles = [
CustomerHostingOrder::CYCLE_MONTHLY,
CustomerHostingOrder::CYCLE_QUARTERLY,
CustomerHostingOrder::CYCLE_SEMIANNUAL,
CustomerHostingOrder::CYCLE_YEARLY,
];
$prices = [];
foreach ($cycles as $cycle) {
$price = $product->getPriceForCycle($cycle);
if ($price !== null) {
$prices[$cycle] = (float) $price;
}
}
return $prices;
}
/**
* @return array<string, float>
*/
public function setupFeesByCycle(HostingProduct $product): array
{
$fees = [];
foreach (array_keys($this->baseCyclePrices($product)) as $cycle) {
$fees[$cycle] = $this->setupFeeForCycle($product, $cycle);
}
return $fees;
}
public function setupFeeForCycle(HostingProduct $product, string $billingCycle): float
{
if (! $product->requiresContabo()) {
return 0.0;
}
$productId = trim((string) $product->contabo_product_id);
if ($productId === '') {
return 0.0;
}
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
if (! in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)) {
continue;
}
if (! in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)) {
continue;
}
if (isset($rule['fixed_eur_by_cycle'])) {
$eurAmount = (float) ($rule['fixed_eur_by_cycle'][$billingCycle] ?? 0);
if ($eurAmount <= 0) {
return 0.0;
}
$eurToGhs = app(\App\Services\ExchangeRateService::class)->getEurToGhs();
$margin = $this->pricing->getMargin($product->category);
$ghs = $eurAmount * $eurToGhs * (1 + ($margin / 100));
return round($ghs / 5) * 5;
}
$monthlyPrice = (float) $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY);
$multiplier = (float) ($rule['monthly_price_multiplier'] ?? 1);
return round($monthlyPrice * $multiplier, 2);
}
return 0.0;
}
private function setupFeeLabel(HostingProduct $product, string $billingCycle): string
{
$productId = trim((string) $product->contabo_product_id);
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
if (
in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)
&& in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)
) {
return (string) ($rule['label'] ?? 'One-time setup fee');
}
}
return 'One-time setup fee';
}
public function cycleMonths(string $billingCycle): int
{
return match ($billingCycle) {
CustomerHostingOrder::CYCLE_MONTHLY => 1,
CustomerHostingOrder::CYCLE_QUARTERLY => 3,
CustomerHostingOrder::CYCLE_SEMIANNUAL => 6,
CustomerHostingOrder::CYCLE_YEARLY => 12,
CustomerHostingOrder::CYCLE_BIENNIAL => 24,
default => 1,
};
}
/**
* @param array<int, array<string, mixed>> $options
* @return array<string, mixed>|null
*/
private function findOption(array $options, ?string $value): ?array
{
foreach ($options as $option) {
if ((string) ($option['value'] ?? '') === (string) $value) {
return $option;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $options
*/
private function validatedOptionValue(array $options, string $value): string
{
return $this->findOption($options, $value)['value'] ?? (string) ($options[0]['value'] ?? '');
}
/**
* @param array<string, array<string, mixed>> $options
* @return array<int, array<string, mixed>>
*/
private function optionCollection(HostingProduct $product, array $options): array
{
$items = [];
foreach ($options as $value => $option) {
if (! $this->optionIsAvailable($option, (string) $value)) {
continue;
}
$items[] = [
'value' => (string) $value,
'label' => (string) ($option['label'] ?? $value),
'description' => (string) ($option['description'] ?? ''),
'monthly_usd' => (float) ($option['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($option['monthly_usd'] ?? 0)),
'license' => $option['license'] ?? null,
'panel' => $option['panel'] ?? null,
'application_id' => $option['application_id'] ?? null,
'cloud_init_preset' => $option['cloud_init_preset'] ?? null,
'add_on' => $option['add_on'] ?? null,
'compatible_os_families' => $this->normalizedOsFamilies($option['compatible_os_families'] ?? []),
'requires_image' => (bool) ($option['requires_image'] ?? false),
'requires_managed_stack_image' => (bool) ($option['requires_managed_stack_image'] ?? false),
'automated' => (bool) ($option['automated'] ?? false),
];
}
return $items;
}
/**
* @return array<int, array<string, mixed>>
*/
private function imageOptions(HostingProduct $product): array
{
if ($this->availableImages !== null) {
return $this->availableImages;
}
$images = [];
try {
foreach ($this->contaboProvider->getAvailableImages() as $image) {
$rule = $this->matchImageRule($image);
$images[] = [
'value' => (string) $image['id'],
'label' => (string) ($image['name'] ?? ($rule['label'] ?? 'Image')),
'description' => (string) ($image['description'] ?? ($rule['label'] ?? '')),
'image_id' => (string) $image['id'],
'os_family' => (string) ($rule['os_family'] ?? 'linux'),
'default_user' => (string) ($rule['default_user'] ?? 'root'),
'monthly_usd' => (float) ($rule['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($rule['monthly_usd'] ?? 0)),
'automated' => true,
'add_on' => ! empty($rule['requires_custom_image_addon']) ? 'customImage' : null,
];
}
} catch (\Throwable) {
// Fall back to configured image list when the API is unavailable.
}
if ($images !== []) {
usort($images, fn (array $a, array $b): int => strcmp((string) ($a['label'] ?? ''), (string) ($b['label'] ?? '')));
return $this->availableImages = $this->prependInstallLaterImage($product, $images);
}
$fallback = [];
foreach ((array) config('hosting.server_order.fallback_images', []) as $image) {
$fallback[] = [
'value' => (string) ($image['value'] ?? ''),
'label' => (string) ($image['label'] ?? 'Image'),
'description' => (string) ($image['description'] ?? ''),
'image_id' => (string) ($image['value'] ?? ''),
'os_family' => (string) ($image['os_family'] ?? 'linux'),
'default_user' => (string) ($image['default_user'] ?? 'root'),
'monthly_usd' => (float) ($image['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($image['monthly_usd'] ?? 0)),
'automated' => (bool) ($image['automated'] ?? false),
'add_on' => ! empty($image['requires_custom_image_addon']) ? 'customImage' : null,
];
}
return $this->availableImages = $this->prependInstallLaterImage($product, $fallback);
}
/**
* @param array<string, mixed> $image
* @return array<string, mixed>
*/
private function matchImageRule(array $image): array
{
$name = strtolower(trim((string) ($image['name'] ?? '')));
$standardImage = (bool) ($image['standard_image'] ?? true);
$rules = (array) config('hosting.server_order.image_pricing_rules', []);
foreach ($rules as $rule) {
$isCustomRule = (bool) ($rule['custom_image'] ?? false);
if ($isCustomRule && ! $standardImage) {
return $rule;
}
foreach ((array) ($rule['match'] ?? []) as $needle) {
if ($needle !== '' && str_contains($name, strtolower((string) $needle))) {
return $rule;
}
}
}
return [
'label' => $image['name'] ?? 'Image',
'monthly_usd' => 0,
'os_family' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'windows' : 'linux',
'default_user' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'administrator' : 'root',
'requires_custom_image_addon' => ! $standardImage,
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function defaultUserOptions(HostingProduct $product, string $osFamily): array
{
$configured = str_starts_with($osFamily, 'win')
? (array) config('hosting.server_order.windows_default_users', [])
: (array) config('hosting.server_order.linux_default_users', []);
$options = [];
foreach ($configured as $value => $option) {
$options[] = [
'value' => (string) $value,
'label' => (string) ($option['label'] ?? $value),
];
}
return $options;
}
/**
* @return array<int, array<string, mixed>>
*/
private function applicationOptions(HostingProduct $product): array
{
$configApps = (array) config('hosting.server_order.applications', []);
$cachedIds = \Illuminate\Support\Facades\Cache::get('contabo_blockchain_app_ids', []);
if ($this->hasMissingConfiguredApplicationIds($configApps)) {
$resolvedIds = $this->resolveApplicationIdsFromCatalog($configApps);
if ($resolvedIds !== []) {
$cachedIds = array_merge($cachedIds, $resolvedIds);
\Illuminate\Support\Facades\Cache::put('contabo_blockchain_app_ids', $cachedIds, now()->addDays(7));
}
}
foreach ($configApps as $key => $option) {
$appId = $option['application_id'] ?? null;
if (($appId === null || $appId === '') && isset($cachedIds[$key])) {
$configApps[$key]['application_id'] = $cachedIds[$key]['id'];
$appId = $configApps[$key]['application_id'];
}
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
$resolvedAppId = trim((string) $appId);
if ($key !== 'none' && $resolvedAppId === '' && $preset === '') {
$configApps[$key]['allow_unresolved'] = true;
$configApps[$key]['automated'] = false;
$description = trim((string) ($configApps[$key]['description'] ?? ''));
if ($description !== '' && ! str_contains(strtolower($description), 'manual')) {
$configApps[$key]['description'] = $description.' Manual setup may be required after provisioning.';
}
}
}
return $this->optionCollection($product, $configApps);
}
/**
* @param array<string, array<string, mixed>> $configApps
*/
private function hasMissingConfiguredApplicationIds(array $configApps): bool
{
foreach ($configApps as $key => $option) {
if (! array_key_exists($key, self::APPLICATION_NAME_MAPPINGS)) {
continue;
}
if (trim((string) ($option['application_id'] ?? '')) === '') {
return true;
}
}
return false;
}
/**
* @param array<string, array<string, mixed>> $configApps
* @return array<string, array{id: string, name: string, description: string}>
*/
private function resolveApplicationIdsFromCatalog(array $configApps): array
{
try {
$applications = $this->contaboProvider->getAvailableApplications();
} catch (\Throwable) {
return [];
}
$mapped = [];
foreach (self::APPLICATION_NAME_MAPPINGS as $configKey => $searchTerms) {
if (! isset($configApps[$configKey])) {
continue;
}
if (trim((string) ($configApps[$configKey]['application_id'] ?? '')) !== '') {
continue;
}
foreach ($applications as $app) {
$name = strtolower((string) ($app['name'] ?? ''));
foreach ($searchTerms as $term) {
if ($term !== '' && str_contains($name, strtolower($term))) {
$mapped[$configKey] = [
'id' => (string) ($app['id'] ?? ''),
'name' => (string) ($app['name'] ?? $configKey),
'description' => (string) ($app['description'] ?? ''),
];
break 2;
}
}
}
}
return array_filter($mapped, static fn (array $app): bool => $app['id'] !== '');
}
/**
* @param array<int, array<string, mixed>> $images
* @return array<string, mixed>|null
*/
private function defaultImageOption(array $images): ?array
{
foreach ($images as $image) {
if (! ($image['is_install_later'] ?? false)) {
return $image;
}
}
return $images[0] ?? null;
}
/**
* @param array<int, array<string, mixed>> $images
* @return array<int, array<string, mixed>>
*/
private function prependInstallLaterImage(HostingProduct $product, array $images): array
{
array_unshift($images, [
'value' => self::INSTALL_LATER_IMAGE,
'label' => 'Install Later',
'description' => 'Provision the server first and choose the final OS installation afterward.',
'image_id' => null,
'os_family' => 'linux',
'default_user' => 'root',
'monthly_usd' => 0.0,
'prices' => $this->cyclePricesForRecurringUsd($product, 0.0),
'automated' => true,
'add_on' => null,
'is_install_later' => true,
]);
return $images;
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed> $catalog
*/
private function assertCompatibleSelections(array $selection, array $catalog): void
{
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
$errors = [];
if (! $this->optionSupportsImage($license, $image)) {
$errors['license'] = 'The selected control panel is not compatible with this image.';
}
if (! $this->optionSupportsImage($application, $image)) {
$errors['application'] = 'The selected preinstalled app is not compatible with this image.';
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/**
* @param array<string, mixed>|null $option
* @param array<string, mixed>|null $image
*/
private function optionSupportsImage(?array $option, ?array $image): bool
{
if (! $option) {
return true;
}
$imageLabel = strtolower((string) ($image['label'] ?? ''));
$bundledPanel = $this->bundledPanelFromImageLabel($imageLabel);
$optionLicense = strtolower((string) ($option['license'] ?? ''));
if (($option['requires_image'] ?? false) && ($image['is_install_later'] ?? false)) {
return false;
}
if (($option['requires_managed_stack_image'] ?? false) && ! $this->imageSupportsManagedStack($image)) {
return false;
}
if ($bundledPanel !== null && (string) ($option['value'] ?? '') === 'none') {
return false;
}
if ($bundledPanel === 'cpanel' && str_starts_with($optionLicense, 'plesk')) {
return false;
}
if ($bundledPanel === 'plesk' && str_starts_with($optionLicense, 'cpanel')) {
return false;
}
$families = (array) ($option['compatible_os_families'] ?? []);
if ($families === []) {
return true;
}
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
foreach ($families as $family) {
if ($family !== '' && str_starts_with($osFamily, strtolower((string) $family))) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed>|null $image
*/
private function imageSupportsManagedStack(?array $image): bool
{
if (! $image || ($image['is_install_later'] ?? false)) {
return false;
}
$imageLabel = strtolower((string) ($image['label'] ?? ''));
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
if (str_starts_with($osFamily, 'win') || $this->imageIncludesVendorPanel($imageLabel)) {
return false;
}
foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) {
$supportedOsFamily = strtolower((string) ($supported['os_family'] ?? 'linux'));
if ($supportedOsFamily !== '' && $supportedOsFamily !== $osFamily) {
continue;
}
foreach ((array) ($supported['match'] ?? []) as $needle) {
$needle = strtolower(trim((string) $needle));
if ($needle !== '' && str_contains($imageLabel, $needle)) {
return true;
}
}
}
return false;
}
private function imageIncludesVendorPanel(string $imageLabel): bool
{
return $this->bundledPanelFromImageLabel($imageLabel) !== null;
}
private function bundledPanelFromImageLabel(string $imageLabel): ?string
{
if (str_contains($imageLabel, 'cpanel') || str_contains($imageLabel, 'whm')) {
return 'cpanel';
}
if (str_contains($imageLabel, 'plesk')) {
return 'plesk';
}
return null;
}
/**
* @param array<string, mixed> $option
*/
private function optionIsAvailable(array $option, string $value): bool
{
if ($value === 'none') {
return true;
}
if (! empty($option['allow_unresolved'])) {
return true;
}
$applicationId = trim((string) ($option['application_id'] ?? ''));
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
if ($applicationId !== '' || $preset !== '') {
return true;
}
return array_key_exists('application_id', $option) || array_key_exists('cloud_init_preset', $option)
? false
: true;
}
/**
* @param mixed $families
* @return array<int, string>
*/
private function normalizedOsFamilies(mixed $families): array
{
$items = is_array($families) ? $families : [];
return array_values(array_filter(array_map(
static fn ($family): string => strtolower(trim((string) $family)),
$items
)));
}
/**
* @param array<string, mixed> $option
*/
private function cloudInitForOption(array $option, string $defaultUser): ?string
{
$preset = (string) ($option['cloud_init_preset'] ?? '');
if ($preset === '') {
return null;
}
return match ($preset) {
'webmin' => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi\'',
],
]),
'webmin_lamp' => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y apache2 mariadb-server php libapache2-mod-php php-mysql curl wget && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi && (systemctl enable --now apache2 || systemctl enable --now httpd || true) && (systemctl enable --now mariadb || systemctl enable --now mysqld || true)\'',
],
]),
default => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
sprintf('echo "Preset %s selected for %s"', $preset, $defaultUser),
],
]),
};
}
/**
* @param array<string, mixed>|null $option
* @return array<string, mixed>|null
*/
private function selectionSummaryItem(string $key, string $label, ?array $option, bool $priceOptional = false): ?array
{
if (! $option) {
return null;
}
$amount = (float) ($option['prices'][CustomerHostingOrder::CYCLE_MONTHLY] ?? 0);
if (! $priceOptional && ($option['label'] ?? null) === null) {
return null;
}
return [
'key' => $key,
'label' => $label,
'value' => (string) ($option['label'] ?? $option['value'] ?? ''),
'description' => (string) ($option['description'] ?? ''),
'automated' => (bool) ($option['automated'] ?? true),
'monthly_amount' => round($amount, 2),
];
}
/**
* @return array<string, float>
*/
private function cyclePricesForRecurringUsd(HostingProduct $product, float $monthlyUsd): array
{
$monthlyGhs = $monthlyUsd > 0
? $this->pricing->convertUsdRecurringOption($monthlyUsd, $product->category)
: 0.0;
return [
CustomerHostingOrder::CYCLE_MONTHLY => round($monthlyGhs, 2),
CustomerHostingOrder::CYCLE_QUARTERLY => round($monthlyGhs * 3, 2),
CustomerHostingOrder::CYCLE_SEMIANNUAL => round($monthlyGhs * 6, 2),
CustomerHostingOrder::CYCLE_YEARLY => round($monthlyGhs * 12, 2),
];
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingProduct;
use Illuminate\Support\Str;
class ServerPanelCapabilityService
{
private const FUTURE_HOSTING_PANEL_CAPABILITIES = [
'Files',
'Databases',
'Domains',
'PHP',
'SSL',
'Cron',
'Logs',
];
/**
* @param array<string, mixed> $selection
* @param array<string, mixed> $catalog
* @return array<string, mixed>
*/
public function describeSelection(HostingProduct $product, array $selection, array $catalog): array
{
$license = $this->findOption((array) ($catalog['licenses'] ?? []), (string) ($selection['license'] ?? ''));
$image = $this->findOption((array) ($catalog['images'] ?? []), (string) ($selection['image'] ?? ''));
$licenseLabel = (string) ($license['label'] ?? 'Remote Login Only');
$panel = (string) ($license['panel'] ?? '');
$vendorLicense = (string) ($license['license'] ?? '');
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
$installLater = (bool) ($image['is_install_later'] ?? false);
$supportedManagedImage = $this->supportedManagedImage($image);
$linuxManagedCandidate = $panel === 'ladill'
&& ! $installLater
&& ! str_starts_with($osFamily, 'win')
&& $supportedManagedImage !== null;
$supportedManagedImageLabels = array_values(array_filter(array_map(
static fn (array $supported): string => (string) ($supported['label'] ?? ''),
(array) config('hosting.server_order.managed_stack_supported_images', [])
)));
if ($panel === 'ladill') {
return [
'mode' => 'server_manager',
'label' => $licenseLabel,
'runtime' => 'provider_api',
'is_ladill' => true,
'managed_stack_candidate' => $linuxManagedCandidate,
'managed_stack_supported_image' => $supportedManagedImage,
'managed_stack_supported_image_labels' => $supportedManagedImageLabels,
'hosting_panel_ready' => $linuxManagedCandidate,
'future_hosting_panel_status' => $linuxManagedCandidate ? 'managed_stack_supported' : 'not_available',
'current_capabilities' => [
'Power controls',
'Status sync',
'Instance details',
'SSH access',
],
'future_capabilities' => $linuxManagedCandidate ? self::FUTURE_HOSTING_PANEL_CAPABILITIES : [],
'primary_message' => $linuxManagedCandidate
? 'Ladill Server Manager will provision this server with Ladill-managed controls on the selected supported Linux image.'
: 'Ladill Server Manager is not available for the selected image.',
'secondary_message' => $linuxManagedCandidate
? 'After provisioning finishes, Server Manager can handle power, status, files, databases, domains, PHP, SSL, cron, and logs.'
: 'Choose a plain supported Linux image without cPanel or Plesk. Supported Ladill-managed images: '.implode(', ', $supportedManagedImageLabels).'.',
'manager_cta_label' => 'Open Server Manager',
'hosting_panel_target_label' => 'Ladill Hosting Panel',
'product_type' => $product->type,
];
}
if ($vendorLicense !== '') {
return [
'mode' => 'vendor_panel',
'label' => $licenseLabel,
'runtime' => 'in_server_panel',
'is_ladill' => false,
'managed_stack_candidate' => false,
'hosting_panel_ready' => false,
'future_hosting_panel_status' => 'external_panel',
'current_capabilities' => [
'Provider provisioning',
'Server login',
'Installed panel management',
],
'future_capabilities' => [],
'primary_message' => "{$licenseLabel} will be the panel running inside this server. Ladill will not replace it with the shared hosting panel.",
'secondary_message' => 'Use Ladill for order tracking and server lifecycle, then manage sites and services from the installed panel itself.',
'manager_cta_label' => 'Open Panel',
'hosting_panel_target_label' => 'Ladill Hosting Panel',
'product_type' => $product->type,
];
}
return [
'mode' => 'remote_login_only',
'label' => $licenseLabel,
'runtime' => 'ssh_only',
'is_ladill' => false,
'managed_stack_candidate' => false,
'hosting_panel_ready' => false,
'future_hosting_panel_status' => 'not_enabled',
'current_capabilities' => [
'Server login',
],
'future_capabilities' => [],
'primary_message' => 'This server will be provisioned without a Ladill-managed or commercial control panel.',
'secondary_message' => 'You will manage the machine directly over SSH or RDP until a panel or managed stack is installed separately.',
'manager_cta_label' => 'Open Access Details',
'hosting_panel_target_label' => 'Ladill Hosting Panel',
'product_type' => $product->type,
];
}
/**
* @param array<int, array<string, mixed>> $options
* @return array<string, mixed>|null
*/
private function findOption(array $options, string $value): ?array
{
foreach ($options as $option) {
if ((string) ($option['value'] ?? '') === $value) {
return $option;
}
}
return null;
}
/**
* @param array<string, mixed>|null $image
* @return array<string, mixed>|null
*/
private function supportedManagedImage(?array $image): ?array
{
if ($image === null) {
return null;
}
$value = Str::lower((string) ($image['value'] ?? ''));
$label = Str::lower((string) ($image['label'] ?? ''));
$osFamily = Str::lower((string) ($image['os_family'] ?? ''));
if ($this->imageIncludesVendorPanel($label)) {
return null;
}
foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) {
if (($supported['os_family'] ?? 'linux') !== '' && Str::lower((string) ($supported['os_family'] ?? 'linux')) !== $osFamily) {
continue;
}
$supportedValue = Str::lower((string) ($supported['value'] ?? ''));
if ($supportedValue !== '' && $supportedValue === $value) {
return $supported;
}
foreach ((array) ($supported['match'] ?? []) as $needle) {
if ($needle !== '' && Str::contains($label, Str::lower((string) $needle))) {
return $supported;
}
}
}
return null;
}
private function imageIncludesVendorPanel(string $label): bool
{
foreach (['cpanel', 'plesk', 'whm'] as $needle) {
if ($needle !== '' && Str::contains($label, $needle)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,224 @@
<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\ServerAgent;
use App\Models\ServerTask;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ServerTaskDispatchService
{
public function __construct(
private ServerTaskResultService $resultService,
) {}
/**
* @return Collection<int, ServerTask>
*/
public function claimTasks(ServerAgent $agent, int $limit = 10): Collection
{
$this->recoverStaleTasks($agent->customer_hosting_order_id);
$claimedIds = DB::transaction(function () use ($agent, $limit): array {
$taskIds = ServerTask::query()
->where('customer_hosting_order_id', $agent->customer_hosting_order_id)
->where('status', ServerTask::STATUS_QUEUED)
->where(function ($query): void {
$query->whereNull('available_at')
->orWhere('available_at', '<=', now());
})
->whereNull('lock_token')
->orderBy('id')
->limit($limit)
->lockForUpdate()
->pluck('id')
->all();
$claimed = [];
foreach ($taskIds as $taskId) {
$lockToken = Str::random(48);
$updated = ServerTask::query()
->whereKey($taskId)
->whereNull('lock_token')
->update([
'server_agent_id' => $agent->id,
'status' => ServerTask::STATUS_DISPATCHED,
'lock_token' => $lockToken,
'locked_at' => now(),
'attempt_count' => DB::raw('attempt_count + 1'),
'started_at' => DB::raw('COALESCE(started_at, CURRENT_TIMESTAMP)'),
'last_heartbeat_at' => now(),
]);
if ($updated) {
$claimed[] = $taskId;
}
}
return $claimed;
});
return ServerTask::query()->whereIn('id', $claimedIds)->orderBy('id')->get();
}
public function markProgress(ServerTask $task, string $lockToken, int $progress, ?string $message = null, array $result = []): ServerTask
{
$task = $this->assertLock($task, $lockToken);
$task->update([
'status' => ServerTask::STATUS_RUNNING,
'progress' => $progress,
'result' => $this->mergedTaskResult($task, $message, $result),
'started_at' => $task->started_at ?? now(),
'last_heartbeat_at' => now(),
'locked_at' => now(),
]);
return $task->fresh();
}
public function markCompleted(ServerTask $task, string $lockToken, ?string $message = null, array $result = []): ServerTask
{
$task = $this->assertLock($task, $lockToken);
$task->update([
'status' => ServerTask::STATUS_COMPLETED,
'progress' => 100,
'result' => $this->mergedTaskResult($task, $message, $result),
'completed_at' => now(),
'last_heartbeat_at' => now(),
'error_message' => null,
'lock_token' => null,
'locked_at' => null,
]);
return $this->resultService->apply($task->fresh());
}
/**
* @return array{task: ServerTask, requeued: bool}
*/
public function markFailed(ServerTask $task, string $lockToken, string $message, array $result = [], bool $retryable = true): array
{
$task = $this->assertLock($task, $lockToken);
$mergedResult = $this->mergedTaskResult($task, $message, $result);
if ($task->canRetry($retryable)) {
$task->update([
'status' => ServerTask::STATUS_QUEUED,
'progress' => 0,
'available_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds)),
'failed_at' => null,
'last_heartbeat_at' => now(),
'error_message' => $message,
'result' => array_merge($mergedResult, [
'retrying' => true,
'retry_scheduled_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds))->toIso8601String(),
]),
'lock_token' => null,
'locked_at' => null,
]);
return [
'task' => $task->fresh(),
'requeued' => true,
];
}
$task->update([
'status' => ServerTask::STATUS_FAILED,
'failed_at' => now(),
'last_heartbeat_at' => now(),
'error_message' => $message,
'result' => array_merge($mergedResult, ['retrying' => false]),
'lock_token' => null,
'locked_at' => null,
]);
return [
'task' => $task->fresh(),
'requeued' => false,
];
}
public function recoverStaleTasks(int|CustomerHostingOrder $order): int
{
$orderId = $order instanceof CustomerHostingOrder ? $order->id : $order;
$tasks = ServerTask::query()
->where('customer_hosting_order_id', $orderId)
->whereIn('status', [ServerTask::STATUS_DISPATCHED, ServerTask::STATUS_RUNNING])
->get();
$recovered = 0;
foreach ($tasks as $task) {
$referenceTime = $task->last_heartbeat_at ?? $task->locked_at ?? $task->updated_at;
if ($referenceTime === null) {
continue;
}
if ($referenceTime->gt(now()->subSeconds(max(30, (int) $task->stale_after_seconds)))) {
continue;
}
if ($task->canRetry()) {
$task->update([
'status' => ServerTask::STATUS_QUEUED,
'progress' => 0,
'server_agent_id' => null,
'available_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds)),
'error_message' => 'Recovered after agent heartbeat timeout.',
'result' => array_merge((array) ($task->result ?? []), [
'recovered_from_stale_lock' => true,
'stale_recovered_at' => now()->toIso8601String(),
]),
'lock_token' => null,
'locked_at' => null,
'last_heartbeat_at' => null,
]);
} else {
$task->update([
'status' => ServerTask::STATUS_FAILED,
'failed_at' => now(),
'error_message' => 'Task failed after stale lock recovery exhausted its retry budget.',
'result' => array_merge((array) ($task->result ?? []), [
'recovered_from_stale_lock' => true,
'stale_recovered_at' => now()->toIso8601String(),
'retrying' => false,
]),
'lock_token' => null,
'locked_at' => null,
]);
}
$recovered++;
}
return $recovered;
}
private function assertLock(ServerTask $task, string $lockToken): ServerTask
{
$task->refresh();
abort_if($task->lock_token === null, 409, 'Task lock has expired.');
abort_unless(hash_equals((string) $task->lock_token, $lockToken), 409, 'Task lock token mismatch.');
return $task;
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function mergedTaskResult(ServerTask $task, ?string $message, array $result): array
{
return array_filter(array_merge((array) ($task->result ?? []), $result, [
'message' => $message,
]), static fn ($value): bool => $value !== null);
}
}
@@ -0,0 +1,557 @@
<?php
namespace App\Services\Hosting;
use App\Models\Domain;
use App\Models\ServerDatabase;
use App\Models\ServerFile;
use App\Models\ServerSite;
use App\Models\ServerTask;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class ServerTaskResultService
{
public function apply(ServerTask $task): ServerTask
{
$task->loadMissing('order', 'agent');
$references = match ($task->type) {
ServerTask::TYPE_DOMAIN_ADD => $this->persistDomainTask($task),
ServerTask::TYPE_SITE_DELETE => $this->persistSiteDeleteTask($task),
ServerTask::TYPE_SSL_REQUEST => $this->persistSslTask($task),
ServerTask::TYPE_SSL_RENEW => $this->persistSslRenewTask($task),
ServerTask::TYPE_DATABASE_CREATE => $this->persistDatabaseTask($task),
ServerTask::TYPE_PHP_CONFIGURE => $this->persistPhpTask($task),
ServerTask::TYPE_CRON_LIST,
ServerTask::TYPE_CRON_UPSERT,
ServerTask::TYPE_CRON_REMOVE => $this->persistCronTask($task),
ServerTask::TYPE_LOG_READ => $this->persistLogTask($task),
ServerTask::TYPE_FILE_LIST,
ServerTask::TYPE_FILE_READ,
ServerTask::TYPE_FILE_WRITE => $this->persistFileTask($task),
default => [],
};
if ($references !== []) {
$result = (array) ($task->result ?? []);
$result['model_refs'] = array_merge((array) ($result['model_refs'] ?? []), $references);
$task->forceFill(['result' => $result])->save();
}
return $task->fresh();
}
/**
* @return array<string, mixed>
*/
private function persistDomainTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$order = $task->order()->first();
$domain = Domain::query()->where('host', $domainName)->first();
if ($domain === null && $order !== null) {
$domain = Domain::create([
'user_id' => $order->user_id,
'host' => $domainName,
'type' => 'server',
'source' => 'server_manager',
'status' => 'pending',
'onboarding_mode' => Domain::MODE_MANUAL_DNS,
'onboarding_state' => Domain::STATE_CONNECTED,
'dns_mode' => 'manual',
'connected_at' => now(),
'verification_meta' => [
'source' => 'server_agent_task',
'customer_hosting_order_id' => $task->customer_hosting_order_id,
],
]);
} elseif ($domain !== null) {
$verificationMeta = array_merge((array) ($domain->verification_meta ?? []), [
'server_agent_task_id' => $task->id,
'customer_hosting_order_id' => $task->customer_hosting_order_id,
]);
$domain->fill([
'user_id' => $domain->user_id ?: $order?->user_id,
'source' => $domain->source ?: 'server_manager',
'connected_at' => $domain->connected_at ?? now(),
'verification_meta' => $verificationMeta,
])->save();
}
$site = ServerSite::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'domain' => $domainName,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'domain_id' => $domain?->id,
'document_root' => (string) ($result['document_root'] ?? data_get($task->payload, 'document_root', '')) ?: null,
'php_version' => (string) data_get($task->payload, 'php_version', '') ?: null,
'php_socket' => (string) ($result['php_socket'] ?? '') ?: null,
'status' => 'active',
'metadata' => array_filter([
'task_type' => $task->type,
'server_task_id' => $task->id,
'payload' => $task->payload,
'result' => Arr::except($result, ['message', 'model_refs']),
], static fn ($value): bool => $value !== null && $value !== []),
]
);
return array_filter([
'domain_id' => $domain?->id,
'server_site_id' => $site->id,
]);
}
/**
* @return array<string, mixed>
*/
private function persistSiteDeleteTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$site = ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->where('domain', $domainName)
->first();
if ($site === null) {
return [];
}
$metadata = array_merge((array) ($site->metadata ?? []), [
'removed_at' => now()->toIso8601String(),
'removed_by_task_id' => $task->id,
]);
$site->fill([
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'status' => 'removed',
'ssl_status' => null,
'metadata' => $metadata,
])->save();
return [
'server_site_id' => $site->id,
];
}
/**
* @return array<string, mixed>
*/
private function persistSslTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$site = ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->where('domain', $domainName)
->first();
if ($site === null) {
$site = ServerSite::create([
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'domain' => $domainName,
'status' => 'active',
'ssl_status' => (string) ($result['ssl'] ?? 'issued'),
'ssl_provisioned_at' => now(),
'metadata' => [
'created_from' => 'ssl.request',
'server_task_id' => $task->id,
],
]);
} else {
$site->fill([
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'ssl_status' => (string) ($result['ssl'] ?? 'issued'),
'ssl_provisioned_at' => now(),
])->save();
}
if ($site->domain_id) {
$domain = Domain::query()->find($site->domain_id);
} else {
$domain = Domain::query()->where('host', $domainName)->first();
}
if ($domain !== null) {
$domain->fill([
'ssl_status' => (string) ($result['ssl'] ?? 'issued'),
'ssl_provisioned_at' => now(),
])->save();
if ($site->domain_id === null) {
$site->forceFill(['domain_id' => $domain->id])->save();
}
}
return array_filter([
'domain_id' => $domain->id ?? null,
'server_site_id' => $site->id,
]);
}
/**
* @return array<string, mixed>
*/
private function persistSslRenewTask(ServerTask $task): array
{
$expiresAt = now()->addDays(90);
ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->whereIn('ssl_status', ['issued', 'active'])
->update([
'ssl_expires_at' => $expiresAt,
'ssl_provisioned_at' => now(),
]);
$domainIds = ServerSite::query()
->where('customer_hosting_order_id', $task->customer_hosting_order_id)
->whereNotNull('domain_id')
->pluck('domain_id');
if ($domainIds->isNotEmpty()) {
Domain::query()
->whereIn('id', $domainIds)
->where('ssl_status', 'active')
->update(['ssl_expires_at' => $expiresAt]);
}
return [];
}
/**
* @return array<string, mixed>
*/
private function persistDatabaseTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$databaseName = (string) ($result['database'] ?? data_get($task->payload, 'name', ''));
if ($databaseName === '') {
return [];
}
$database = ServerDatabase::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'name' => $databaseName,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'username' => (string) ($result['username'] ?? $databaseName) ?: null,
'password_encrypted' => isset($result['password']) && $result['password'] !== '' ? encrypt((string) $result['password']) : null,
'engine' => 'mariadb',
'status' => 'active',
'metadata' => array_filter([
'server_task_id' => $task->id,
'payload' => $task->payload,
'result' => Arr::except($result, ['message', 'model_refs', 'password']),
], static fn ($value): bool => $value !== null && $value !== []),
]
);
return [
'server_database_id' => $database->id,
];
}
/**
* @return array<string, mixed>
*/
private function persistPhpTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', '')));
if ($domainName === '') {
return [];
}
$site = ServerSite::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'domain' => $domainName,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'php_version' => (string) ($result['php_version'] ?? data_get($task->payload, 'php_version', '')) ?: null,
'php_socket' => (string) ($result['php_socket'] ?? '') ?: null,
'status' => 'active',
'metadata' => array_filter([
'php_configured_at' => now()->toIso8601String(),
'server_task_id' => $task->id,
'result' => Arr::except($result, ['message', 'model_refs']),
], static fn ($value): bool => $value !== null && $value !== []),
]
);
return [
'server_site_id' => $site->id,
];
}
/**
* @return array<string, mixed>
*/
private function persistCronTask(ServerTask $task): array
{
$entries = array_values(array_filter((array) data_get($task->result, 'entries', []), 'is_array'));
$order = $task->order()->first();
if ($order !== null) {
$meta = (array) ($order->meta ?? []);
$panelState = (array) ($meta['server_panel'] ?? []);
$panelState['managed_cron_entries'] = $entries;
$panelState['managed_cron_synced_at'] = now()->toIso8601String();
$meta['server_panel'] = $panelState;
$order->update(['meta' => $meta]);
}
return [
'cron_entry_count' => count($entries),
];
}
/**
* @return array<string, mixed>
*/
private function persistLogTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
$contentBase64 = (string) ($result['content_base64'] ?? '');
$content = $contentBase64 !== '' ? base64_decode($contentBase64, true) : false;
$content = is_string($content) ? $content : '';
$target = (string) ($result['target'] ?? data_get($task->payload, 'target', 'log'));
$domain = (string) ($result['domain'] ?? data_get($task->payload, 'domain', ''));
$snapshotKey = $target.($domain !== '' ? ':'.$domain : '');
$order = $task->order()->first();
if ($order !== null) {
$meta = (array) ($order->meta ?? []);
$panelState = (array) ($meta['server_panel'] ?? []);
$panelState['log_snapshots'] = array_merge((array) ($panelState['log_snapshots'] ?? []), [
$snapshotKey => [
'target' => $target,
'domain' => $domain !== '' ? $domain : null,
'lines' => (int) ($result['lines'] ?? data_get($task->payload, 'lines', 0)),
'path' => $result['path'] ?? null,
'read_at' => now()->toIso8601String(),
'content_preview' => $this->previewForContent($content),
],
]);
$meta['server_panel'] = $panelState;
$order->update(['meta' => $meta]);
}
return [
'log_snapshot_key' => $snapshotKey,
];
}
/**
* @return array<string, mixed>
*/
private function persistFileTask(ServerTask $task): array
{
$result = (array) ($task->result ?? []);
return match ($task->type) {
ServerTask::TYPE_FILE_LIST => $this->persistFileListTask($task, $result),
ServerTask::TYPE_FILE_READ => $this->persistFileReadTask($task, $result),
ServerTask::TYPE_FILE_WRITE => $this->persistFileWriteTask($task, $result),
default => [],
};
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function persistFileListTask(ServerTask $task, array $result): array
{
$basePath = (string) data_get($task->payload, 'path', '');
$entries = array_values(array_filter((array) ($result['entries'] ?? []), 'is_array'));
$recordIds = [];
if ($basePath !== '') {
$rootRecord = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $basePath,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => 'directory',
'exists' => true,
'metadata' => [
'entry_count' => count($entries),
'server_task_id' => $task->id,
],
'last_synced_at' => now(),
]
);
$recordIds[] = $rootRecord->id;
}
foreach ($entries as $entry) {
$path = (string) ($entry['path'] ?? '');
if ($path === '') {
continue;
}
$record = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $path,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => $this->normalizeFileKind((string) ($entry['type'] ?? 'f')),
'exists' => true,
'size_bytes' => is_numeric($entry['size'] ?? null) ? (int) $entry['size'] : null,
'metadata' => [
'listed_name' => $entry['name'] ?? basename($path),
'server_task_id' => $task->id,
],
'last_synced_at' => now(),
]
);
$recordIds[] = $record->id;
}
return [
'server_file_ids' => array_values(array_unique($recordIds)),
];
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function persistFileReadTask(ServerTask $task, array $result): array
{
$path = (string) ($result['path'] ?? data_get($task->payload, 'path', ''));
if ($path === '') {
return [];
}
$contentBase64 = (string) ($result['content_base64'] ?? '');
$content = $contentBase64 !== '' ? base64_decode($contentBase64, true) : false;
$content = is_string($content) ? $content : '';
$file = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $path,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => 'file',
'exists' => true,
'size_bytes' => strlen($content),
'content_hash' => $content !== '' ? hash('sha256', $content) : null,
'content_preview' => $this->previewForContent($content),
'metadata' => [
'encoding' => 'base64',
'server_task_id' => $task->id,
],
'last_synced_at' => now(),
]
);
return [
'server_file_id' => $file->id,
];
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function persistFileWriteTask(ServerTask $task, array $result): array
{
$path = (string) ($result['path'] ?? data_get($task->payload, 'path', ''));
if ($path === '') {
return [];
}
$content = (string) data_get($task->payload, 'content', '');
$file = ServerFile::query()->updateOrCreate(
[
'customer_hosting_order_id' => $task->customer_hosting_order_id,
'path' => $path,
],
[
'server_agent_id' => $task->server_agent_id,
'latest_server_task_id' => $task->id,
'kind' => 'file',
'exists' => true,
'size_bytes' => strlen($content),
'content_hash' => $content !== '' ? hash('sha256', $content) : null,
'content_preview' => $this->previewForContent($content),
'metadata' => [
'server_task_id' => $task->id,
'write_source' => 'server_agent',
],
'last_synced_at' => now(),
]
);
return [
'server_file_id' => $file->id,
];
}
private function normalizeFileKind(string $type): string
{
return match ($type) {
'd' => 'directory',
'l' => 'symlink',
default => 'file',
};
}
private function previewForContent(string $content): ?string
{
if ($content === '') {
return null;
}
if (preg_match('//u', $content) !== 1) {
return Str::limit(base64_encode(substr($content, 0, 2000)), 4000, '');
}
return (string) Str::of($content)
->replaceMatches('/[^\P{C}\t\r\n]/u', '')
->limit(4000, '');
}
}
@@ -0,0 +1,137 @@
<?php
namespace App\Services\Hosting\Terminal;
class LocalPtyShellSession implements ShellSession
{
/** @var resource|null */
private $process = null;
/** @var array<int, resource> */
private array $pipes = [];
public function __construct(
private string $command,
) {}
public function boot(): void
{
$spec = [
0 => ['pipe', 'r'],
1 => ['pty'],
2 => ['pty'],
];
$process = proc_open($this->command, $spec, $pipes, base_path());
if (! is_resource($process)) {
throw new \RuntimeException('Unable to start local PTY shell.');
}
$this->process = $process;
$this->pipes = $pipes;
foreach ($this->pipes as $pipe) {
stream_set_blocking($pipe, false);
}
}
public function read(): string
{
if (! $this->isAlive()) {
return '';
}
$output = '';
$stream = $this->outputStream();
if (! $stream) {
return '';
}
$read = [$stream];
$write = null;
$except = null;
$available = @stream_select($read, $write, $except, 0, 200000);
if ($available === false || $available === 0) {
return '';
}
$chunk = @stream_get_contents($stream);
if ($chunk !== false && $chunk !== '') {
$output .= $chunk;
}
return $output;
}
public function write(string $input): void
{
$stream = $this->inputStream();
if ($input === '' || ! $stream) {
return;
}
@fwrite($stream, $input);
@fflush($stream);
}
public function resize(int $cols, int $rows): void
{
// proc_open PTYs do not expose a portable runtime resize API.
}
public function isAlive(): bool
{
return is_resource($this->process) && (proc_get_status($this->process)['running'] ?? false);
}
public function close(): void
{
$input = $this->inputStream();
if ($input) {
@fwrite($input, "exit\n");
@fflush($input);
}
foreach ($this->pipes as $pipe) {
if (is_resource($pipe)) {
@fclose($pipe);
}
}
$this->pipes = [];
if (is_resource($this->process)) {
@proc_terminate($this->process);
@proc_close($this->process);
}
$this->process = null;
}
/** @return resource|null */
private function inputStream()
{
return isset($this->pipes[0]) && is_resource($this->pipes[0]) ? $this->pipes[0] : null;
}
/** @return resource|null */
private function outputStream()
{
if (isset($this->pipes[1]) && is_resource($this->pipes[1])) {
return $this->pipes[1];
}
if (isset($this->pipes[2]) && is_resource($this->pipes[2])) {
return $this->pipes[2];
}
return isset($this->pipes[0]) && is_resource($this->pipes[0]) ? $this->pipes[0] : null;
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Hosting\Terminal;
use phpseclib3\Net\SSH2;
class RemoteSshShellSession implements ShellSession
{
private const STARTUP_TIMEOUT_SECONDS = 10;
private const READ_TIMEOUT_SECONDS = 0.1;
private bool $closed = false;
public function __construct(
private SSH2 $ssh,
private string $launchCommand,
private int $cols = 120,
private int $rows = 30,
) {}
public function boot(): void
{
$this->ssh->setWindowSize($this->cols, $this->rows);
$this->ssh->setTimeout(self::STARTUP_TIMEOUT_SECONDS);
$this->ssh->enablePTY();
if ($this->ssh->exec($this->launchCommand, false) !== true) {
throw new \RuntimeException('Unable to start remote terminal shell.');
}
}
public function read(): string
{
if (! $this->isAlive()) {
return '';
}
$output = '';
$this->ssh->setTimeout(self::READ_TIMEOUT_SECONDS);
while (true) {
$chunk = $this->ssh->read('', SSH2::READ_NEXT);
if ($chunk === true) {
if ($this->ssh->isTimeout()) {
break;
}
$this->closed = true;
break;
}
if (! is_string($chunk) || $chunk === '') {
break;
}
$output .= $chunk;
}
return $output;
}
public function write(string $input): void
{
if ($input === '' || ! $this->isAlive()) {
return;
}
$this->ssh->write($input);
}
public function resize(int $cols, int $rows): void
{
// phpseclib applies the PTY size during shell creation; runtime resize is left as a no-op
// to avoid injecting visible shell commands into the user session.
}
public function isAlive(): bool
{
return ! $this->closed && $this->ssh->isConnected();
}
public function close(): void
{
if ($this->ssh->isConnected()) {
try {
$this->ssh->write("exit\n");
} catch (\Throwable) {
// Ignore shutdown write failures.
}
$this->ssh->disconnect();
}
$this->closed = true;
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Hosting\Terminal;
interface ShellSession
{
public function boot(): void;
public function read(): string;
public function write(string $input): void;
public function resize(int $cols, int $rows): void;
public function isAlive(): bool;
public function close(): void;
}
@@ -0,0 +1,196 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use Illuminate\Support\Collection;
class UpsellRecommendationService
{
private const STORAGE_THRESHOLD = 70.0;
private const TRAFFIC_THRESHOLD = 70.0;
private const EMAIL_ADDON_QUANTITY = 10;
private const EMAIL_ADDON_UNIT_PRICE = 5;
private const BYTES_PER_GB = 1073741824;
/**
* @return Collection<int, array<string, mixed>>
*/
public function recommendationsForUser(User $user): Collection
{
$accounts = HostingAccount::query()
->where('user_id', $user->id)
->where('status', HostingAccount::STATUS_ACTIVE)
->with(['product', 'mailboxes:id,hosting_account_id,is_paid'])
->get();
return $accounts
->flatMap(fn (HostingAccount $account): array => $this->recommendationsForAccount($account))
->sortByDesc(fn (array $recommendation): float => (float) ($recommendation['priority_score'] ?? 0))
->values();
}
/**
* @return array<int, array<string, mixed>>
*/
public function recommendationsForAccount(HostingAccount $account): array
{
$account->loadMissing(['product', 'mailboxes']);
$recommendations = [];
$upgradeProduct = $this->nextUpgradeProduct($account);
$diskPercent = $account->diskUsagePercent();
$trafficPercent = $this->bandwidthUsagePercent($account);
$trafficTriggered = $trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD;
$storageTriggered = $diskPercent >= self::STORAGE_THRESHOLD;
if ($upgradeProduct && ($storageTriggered || $trafficTriggered)) {
$recommendations[] = [
'id' => sprintf('account-%d-upgrade', $account->id),
'type' => 'plan_upgrade',
'trigger' => $storageTriggered && $trafficTriggered
? 'storage_and_traffic'
: ($storageTriggered ? 'storage_usage' : 'traffic_spike'),
'account_id' => $account->id,
'account_label' => $account->primary_domain ?: $account->username,
'title' => $storageTriggered && $trafficTriggered
? sprintf('%s is outgrowing its current plan', $account->primary_domain ?: $account->username)
: ($storageTriggered
? sprintf('Storage usage is high on %s', $account->primary_domain ?: $account->username)
: sprintf('Traffic is climbing on %s', $account->primary_domain ?: $account->username)),
'message' => $this->upgradeMessage($account, $upgradeProduct, $diskPercent, $trafficPercent),
'cta_label' => sprintf('Upgrade to %s', $upgradeProduct->name),
'cta_url' => route('hosting.accounts.show', $account),
'target_product_id' => $upgradeProduct->id,
'target_product_name' => $upgradeProduct->name,
'target_product_type' => $upgradeProduct->type,
'priority_score' => max($diskPercent, $trafficPercent ?? 0),
'metrics' => [
'disk_usage_percent' => round($diskPercent, 2),
'traffic_usage_percent' => $trafficPercent !== null ? round($trafficPercent, 2) : null,
],
];
}
$freeAllowance = $account->freeEmailAllowance();
$mailboxCount = $account->mailboxes->count();
if ($freeAllowance !== null && $mailboxCount > $freeAllowance) {
$extraMailboxes = $mailboxCount - $freeAllowance;
$addonAmount = self::EMAIL_ADDON_QUANTITY * self::EMAIL_ADDON_UNIT_PRICE;
$recommendations[] = [
'id' => sprintf('account-%d-email-addon', $account->id),
'type' => 'email_addon',
'trigger' => 'email_overage',
'account_id' => $account->id,
'account_label' => $account->primary_domain ?: $account->username,
'title' => sprintf('Mailbox overage on %s', $account->primary_domain ?: $account->username),
'message' => sprintf(
'This account is using %d mailbox%s on a plan with %d included. Adding %d more mailboxes would be GHS %d/month.',
$mailboxCount,
$mailboxCount === 1 ? '' : 'es',
$freeAllowance,
self::EMAIL_ADDON_QUANTITY,
$addonAmount
),
'cta_label' => 'Manage Email Add-ons',
'cta_url' => route('hosting.accounts.show', $account),
'suggested_quantity' => self::EMAIL_ADDON_QUANTITY,
'estimated_amount' => (float) $addonAmount,
'priority_score' => 50 + ($extraMailboxes * 5),
'metrics' => [
'mailbox_count' => $mailboxCount,
'free_allowance' => $freeAllowance,
'extra_mailboxes' => $extraMailboxes,
],
];
}
return $recommendations;
}
private function nextUpgradeProduct(HostingAccount $account): ?HostingProduct
{
$current = $account->product;
if (! $current) {
return null;
}
return HostingProduct::query()
->active()
->visible()
->where('category', HostingProduct::CATEGORY_SHARED)
->whereKeyNot($current->id)
->get()
->filter(function (HostingProduct $candidate) use ($current): bool {
$candidateDomains = $candidate->max_domains ?? 0;
$currentDomains = $current->max_domains ?? 0;
return ($candidate->disk_gb ?? 0) > ($current->disk_gb ?? 0)
|| ($candidate->max_email_accounts ?? 0) > ($current->max_email_accounts ?? 0)
|| ($candidateDomains === -1 && $currentDomains !== -1)
|| ($candidateDomains > $currentDomains && $currentDomains !== -1);
})
->sortBy(fn (HostingProduct $candidate): string => sprintf(
'%d-%08d-%012.2f',
$candidate->type === $current->type ? 0 : 1,
(int) ($candidate->sort_order ?? 0),
(float) ($candidate->price_monthly ?? 0)
))
->first();
}
private function bandwidthUsagePercent(HostingAccount $account): ?float
{
$limitBytes = $this->bandwidthLimitBytes($account);
if ($limitBytes === null || $limitBytes <= 0) {
return null;
}
return ($account->bandwidth_used_bytes / $limitBytes) * 100;
}
private function bandwidthLimitBytes(HostingAccount $account): ?int
{
$limitBytes = data_get($account->resource_limits, 'bandwidth_limit_bytes');
if (is_numeric($limitBytes) && (int) $limitBytes > 0) {
return (int) $limitBytes;
}
$bandwidthGb = data_get($account->resource_limits, 'bandwidth_gb');
if (is_numeric($bandwidthGb) && (int) $bandwidthGb > 0) {
return (int) $bandwidthGb * self::BYTES_PER_GB;
}
if ($account->product?->bandwidth_gb && $account->product->bandwidth_gb > 0) {
return (int) $account->product->bandwidth_gb * self::BYTES_PER_GB;
}
return null;
}
private function upgradeMessage(HostingAccount $account, HostingProduct $target, float $diskPercent, ?float $trafficPercent): string
{
$parts = [];
if ($diskPercent >= self::STORAGE_THRESHOLD) {
$parts[] = sprintf('disk usage is at %.1f%%', $diskPercent);
}
if ($trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD) {
$parts[] = sprintf('traffic usage is at %.1f%%', $trafficPercent);
}
$reason = $parts === [] ? 'usage is increasing' : implode(' and ', $parts);
return sprintf(
'%s. Moving this account to %s gives you %s storage%s.',
ucfirst($reason),
$target->name,
$target->formatDiskSize(),
$target->formatDomainLimit() ? ' and '.$target->formatDomainLimit() : ''
);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Identity;
use Illuminate\Support\Facades\Http;
class IdentityClient
{
/** @return array<string, mixed> */
public function mailboxLinkStatus(string $publicId): array
{
$response = $this->request()->get($this->url('/identity/mailbox-link'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function linkMailbox(string $publicId, string $mailboxAddress): array
{
$response = $this->request()->put($this->url('/identity/mailbox-link'), [
'user' => $publicId,
'mailbox_address' => $mailboxAddress,
]);
$response->throw();
return (array) $response->json('data', []);
}
/** @return array<string, mixed> */
public function unlinkMailbox(string $publicId): array
{
$response = $this->request()->delete($this->url('/identity/mailbox-link'), [
'user' => $publicId,
]);
$response->throw();
return (array) $response->json('data', []);
}
private function request()
{
return Http::withToken((string) config('identity.api_key'))
->acceptJson()
->timeout(15);
}
private function url(string $path): string
{
return rtrim((string) config('identity.api_url'), '/').$path;
}
}
@@ -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),
];
});
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Services\Mail;
use App\Models\EmailDomain;
use App\Models\EmailServer;
use App\Models\Mailbox;
use Illuminate\Support\Collection;
class EmailServerPoolService
{
public function poolRoot(EmailServer $server): EmailServer
{
return $server->poolRoot();
}
/**
* @return Collection<int, EmailServer>
*/
public function poolMembers(EmailServer $server): Collection
{
$root = $this->poolRoot($server);
return EmailServer::query()
->where(function ($query) use ($root) {
$query->where('id', $root->id)
->orWhere('primary_email_server_id', $root->id);
})
->whereNotIn('status', [EmailServer::STATUS_DECOMMISSIONED])
->orderBy('role')
->get();
}
public function poolDiskGb(EmailServer $server): int
{
return (int) $this->poolMembers($server)->sum('disk_gb');
}
public function poolUsedStorageMb(EmailServer $server): int
{
return (int) $this->poolMembers($server)->sum('used_storage_mb');
}
public function poolAllocatedStorageMb(EmailServer $server): int
{
$memberIds = $this->poolMembers($server)->pluck('id');
$fromEmailDomains = EmailDomain::query()
->whereIn('email_server_id', $memberIds)
->withSum('mailboxes as quota_sum', 'quota_mb')
->get()
->sum(fn (EmailDomain $d) => (int) ($d->quota_sum ?? 0));
$fromWebsiteDomains = Mailbox::query()
->whereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds))
->sum('quota_mb');
return (int) ($fromEmailDomains + $fromWebsiteDomains);
}
public function poolDomainCount(EmailServer $server): int
{
$memberIds = $this->poolMembers($server)->pluck('id');
$standalone = EmailDomain::query()->whereIn('email_server_id', $memberIds)->count();
$website = \App\Models\Domain::query()->whereIn('email_server_id', $memberIds)->count();
return $standalone + $website;
}
public function poolMailboxCount(EmailServer $server): int
{
$memberIds = $this->poolMembers($server)->pluck('id');
$standalone = Mailbox::query()
->whereHas('emailDomain', fn ($q) => $q->whereIn('email_server_id', $memberIds))
->count();
$website = Mailbox::query()
->whereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds))
->count();
return $standalone + $website;
}
/**
* Pick the pool member with the most free physical disk for new mail storage.
*/
public function findStorageMember(EmailServer $poolRoot): ?EmailServer
{
$members = $this->poolMembers($poolRoot)
->filter(fn (EmailServer $m) => in_array($m->status, [EmailServer::STATUS_ACTIVE, EmailServer::STATUS_FULL], true));
if ($members->isEmpty()) {
return null;
}
return $members
->sortByDesc(function (EmailServer $member) {
$disk = (int) ($member->disk_gb ?? 0);
$usedGb = $this->memberUsedStorageGb($member);
return max($disk - $usedGb, 0);
})
->first();
}
public function memberUsedStorageGb(EmailServer $member): float
{
return ((int) $member->used_storage_mb) / 1024;
}
public function poolPressurePercent(EmailServer $server): float
{
$poolDisk = $this->poolDiskGb($server);
if ($poolDisk <= 0) {
return 0;
}
$usedGb = $this->poolUsedStorageMb($server) / 1024;
return round(($usedGb / $poolDisk) * 100, 2);
}
public function syncExtensionCredentials(EmailServer $primary): void
{
if (! $primary->isPrimary()) {
return;
}
$primary->extensions()->update([
'mail_host' => $primary->mail_host,
'db_host' => $primary->db_host,
'db_port' => $primary->db_port,
'db_database' => $primary->db_database,
'db_username' => $primary->db_username,
'db_password' => $primary->db_password,
]);
}
public function syncExtensionFromPrimary(EmailServer $extension, EmailServer $primary): void
{
$extension->update([
'mail_host' => $primary->mail_host,
'role' => EmailServer::ROLE_EXTENSION,
'primary_email_server_id' => $primary->id,
'db_host' => $primary->db_host,
'db_port' => $primary->db_port,
'db_database' => $primary->db_database,
'db_username' => $primary->db_username,
'db_password' => $primary->db_password,
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Services\Mail;
use App\Models\EmailServer;
use Illuminate\Support\Facades\Log;
class MailServerNfsService
{
public function __construct(
private MailServerSshExecutor $ssh,
private EmailServerPoolService $pool,
) {}
/**
* Allow pool extension IPs on the primary NFS export.
*/
public function refreshPrimaryExports(EmailServer $primary): void
{
$primary = $primary->poolRoot();
if (! $primary->isPrimary() || ! $this->ssh->canConnect($primary)) {
return;
}
$clientIps = $this->pool->poolMembers($primary)
->filter(fn (EmailServer $m) => $m->isExtension())
->pluck('ip_address')
->filter()
->unique()
->values()
->all();
if ($clientIps === []) {
return;
}
$script = resource_path('infrastructure/scripts/nfs-refresh-exports.sh');
$replacements = [
'__VMAIL_ROOT__' => rtrim((string) ($primary->pool_vmail_root ?: config('infrastructure.mail_stack.vmail_root', '/var/vmail')), '/'),
'__NEW_CLIENTS__' => implode(' ', $clientIps),
];
try {
$this->ssh->uploadAndRunScript(
$primary,
'/tmp/ladill-nfs-refresh-exports.sh',
$script,
$replacements,
);
} catch (\Throwable $e) {
Log::warning('Failed to refresh NFS exports on primary mail server', [
'primary_id' => $primary->id,
'error' => $e->getMessage(),
]);
}
}
}
+369
View File
@@ -0,0 +1,369 @@
<?php
namespace App\Services\Mail;
use App\Models\Domain;
use App\Models\DomainDkimKey;
use App\Models\EmailDomain;
use App\Models\EmailServer;
use App\Models\Mailbox;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class MailServerProvisioner
{
public function __construct(
private MailServerConnectionRegistry $connections,
private MailServerCapacityService $capacity,
) {}
public function provisionDomain(Domain $domain): int
{
$server = $this->resolveServer(domain: $domain);
$name = rtrim($domain->host, '.');
$existing = $this->db($server)->table('domains')->where('name', $name)->first();
if ($existing) {
$this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]);
Log::info('MailServerProvisioner: Domain already exists, activated', ['domain' => $name, 'server_id' => $server->id]);
return $existing->id;
}
$id = $this->db($server)->table('domains')->insertGetId([
'name' => $name,
'active' => 1,
'created_at' => now(),
]);
Log::info('MailServerProvisioner: Domain provisioned', ['domain' => $name, 'server_id' => $server->id, 'id' => $id]);
return $id;
}
public function provisionMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int
{
$domain = $mailbox->domain;
if (! $domain) {
throw new \RuntimeException('Mailbox has no associated domain.');
}
$server = $this->resolveServer(domain: $domain);
$mailDomainId = $this->provisionDomain($domain);
$email = strtolower(trim($mailbox->address));
$localPart = strtolower(trim($mailbox->local_part));
$domainName = rtrim($domain->host, '.');
$maildir = $this->maildirPath($server, $domainName, $localPart);
return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain);
}
public function provisionAlias(Domain $domain, string $source, string $destination): int
{
$server = $this->resolveServer(domain: $domain);
$mailDomainId = $this->provisionDomain($domain);
$existing = $this->db($server)->table('aliases')->where('source', $source)->first();
if ($existing) {
$this->db($server)->table('aliases')->where('id', $existing->id)->update([
'destination' => $destination,
'active' => 1,
]);
return $existing->id;
}
return $this->db($server)->table('aliases')->insertGetId([
'domain_id' => $mailDomainId,
'source' => $source,
'destination' => $destination,
'active' => 1,
'created_at' => now(),
]);
}
public function deactivateDomain(Domain $domain): void
{
$server = $this->resolveServer(domain: $domain);
$name = rtrim($domain->host, '.');
$this->db($server)->table('domains')->where('name', $name)->update(['active' => 0]);
Log::info('MailServerProvisioner: Domain deactivated', ['domain' => $name, 'server_id' => $server->id]);
}
public function deactivateMailbox(Mailbox $mailbox): void
{
$server = $this->resolveServer(mailbox: $mailbox);
$email = strtolower(trim($mailbox->address));
$this->db($server)->table('mailboxes')->where('email', $email)->update(['active' => 0]);
Log::info('MailServerProvisioner: Mailbox deactivated', ['email' => $email, 'server_id' => $server->id]);
}
public function deployDkim(Domain $domain, DomainDkimKey $dkim): bool
{
$server = $this->resolveServer(domain: $domain);
$signingServer = $server->mailDatabaseServer();
return $this->deployDkimViaSsh($signingServer, rtrim($domain->host, '.'), $dkim);
}
public function isConfigured(): bool
{
if (EmailServer::query()->whereIn('status', [EmailServer::STATUS_ACTIVE, EmailServer::STATUS_FULL])->exists()) {
return true;
}
$password = config('database.connections.mailserver.password');
return is_string($password) && $password !== '';
}
public function provisionEmailDomain(EmailDomain $emailDomain): int
{
$server = $this->resolveServer(emailDomain: $emailDomain);
$name = rtrim($emailDomain->domain, '.');
$existing = $this->db($server)->table('domains')->where('name', $name)->first();
if ($existing) {
$this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]);
return $existing->id;
}
$id = $this->db($server)->table('domains')->insertGetId([
'name' => $name,
'active' => 1,
'created_at' => now(),
]);
Log::info('MailServerProvisioner: Email domain provisioned', ['domain' => $name, 'server_id' => $server->id]);
return $id;
}
public function provisionStandaloneMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int
{
$emailDomain = $mailbox->emailDomain;
if (! $emailDomain) {
throw new \RuntimeException('Standalone mailbox has no associated email domain.');
}
$server = $this->resolveServer(emailDomain: $emailDomain);
$mailDomainId = $this->provisionEmailDomain($emailDomain);
$email = strtolower(trim($mailbox->address));
$localPart = strtolower(trim($mailbox->local_part));
$domainName = rtrim($emailDomain->domain, '.');
$maildir = $this->maildirPath($server, $domainName, $localPart);
return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain);
}
public function deprovisionEmailDomain(EmailDomain $emailDomain): void
{
$server = $this->resolveServer(emailDomain: $emailDomain);
$name = rtrim($emailDomain->domain, '.');
$domainRecord = $this->db($server)->table('domains')->where('name', $name)->first();
if ($domainRecord) {
$this->db($server)->table('mailboxes')->where('domain_id', $domainRecord->id)->update(['active' => 0]);
$this->db($server)->table('domains')->where('id', $domainRecord->id)->update(['active' => 0]);
}
Log::info('MailServerProvisioner: Email domain deprovisioned', ['domain' => $name, 'server_id' => $server->id]);
}
private function upsertMailboxRecord(
EmailServer $server,
int $mailDomainId,
string $email,
string $localPart,
string $maildir,
int $quotaMb,
?string $passwordPlain,
): int {
$existing = $this->db($server)->table('mailboxes')->where('email', $email)->first();
if ($existing) {
$update = [
'active' => 1,
'quota_mb' => $quotaMb,
'maildir' => $maildir,
];
if ($passwordPlain !== null && $passwordPlain !== '') {
$update['password_hash'] = $this->hashPassword($passwordPlain);
}
$this->db($server)->table('mailboxes')->where('id', $existing->id)->update($update);
return $existing->id;
}
if ($passwordPlain === null || $passwordPlain === '') {
throw new \RuntimeException("Cannot provision new mailbox {$email} without a password.");
}
$id = $this->db($server)->table('mailboxes')->insertGetId([
'domain_id' => $mailDomainId,
'local_part' => $localPart,
'email' => $email,
'password_hash' => $this->hashPassword($passwordPlain),
'quota_mb' => $quotaMb,
'active' => 1,
'maildir' => $maildir,
'created_at' => now(),
]);
$this->capacity->refreshServerMetrics($server);
return $id;
}
private function maildirPath(EmailServer $server, string $domainName, string $localPart): string
{
return "{$domainName}/{$localPart}/";
}
private function deployDkimViaSsh(EmailServer $server, string $domainName, DomainDkimKey $dkim): bool
{
$sshHost = $server->ssh_host ?: $server->ip_address;
$sshUser = $server->ssh_user ?: 'root';
$sshKey = $server->ssh_private_key;
if (empty($sshHost) || empty($sshKey)) {
$sshHost = config('mailinfra.mail_ssh_host');
$sshUser = config('mailinfra.mail_ssh_user', 'root');
$sshKeyPath = config('mailinfra.mail_ssh_key_path');
if (empty($sshHost) || empty($sshKeyPath)) {
Log::warning('MailServerProvisioner: SSH not configured, skipping DKIM deployment', ['domain' => $domainName]);
return false;
}
return $this->deployDkimWithKeyPath($sshHost, $sshUser, $sshKeyPath, $domainName, $dkim);
}
$tmpKey = tempnam(sys_get_temp_dir(), 'ladill-mail-ssh-');
file_put_contents($tmpKey, $sshKey);
chmod($tmpKey, 0600);
try {
return $this->deployDkimWithKeyPath($sshHost, $sshUser, $tmpKey, $domainName, $dkim);
} finally {
@unlink($tmpKey);
}
}
private function deployDkimWithKeyPath(string $sshHost, string $sshUser, string $sshKeyPath, string $domainName, DomainDkimKey $dkim): bool
{
$selector = $dkim->selector;
$privatePath = $dkim->private_key_path;
if (! $privatePath || ! file_exists($privatePath)) {
Log::warning('MailServerProvisioner: DKIM private key file not found', ['domain' => $domainName, 'path' => $privatePath]);
return false;
}
$privateKey = file_get_contents($privatePath);
$remoteKeyDir = "/etc/opendkim/keys/{$domainName}";
$remoteKeyFile = "{$remoteKeyDir}/{$selector}.private";
$escapedKey = base64_encode($privateKey);
$keyTableEntry = "{$selector}._domainkey.{$domainName} {$domainName}:{$selector}:{$remoteKeyFile}";
$signingTableEntry = "*@{$domainName} {$selector}._domainkey.{$domainName}";
$commands = [
"mkdir -p {$remoteKeyDir}",
"chown opendkim:opendkim {$remoteKeyDir}",
"echo '{$escapedKey}' | base64 -d > {$remoteKeyFile}",
"chown opendkim:opendkim {$remoteKeyFile}",
"chmod 600 {$remoteKeyFile}",
"grep -qF '{$domainName}' /etc/opendkim/key.table || echo '{$keyTableEntry}' >> /etc/opendkim/key.table",
"grep -qF '{$domainName}' /etc/opendkim/signing.table || echo '{$signingTableEntry}' >> /etc/opendkim/signing.table",
'systemctl reload opendkim 2>/dev/null || service opendkim restart 2>/dev/null || true',
];
$sshBase = "ssh -i {$sshKeyPath} -o StrictHostKeyChecking=no {$sshUser}@{$sshHost}";
$fullCommand = $sshBase.' "'.implode(' && ', $commands).'"';
$output = [];
$exitCode = 0;
exec($fullCommand.' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
Log::error('MailServerProvisioner: DKIM deployment failed', [
'domain' => $domainName,
'exit_code' => $exitCode,
'output' => implode("\n", $output),
]);
return false;
}
Log::info('MailServerProvisioner: DKIM deployed', ['domain' => $domainName, 'selector' => $selector]);
return true;
}
private function hashPassword(string $password): string
{
return '{BLF-CRYPT}'.password_hash($password, PASSWORD_BCRYPT);
}
private function db(?EmailServer $server = null): ConnectionInterface
{
if ($server) {
return $this->connections->connection($server->mailDatabaseServer());
}
return DB::connection('mailserver');
}
private function resolveServer(
?EmailServer $server = null,
?EmailDomain $emailDomain = null,
?Domain $domain = null,
?Mailbox $mailbox = null,
): EmailServer {
if ($server) {
return $server;
}
if ($mailbox?->emailDomain?->emailServer) {
return $mailbox->emailDomain->emailServer;
}
if ($mailbox?->domain?->emailServer) {
return $mailbox->domain->emailServer;
}
if ($emailDomain) {
if ($emailDomain->email_server_id) {
return $emailDomain->emailServer;
}
return $this->capacity->assignServerToEmailDomain($emailDomain);
}
if ($domain) {
if ($domain->email_server_id) {
return $domain->emailServer;
}
return $this->capacity->assignServerToDomain($domain);
}
$fallback = EmailServer::available()->where('is_default', true)->first()
?? EmailServer::available()->first();
if ($fallback) {
return $fallback;
}
throw new \RuntimeException('No email server configured. Add a primary mail pool in Admin → Email Servers or set MAIL_DB_* in .env.');
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Services\Mail;
use App\Models\Mailbox;
use App\Models\User;
use Illuminate\Support\Str;
/**
* Every mailbox holder gets a Ladill account (users.email = mailbox address).
* The Ladill login password matches the mailbox password so holders can sign in
* to account.ladill.com with the same credentials they use for webmail/IMAP.
*/
class MailboxUserProvisioner
{
public function __construct(private MailboxWebmailVault $vault) {}
public function ensureForMailbox(Mailbox $mailbox, ?string $password = null): User
{
$user = $this->ensureForAddress((string) $mailbox->address, (string) $mailbox->display_name, $password);
$this->syncHolderPassword($mailbox, $password);
return $user;
}
public function ensureForAddress(string $address, string $displayName = '', ?string $password = null): User
{
$email = strtolower(trim($address));
if ($email === '' || ! str_contains($email, '@')) {
throw new \InvalidArgumentException('Invalid mailbox address.');
}
$attributes = [
'name' => ($name = trim($displayName) ?: Str::headline(Str::before($email, '@'))),
'first_name' => Str::before($name, ' '),
'last_name' => trim(Str::after($name, ' ')) ?: '',
'password' => $password ?? Str::random(40),
];
$user = User::firstOrCreate(['email' => $email], $attributes);
if (! $user->wasRecentlyCreated && $password !== null && $password !== '') {
$user->password = $password;
$user->save();
}
if ($user->email_verified_at === null) {
$user->forceFill(['email_verified_at' => now()])->save();
}
return $user;
}
/**
* Keep the Ladill account password aligned with the mailbox password.
* Uses the plaintext when available; otherwise copies the stored bcrypt hash.
*/
public function syncHolderPassword(Mailbox $mailbox, ?string $plaintextPassword = null): void
{
$email = strtolower(trim((string) $mailbox->address));
if ($email === '' || ! str_contains($email, '@')) {
return;
}
$user = User::query()->where('email', $email)->first();
if (! $user) {
return;
}
if ($plaintextPassword !== null && $plaintextPassword !== '') {
$user->password = $plaintextPassword;
$user->save();
$this->vault->store($mailbox, $plaintextPassword);
return;
}
$hash = (string) $mailbox->password_hash;
if ($hash !== '') {
$this->assignPasswordHash($user, $hash);
}
}
public function provisionSilently(string $address, string $displayName = '', ?string $password = null): ?User
{
try {
return $this->ensureForAddress($address, $displayName, $password);
} catch (\Throwable $e) {
report($e);
return null;
}
}
public function provisionMailboxSilently(Mailbox $mailbox, ?string $password = null): ?User
{
try {
return $this->ensureForMailbox($mailbox, $password);
} catch (\Throwable $e) {
report($e);
return null;
}
}
private function assignPasswordHash(User $user, string $passwordHash): void
{
$user->getConnection()
->table($user->getTable())
->where($user->getKeyName(), $user->getKey())
->update(['password' => $passwordHash]);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Services\Mailbox;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Mailbox API (§4). Mail provisioning (Postfix/Dovecot
* via the mail DB + pools) stays on the platform; the Email app creates/manages
* mailboxes through here. Mailboxes are scoped to the user (public_id).
* Summary: {id, address, local_part, domain, quota_mb, status, is_paid, ...}.
*/
class MailboxClient
{
private function base(): string
{
return rtrim((string) config('mailbox.api_url'), '/');
}
private function http()
{
return Http::withToken((string) (config('mailbox.api_key') ?? ''))->acceptJson()->timeout(15);
}
/** All mailboxes for the user (optionally scoped to one email domain). */
public function forUser(string $publicId, ?int $emailDomainId = null): array
{
$q = ['user' => $publicId];
if ($emailDomainId) {
$q['email_domain_id'] = $emailDomainId;
}
return $this->data($this->http()->get($this->base(), $q));
}
public function show(string $publicId, int $id): array
{
return $this->data($this->http()->get($this->base().'/'.$id, ['user' => $publicId]));
}
/**
* Create a mailbox. The Email app does its own wallet billing (§4-A) before
* calling and passes is_paid + payment_reference. Returns the mailbox detail.
*/
public function create(string $publicId, int $emailDomainId, string $localPart, string $displayName, string $password, ?int $quotaMb = null, bool $isPaid = false, ?string $paymentReference = null): array
{
$res = $this->http()->post($this->base(), array_filter([
'user' => $publicId,
'email_domain_id' => $emailDomainId,
'local_part' => $localPart,
'display_name' => $displayName,
'password' => $password,
'quota_mb' => $quotaMb,
'is_paid' => $isPaid,
'payment_reference' => $paymentReference,
], fn ($v) => $v !== null));
$res->throw();
return (array) $res->json('data');
}
public function resetPassword(string $publicId, int $id, string $password): array
{
$res = $this->http()->patch($this->base().'/'.$id.'/password', ['user' => $publicId, 'password' => $password]);
$res->throw();
return (array) $res->json('data');
}
/** Change a mailbox's storage plan (quota). Billing is done before this call. */
public function updateQuota(string $publicId, int $id, int $quotaMb, bool $isPaid = false, ?string $paymentReference = null): array
{
$res = $this->http()->patch($this->base().'/'.$id.'/quota', array_filter([
'user' => $publicId,
'quota_mb' => $quotaMb,
'is_paid' => $isPaid,
'payment_reference' => $paymentReference,
], fn ($v) => $v !== null));
$res->throw();
return (array) $res->json('data');
}
public function delete(string $publicId, int $id): void
{
$this->http()->delete($this->base().'/'.$id, ['user' => $publicId])->throw();
}
private function data($res): array
{
$res->throw();
return (array) ($res->json('data') ?? $res->json());
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Services\Payment;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
/**
* Wallet payments for the Email product against the one platform UserWallet
* (Billing API, tagged 'email'). Mailboxes are wallet-funded; card top-ups
* happen on the account portal. Debits are idempotent by reference.
*/
class WalletPaymentService
{
public function __construct(private BillingClient $billing) {}
private function service(): string
{
return (string) config('billing.service', 'email');
}
public function isConfigured(): bool
{
return (string) config('billing.api_key') !== '';
}
public function balanceMinor(User $user): int
{
try {
return $this->billing->balanceMinor($user->public_id);
} catch (\Throwable $e) {
Log::warning('WalletPaymentService: balance failed', ['error' => $e->getMessage()]);
return 0;
}
}
public function canAfford(User $user, int $amountMinor): bool
{
if ($amountMinor <= 0) {
return true;
}
if (! $this->isConfigured()) {
return false;
}
try {
return $this->billing->canAfford($user->public_id, $amountMinor);
} catch (\Throwable $e) {
Log::warning('WalletPaymentService: canAfford failed', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance.
* Idempotent by $reference.
*/
public function charge(User $user, int $amountMinor, string $reference, string $source, string $description): bool
{
if ($amountMinor <= 0) {
return true;
}
if (! $this->isConfigured()) {
return false;
}
try {
return $this->billing->debit(
publicId: $user->public_id,
amountMinor: $amountMinor,
service: $this->service(),
source: $source,
reference: $reference,
description: $description,
);
} catch (\Throwable $e) {
Log::error('WalletPaymentService: charge failed', ['reference' => $reference, 'error' => $e->getMessage()]);
return false;
}
}
/** Refund (e.g. if provisioning fails after charge). Idempotent. */
public function refund(User $user, int $amountMinor, string $reference, string $description): void
{
if ($amountMinor <= 0 || ! $this->isConfigured()) {
return;
}
try {
$this->billing->credit($user->public_id, $amountMinor, $this->service(), 'mailbox_refund', $reference.':refund', null, $description);
} catch (\Throwable $e) {
Log::error('WalletPaymentService: refund failed', ['reference' => $reference, 'error' => $e->getMessage()]);
}
}
}
@@ -0,0 +1,587 @@
<?php
namespace App\Services\ResellerClub;
use App\Models\Domain;
use App\Models\PromoBanner;
use App\Models\RcServiceOrder;
use App\Models\User;
use App\Services\Domain\DomainRegistrarService;
use App\Support\ResellerClubLegacy;
use Carbon\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use RuntimeException;
class RcOrderCartService
{
/**
* @return array<int, string>
*/
public function cartStatuses(): array
{
return [
RcServiceOrder::STATUS_CART,
RcServiceOrder::STATUS_PENDING_PAYMENT,
];
}
public function cartItemsForUser(User $user): EloquentCollection
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->latest()
->get();
}
public function cartCountForUser(User $user): int
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->count();
}
/**
* @return array{items: EloquentCollection<int, RcServiceOrder>, count: int, reference: string|null}|null
*/
public function pendingCheckoutForUser(User $user): ?array
{
$items = RcServiceOrder::query()
->where('user_id', $user->id)
->where('status', RcServiceOrder::STATUS_PENDING_PAYMENT)
->latest()
->get();
if ($items->isEmpty()) {
return null;
}
return [
'items' => $items,
'count' => $items->count(),
'reference' => $items->pluck('payment_reference')->filter()->first(),
];
}
public function recentOrdersForUser(User $user, int $limit = 10): EloquentCollection
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereNotIn('status', $this->cartStatuses())
->latest()
->limit($limit)
->get();
}
public function addProductOrder(User $user, string $category, array $input, ResellerClubProductService $products): RcServiceOrder
{
ResellerClubLegacy::assertNewSaleAllowed();
$quote = $products->quoteForSelection(
$category,
(string) ($input['plan_id'] ?? ''),
(int) ($input['months'] ?? 0)
);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'This product cannot be added to the cart right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
$domainName = strtolower(trim((string) ($input['domain_name'] ?? $category)));
return $this->upsertCartItem(
$user,
[
'category' => $category,
'order_type' => RcServiceOrder::TYPE_SERVICE,
'domain_name' => $domainName,
'plan_id' => (string) ($input['plan_id'] ?? ''),
'plan_name' => (string) ($quote['plan_name'] ?? $products->packageLabel($category, (string) ($input['plan_id'] ?? ''))),
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => (int) ($input['months'] ?? 1),
'term_years' => null,
'meta' => [
'order_input' => $input,
'quote' => $quote,
],
]
);
}
public function addDomainRegistration(User $user, string $domain, DomainRegistrarService $registrar): RcServiceOrder
{
ResellerClubLegacy::assertNewSaleAllowed();
$domainName = strtolower(trim($domain));
if (Domain::query()->where('host', $domainName)->exists()) {
throw new RuntimeException('This domain is already registered in our system.');
}
$quote = $registrar->quoteDomainRegistration($domainName);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'Domain pricing is unavailable right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
return $this->upsertCartItem(
$user,
[
'category' => 'domain-registration',
'order_type' => RcServiceOrder::TYPE_DOMAIN_REGISTRATION,
'domain_name' => $domainName,
'plan_id' => null,
'plan_name' => '1 year registration',
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => null,
'term_years' => 1,
'meta' => [
'quote' => $quote,
],
]
);
}
public function addDomainTransfer(User $user, string $domain, ?string $authCode, DomainRegistrarService $registrar): RcServiceOrder
{
ResellerClubLegacy::assertNewSaleAllowed();
$domainName = strtolower(trim($domain));
$quote = $registrar->quoteDomainTransfer($domainName);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'This domain cannot be transferred right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
$meta = [
'quote' => $quote,
];
$authCode = trim((string) $authCode);
if ($authCode !== '') {
$meta['transfer_auth_code_encrypted'] = Crypt::encryptString($authCode);
}
return $this->upsertCartItem(
$user,
[
'category' => 'domain-transfer',
'order_type' => RcServiceOrder::TYPE_DOMAIN_TRANSFER,
'domain_name' => $domainName,
'plan_id' => null,
'plan_name' => 'Domain transfer',
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => null,
'term_years' => 1,
'meta' => $meta,
]
);
}
public function addServiceRenewal(
User $user,
RcServiceOrder $existingOrder,
int $months,
ResellerClubProductService $products
): RcServiceOrder {
ResellerClubLegacy::assertRenewalsAllowed();
if ((int) $existingOrder->user_id !== (int) $user->id) {
throw new RuntimeException('You can only renew your own services.');
}
if (! filled($existingOrder->rc_order_id)) {
throw new RuntimeException('This service cannot be renewed online yet. Please contact support.');
}
if (! $products->supportsCategory($existingOrder->category)) {
throw new RuntimeException('Renewals are not available for this product type.');
}
$months = max(1, min(120, $months));
$planId = (string) ($existingOrder->plan_id ?? '');
$quote = $products->quoteRenewal($existingOrder->category, $planId, $months);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'Renewal pricing is unavailable right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
return $this->upsertCartItem(
$user,
[
'category' => $existingOrder->category,
'order_type' => RcServiceOrder::TYPE_RENEWAL,
'domain_name' => $existingOrder->domain_name,
'plan_id' => $planId !== '' ? $planId : null,
'plan_name' => (string) ($quote['plan_name'] ?? $existingOrder->plan_name ?? 'Service renewal'),
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => $months,
'term_years' => null,
'meta' => [
'is_renewal' => true,
'renew_rc_order_id' => (string) $existingOrder->rc_order_id,
'renew_source_order_id' => $existingOrder->id,
'quote' => $quote,
],
]
);
}
public function addDomainRenewal(
User $user,
string $domain,
int $years,
DomainRegistrarService $registrar
): RcServiceOrder {
ResellerClubLegacy::assertRenewalsAllowed();
$domainName = strtolower(trim($domain));
$years = max(1, min(10, $years));
$quote = $registrar->quoteDomainRenewal($domainName, $years);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'Domain renewal pricing is unavailable right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
return $this->upsertCartItem(
$user,
[
'category' => 'domain-renewal',
'order_type' => RcServiceOrder::TYPE_RENEWAL,
'domain_name' => $domainName,
'plan_id' => null,
'plan_name' => $years === 1 ? '1 year renewal' : "{$years} year renewal",
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => null,
'term_years' => $years,
'meta' => [
'is_renewal' => true,
'renew_rc_order_id' => (string) ($quote['rc_order_id'] ?? ''),
'quote' => $quote,
],
]
);
}
/**
* @return array{items: EloquentCollection<int, RcServiceOrder>, total_minor: int, currency: string, subtotal_minor: int, discount_minor: int, applied_promos: array, promo_name: string|null}
*/
public function checkoutSummaryForUser(User $user): array
{
$items = $this->cartItemsForUser($user);
ResellerClubLegacy::assertCartCheckoutAllowed($items);
$currency = strtoupper((string) $items->first()->currency);
if ($items->contains(fn (RcServiceOrder $item) => strtoupper((string) $item->currency) !== $currency)) {
throw new RuntimeException('Your cart contains items in more than one currency. Clear it and try again with a single currency.');
}
$subtotalMinor = (int) $items->sum('amount_minor');
// Get all active promos with discounts
$activePromos = PromoBanner::active()
->whereNotNull('discount_percent')
->where('discount_percent', '>', 0)
->get();
// Calculate per-item discounts based on product-specific promos
$appliedPromos = [];
$totalDiscountMinor = 0;
foreach ($items as $item) {
$itemPromo = $this->findMatchingPromoForItem($item, $activePromos);
if ($itemPromo) {
$discountPercent = (int) $itemPromo->discount_percent;
$itemDiscount = (int) round($item->amount_minor * ($discountPercent / 100));
$totalDiscountMinor += $itemDiscount;
// Track applied promos for display
$promoKey = $itemPromo->id;
if (!isset($appliedPromos[$promoKey])) {
$appliedPromos[$promoKey] = [
'id' => $itemPromo->id,
'name' => $itemPromo->name,
'title' => $itemPromo->title ?: $itemPromo->name,
'product_type' => $itemPromo->product_type,
'discount_percent' => $discountPercent,
'discount_minor' => 0,
'items' => [],
];
}
$appliedPromos[$promoKey]['discount_minor'] += $itemDiscount;
$appliedPromos[$promoKey]['items'][] = [
'id' => $item->id,
'domain_name' => $item->domain_name,
'category' => $item->category,
'original_amount' => $item->amount_minor,
'discount_amount' => $itemDiscount,
];
}
}
$totalMinor = max(0, $subtotalMinor - $totalDiscountMinor);
// For backward compatibility, provide a single promo name if only one promo applied
$promoName = count($appliedPromos) === 1 ? array_values($appliedPromos)[0]['name'] : null;
$discountPercent = count($appliedPromos) === 1 ? array_values($appliedPromos)[0]['discount_percent'] : null;
return [
'items' => $items,
'currency' => $currency,
'subtotal_minor' => $subtotalMinor,
'discount_minor' => $totalDiscountMinor,
'discount_percent' => $discountPercent,
'promo_name' => $promoName,
'applied_promos' => array_values($appliedPromos),
'total_minor' => $totalMinor,
];
}
/**
* Find the best matching promo for a cart item based on product type.
*/
private function findMatchingPromoForItem(RcServiceOrder $item, $promos): ?PromoBanner
{
$itemProductType = $this->mapItemToProductType($item);
// First, try to find a product-specific promo
$specificPromo = $promos->first(function (PromoBanner $promo) use ($itemProductType) {
return $promo->product_type && $promo->product_type === $itemProductType;
});
if ($specificPromo) {
return $specificPromo;
}
// Fall back to general promos (no product_type set or product_type is 'general')
return $promos->first(function (PromoBanner $promo) {
return !$promo->product_type || $promo->product_type === PromoBanner::TYPE_GENERAL;
});
}
/**
* Map a cart item to a promo product type.
*/
private function mapItemToProductType(RcServiceOrder $item): string
{
// Domain orders
if (in_array($item->order_type, [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER, RcServiceOrder::TYPE_RENEWAL], true)
&& str_contains((string) $item->category, 'domain')) {
return PromoBanner::TYPE_DOMAINS;
}
// Map category to product type
$categoryMap = [
'domain-registration' => PromoBanner::TYPE_DOMAINS,
'domain-transfer' => PromoBanner::TYPE_DOMAINS,
'single-domain-hosting' => PromoBanner::TYPE_HOSTING,
'multi-domain-hosting' => PromoBanner::TYPE_HOSTING,
'reseller-hosting' => PromoBanner::TYPE_HOSTING,
'cloud-hosting' => PromoBanner::TYPE_HOSTING,
'vps' => PromoBanner::TYPE_VPS,
'dedicated-server' => PromoBanner::TYPE_DEDICATED,
'email' => PromoBanner::TYPE_EMAIL,
];
return $categoryMap[$item->category] ?? PromoBanner::TYPE_GENERAL;
}
/**
* @param Collection<int, RcServiceOrder>|EloquentCollection<int, RcServiceOrder> $items
*/
public function markCheckoutPending(iterable $items, string $reference): void
{
foreach ($items as $item) {
$item->forceFill([
'status' => RcServiceOrder::STATUS_PENDING_PAYMENT,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PENDING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_PAYMENT_PENDING,
'payment_reference' => $reference,
])->save();
}
}
/**
* @return EloquentCollection<int, RcServiceOrder>
*/
public function markPaymentSuccessful(array $metadata, string $reference, ?Carbon $paidAt = null): EloquentCollection
{
$orders = $this->ordersForPaymentMetadata($metadata, $reference);
$transitioned = new EloquentCollection();
foreach ($orders as $order) {
if ($order->payment_status === RcServiceOrder::PAYMENT_STATUS_PAID) {
continue;
}
$order->forceFill([
'status' => RcServiceOrder::STATUS_PROCESSING,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_PAYMENT_RECEIVED,
'payment_reference' => $reference,
'paid_at' => $paidAt ?: now(),
])->save();
$transitioned->push($order->fresh());
}
return $transitioned;
}
public function markPaymentFailed(array $metadata, string $reference): void
{
$orders = $this->ordersForPaymentMetadata($metadata, $reference);
foreach ($orders as $order) {
if ($order->payment_status === RcServiceOrder::PAYMENT_STATUS_PAID) {
continue;
}
$order->forceFill([
'status' => RcServiceOrder::STATUS_CART,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_FAILED,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART,
'payment_reference' => null,
])->save();
}
}
public function returnPendingCheckoutToCart(User $user): int
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->where('status', RcServiceOrder::STATUS_PENDING_PAYMENT)
->update([
'status' => RcServiceOrder::STATUS_CART,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_UNPAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART,
'payment_reference' => null,
]);
}
public function removeCartItem(User $user, RcServiceOrder $order): void
{
if ((int) $order->user_id !== (int) $user->id || ! in_array($order->status, $this->cartStatuses(), true)) {
throw new RuntimeException('This cart item cannot be removed.');
}
$order->delete();
}
public function clearCart(User $user): int
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->delete();
}
/**
* @param array<string, mixed> $attributes
*/
private function upsertCartItem(User $user, array $attributes): RcServiceOrder
{
$query = RcServiceOrder::query()
->where('user_id', $user->id)
->where('order_type', (string) $attributes['order_type'])
->where('category', (string) $attributes['category'])
->where('domain_name', (string) $attributes['domain_name'])
->whereIn('status', $this->cartStatuses());
if (isset($attributes['plan_id']) && $attributes['plan_id'] !== null) {
$query->where('plan_id', (string) $attributes['plan_id']);
} else {
$query->whereNull('plan_id');
}
if (isset($attributes['term_months']) && $attributes['term_months'] !== null) {
$query->where('term_months', (int) $attributes['term_months']);
} else {
$query->whereNull('term_months');
}
$existing = $query->latest('id')->first();
$payload = array_merge($attributes, [
'user_id' => $user->id,
'status' => RcServiceOrder::STATUS_CART,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_UNPAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART,
'payment_reference' => null,
'paid_at' => null,
'submitted_at' => null,
'last_synced_at' => null,
]);
if ($existing) {
$existing->forceFill($payload)->save();
return $existing->fresh();
}
return RcServiceOrder::query()->create($payload);
}
/**
* @param array<string, mixed> $metadata
* @return EloquentCollection<int, RcServiceOrder>
*/
private function ordersForPaymentMetadata(array $metadata, string $reference): EloquentCollection
{
$ids = collect($metadata['rc_service_order_ids'] ?? [])
->map(fn ($value) => (int) $value)
->filter()
->values();
if ($ids->isNotEmpty()) {
return RcServiceOrder::query()
->whereIn('id', $ids->all())
->get();
}
return RcServiceOrder::query()
->where('payment_reference', $reference)
->get();
}
private function assertCurrencyCompatibility(User $user, string $currency): void
{
$cartCurrency = RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->value('currency');
if ($cartCurrency && strtoupper((string) $cartCurrency) !== strtoupper($currency)) {
throw new RuntimeException(sprintf(
'Your cart already contains %s items. Complete or clear that checkout before adding a %s item.',
strtoupper((string) $cartCurrency),
strtoupper($currency)
));
}
}
}
@@ -0,0 +1,275 @@
<?php
namespace App\Services\ResellerClub;
use App\Models\RcServiceOrder;
use App\Services\Domain\DomainRegistrarService;
use App\Support\ResellerClubLegacy;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
class RcOrderFulfillmentService
{
public function __construct(
private readonly ResellerClubCustomerService $customers,
private readonly ResellerClubProductService $products,
private readonly DomainRegistrarService $registrar,
) {
}
public function processPaidOrder(int|RcServiceOrder $order): void
{
$order = $order instanceof RcServiceOrder
? $order->fresh(['user'])
: RcServiceOrder::query()->with('user')->find($order);
if (! $order || $order->payment_status !== RcServiceOrder::PAYMENT_STATUS_PAID) {
return;
}
if (in_array($order->fulfillment_status, [RcServiceOrder::FULFILLMENT_SUBMITTING, RcServiceOrder::FULFILLMENT_FULFILLED], true)) {
return;
}
if (! ResellerClubLegacy::canAutomateFulfillment($order)) {
$message = ResellerClubLegacy::isRenewalRcServiceOrder($order)
? ResellerClubLegacy::renewalsDisabledMessage()
: ResellerClubLegacy::fulfillmentDisabledMessage();
$this->markManualReview($order, $message);
return;
}
$order->forceFill([
'status' => RcServiceOrder::STATUS_PROCESSING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_SUBMITTING,
])->save();
$resolved = $this->customers->resolveCustomerForUser($order->user);
if (! ($resolved['success'] ?? false)) {
$this->markManualReview($order, $resolved['message'] ?? 'Could not prepare the ResellerClub customer.');
return;
}
$customerId = (string) ($resolved['customer_id'] ?? '');
match ($order->order_type) {
RcServiceOrder::TYPE_DOMAIN_REGISTRATION => $this->submitDomainRegistration($order, $customerId),
RcServiceOrder::TYPE_DOMAIN_TRANSFER => $this->submitDomainTransfer($order, $customerId),
RcServiceOrder::TYPE_RENEWAL => $this->submitRenewal($order, $customerId),
default => $this->submitServiceOrder($order, $customerId),
};
}
private function submitDomainRegistration(RcServiceOrder $order, string $customerId): void
{
$contact = $this->registrar->resolveContactForCustomer($order->user, $customerId);
if (! ($contact['success'] ?? false)) {
$this->markManualReview($order, $contact['message'] ?? 'Could not prepare the domain contact.');
return;
}
$registration = $this->registrar->registerDomain(
$order->domain_name,
max(1, (int) ($order->term_years ?: 1)),
$customerId,
(string) ($contact['contact_id'] ?? '')
);
if (! ($registration['success'] ?? false)) {
$this->markManualReview($order, $registration['message'] ?? 'The registrar did not accept the domain order.');
return;
}
$this->markAwaitingVendor($order, $customerId, (string) ($registration['order_id'] ?? ''));
}
private function submitDomainTransfer(RcServiceOrder $order, string $customerId): void
{
$contact = $this->registrar->resolveContactForCustomer($order->user, $customerId);
if (! ($contact['success'] ?? false)) {
$this->markManualReview($order, $contact['message'] ?? 'Could not prepare the domain contact.');
return;
}
$encryptedAuthCode = (string) Arr::get($order->meta ?? [], 'transfer_auth_code_encrypted', '');
$authCode = $encryptedAuthCode !== '' ? Crypt::decryptString($encryptedAuthCode) : null;
$transfer = $this->registrar->transferDomain(
$order->domain_name,
$authCode,
$customerId,
(string) ($contact['contact_id'] ?? '')
);
if (! ($transfer['success'] ?? false)) {
$this->markManualReview($order, $transfer['message'] ?? 'The registrar did not accept the transfer request.');
return;
}
$this->markAwaitingVendor($order, $customerId, (string) ($transfer['order_id'] ?? ''));
}
private function submitRenewal(RcServiceOrder $order, string $customerId): void
{
$rcOrderId = (string) data_get($order->meta, 'renew_rc_order_id', $order->rc_order_id ?? '');
if ($rcOrderId === '') {
$this->markManualReview($order, 'Renewal is missing the upstream ResellerClub order id.');
return;
}
if (in_array($order->category, ['domain-renewal', 'domain-registration', 'domain-transfer'], true)
&& $order->order_type === RcServiceOrder::TYPE_RENEWAL) {
$years = max(1, (int) ($order->term_years ?: 1));
$renewal = $this->registrar->renewDomain(
$order->domain_name,
$years,
$customerId
);
if (! ($renewal['success'] ?? false)) {
$this->markManualReview($order, $renewal['message'] ?? 'The registrar did not accept the domain renewal.');
return;
}
$this->markRenewalComplete($order, $customerId, (string) ($renewal['order_id'] ?? $rcOrderId));
return;
}
$months = max(1, (int) ($order->term_months ?: 1));
$renewal = $this->products->renewOrder($order->category, $rcOrderId, $months);
if (! ($renewal['success'] ?? false)) {
$this->markManualReview($order, $renewal['message'] ?? 'The registrar did not accept the renewal.');
return;
}
$this->markRenewalComplete($order, $customerId, $rcOrderId);
}
private function markRenewalComplete(RcServiceOrder $order, string $customerId, string $rcOrderId): void
{
$details = $this->products->getOrderDetails($order->category === 'domain-renewal' ? 'domain-registration' : $order->category, $rcOrderId);
if ($details['success'] ?? false) {
$synced = $this->products->syncOrderToLocal(
$order->category === 'domain-renewal' ? 'domain-registration' : $order->category,
(array) ($details['order'] ?? []),
$order->user_id,
$customerId,
$order
);
$synced->forceFill([
'status' => $synced->status === RcServiceOrder::STATUS_ACTIVE
? RcServiceOrder::STATUS_ACTIVE
: RcServiceOrder::STATUS_PROCESSING,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_FULFILLED,
'rc_order_id' => $rcOrderId,
'submitted_at' => $synced->submitted_at ?: now(),
'last_synced_at' => now(),
])->save();
return;
}
$order->forceFill([
'rc_customer_id' => $customerId,
'rc_order_id' => $rcOrderId,
'status' => RcServiceOrder::STATUS_ACTIVE,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_FULFILLED,
'submitted_at' => $order->submitted_at ?: now(),
'last_synced_at' => now(),
])->save();
}
private function submitServiceOrder(RcServiceOrder $order, string $customerId): void
{
$input = (array) Arr::get($order->meta ?? [], 'order_input', []);
$input['domain_name'] = $input['domain_name'] ?? $order->domain_name;
$input['plan_id'] = $input['plan_id'] ?? $order->plan_id;
$input['months'] = $input['months'] ?? $order->term_months ?? 1;
$result = $this->products->order($order->category, $customerId, $input);
if (! ($result['success'] ?? false)) {
$this->markManualReview($order, $result['message'] ?? 'The provisioning request could not be submitted.');
return;
}
$orderId = (string) ($result['order_id'] ?? '');
if ($orderId === '') {
$this->markManualReview($order, 'The provisioning request did not return an upstream order id.');
return;
}
$details = $this->products->getOrderDetails($order->category, $orderId);
if ($details['success'] ?? false) {
$synced = $this->products->syncOrderToLocal(
$order->category,
(array) ($details['order'] ?? []),
$order->user_id,
$customerId,
$order
);
$synced->forceFill([
'status' => $synced->status === RcServiceOrder::STATUS_ACTIVE
? RcServiceOrder::STATUS_ACTIVE
: RcServiceOrder::STATUS_PROCESSING,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID,
'fulfillment_status' => $synced->status === RcServiceOrder::STATUS_ACTIVE
? RcServiceOrder::FULFILLMENT_FULFILLED
: RcServiceOrder::FULFILLMENT_AWAITING_VENDOR,
'submitted_at' => $synced->submitted_at ?: now(),
'last_synced_at' => now(),
])->save();
return;
}
$this->markAwaitingVendor($order, $customerId, $orderId, $details['message'] ?? null);
}
private function markAwaitingVendor(RcServiceOrder $order, string $customerId, string $orderId, ?string $message = null): void
{
$meta = (array) ($order->meta ?? []);
if ($message !== null && $message !== '') {
$meta['last_message'] = $message;
}
$order->forceFill([
'rc_customer_id' => $customerId,
'rc_order_id' => $orderId !== '' ? $orderId : $order->rc_order_id,
'status' => RcServiceOrder::STATUS_PROCESSING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_AWAITING_VENDOR,
'submitted_at' => $order->submitted_at ?: now(),
'last_synced_at' => now(),
'meta' => $meta,
])->save();
}
private function markManualReview(RcServiceOrder $order, string $message): void
{
$meta = (array) ($order->meta ?? []);
$meta['last_message'] = $message;
$order->forceFill([
'status' => RcServiceOrder::STATUS_PROCESSING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_MANUAL_REVIEW,
'meta' => $meta,
])->save();
}
}
@@ -0,0 +1,328 @@
<?php
namespace App\Services\ResellerClub;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubConnectivityDiagnosticsService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private string $apiUrl;
private string $authUserId;
private string $apiKey;
public function __construct()
{
$value = trim((string) config('mailinfra.registrar_api_url'));
$this->apiUrl = rtrim($value !== '' ? $value : self::DEFAULT_API_URL, '/');
$this->authUserId = (string) config('mailinfra.registrar_auth_userid');
$this->apiKey = (string) config('mailinfra.registrar_api_key');
}
/**
* @return array{
* configured: bool,
* api_url: string,
* email: string,
* initial_customer_id: string,
* resolved_customer_id: string,
* probes: array<int, array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* }>
* }
*/
public function run(string $email, ?string $customerId, string $ipAddress): array
{
$email = strtolower(trim($email));
$customerId = trim((string) $customerId);
$probes = [];
if ($this->authUserId === '' || $this->apiKey === '') {
$probes[] = $this->probeResult(
'Configuration',
'error',
'LOCAL',
'configuration',
[],
null,
'ResellerClub credentials are missing.'
);
return [
'configured' => false,
'api_url' => $this->apiUrl,
'email' => $email,
'initial_customer_id' => $customerId,
'resolved_customer_id' => '',
'probes' => $probes,
];
}
$probes[] = $this->probeResult(
'Configuration',
'success',
'LOCAL',
'configuration',
['api-url' => $this->apiUrl, 'auth-userid' => $this->maskValue($this->authUserId)],
null,
'ResellerClub credentials are configured.'
);
$resolvedCustomerId = $customerId;
if ($email !== '') {
$lookup = $this->probeLookup($email);
$probes[] = $lookup['probe'];
if (($lookup['customer_id'] ?? '') !== '') {
$resolvedCustomerId = (string) $lookup['customer_id'];
}
}
if ($resolvedCustomerId !== '') {
$probes[] = $this->probeGenerateToken($resolvedCustomerId, $ipAddress);
}
return [
'configured' => true,
'api_url' => $this->apiUrl,
'email' => $email,
'initial_customer_id' => $customerId,
'resolved_customer_id' => $resolvedCustomerId,
'probes' => $probes,
];
}
/**
* @return array{
* probe: array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* },
* customer_id?: string
* }
*/
private function probeLookup(string $email): array
{
$endpoint = $this->apiUrl.'/customers/details.json';
$request = [
'auth-userid' => $this->maskValue($this->authUserId),
'username' => $email,
];
try {
$response = Http::timeout(15)->get($endpoint, [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'username' => $email,
]);
$data = $response->json();
$summary = $this->summarizeResponse($data, (string) $response->body());
$customerId = is_array($data) ? (string) ($data['customerid'] ?? $data['customer-id'] ?? '') : '';
return [
'probe' => $this->probeResult(
'Customer lookup',
$response->successful() ? 'success' : 'error',
'GET',
$endpoint,
$request,
$response->status(),
$summary !== '' ? $summary : 'No response summary returned.'
),
'customer_id' => $customerId,
];
} catch (\Throwable $e) {
Log::warning('ResellerClub connectivity diagnostics: customer lookup failed', [
'email' => $email,
'error' => $e->getMessage(),
]);
return [
'probe' => $this->probeResult(
'Customer lookup',
'error',
'GET',
$endpoint,
$request,
null,
$this->sanitizeText($e->getMessage())
),
];
}
}
/**
* @return array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* }
*/
private function probeGenerateToken(string $customerId, string $ipAddress): array
{
$endpoint = $this->apiUrl.'/customers/generate-login-token.json';
$request = [
'auth-userid' => $this->maskValue($this->authUserId),
'customer-id' => $customerId,
'ip' => $ipAddress,
];
try {
$response = Http::timeout(15)->get($endpoint, [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'customer-id' => $customerId,
'ip' => $ipAddress,
]);
$data = $response->json();
$summary = $this->summarizeResponse($data, (string) $response->body());
return $this->probeResult(
'Generate login token',
$response->successful() ? 'success' : 'error',
'GET',
$endpoint,
$request,
$response->status(),
$summary !== '' ? $summary : 'No response summary returned.'
);
} catch (\Throwable $e) {
Log::warning('ResellerClub connectivity diagnostics: token generation failed', [
'customer_id' => $customerId,
'error' => $e->getMessage(),
]);
return $this->probeResult(
'Generate login token',
'error',
'GET',
$endpoint,
$request,
null,
$this->sanitizeText($e->getMessage())
);
}
}
/**
* @param array<string, mixed>|mixed $data
*/
private function summarizeResponse(mixed $data, string $body): string
{
if (is_array($data)) {
foreach (['message', 'error', 'description', 'msg'] as $key) {
$value = $this->sanitizeText((string) ($data[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
if (($data['customerid'] ?? null) !== null) {
return 'Customer found: '.(string) $data['customerid'];
}
if (($data['token'] ?? null) !== null || ($data['loginid'] ?? null) !== null || ($data['userLoginId'] ?? null) !== null) {
return 'Login token generated successfully.';
}
}
return $this->sanitizeText($body);
}
private function sanitizeText(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($this->looksLikeHtmlDocument($value)) {
return 'HTML error page from upstream. ResellerClub temporarily blocked or rejected the request.';
}
$plain = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? '');
return mb_strimwidth($plain, 0, 240, '...');
}
private function looksLikeHtmlDocument(string $value): bool
{
$normalized = strtolower(ltrim($value));
return str_starts_with($normalized, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, 'cloudflare');
}
/**
* @param array<string, string> $request
* @return array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* }
*/
private function probeResult(
string $label,
string $status,
string $method,
string $endpoint,
array $request,
?int $httpStatus,
string $summary
): array {
return [
'label' => $label,
'status' => $status,
'method' => $method,
'endpoint' => $endpoint,
'request' => $request,
'http_status' => $httpStatus !== null ? (string) $httpStatus : 'n/a',
'summary' => $summary,
];
}
private function maskValue(string $value): string
{
$value = trim($value);
if ($value === '') {
return 'missing';
}
if (strlen($value) <= 4) {
return str_repeat('*', strlen($value));
}
return substr($value, 0, 2).str_repeat('*', max(strlen($value) - 4, 2)).substr($value, -2);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Services\ResellerClub;
class ResellerClubConsoleService
{
private const DEFAULT_CONTROL_PANEL_URL = 'https://manage.resellerclub.com/customer';
public const SERVICE_DNS = 'dns';
public const SERVICE_WEB_HOSTING = 'webhosting';
public const SERVICE_MAIL_HOSTING = 'mailhosting';
public const SERVICE_WEBSITE_BUILDER = 'websitebuilder';
public function launchUrl(): string
{
return rtrim($this->configuredControlPanelUrl(), '/').'/servlet/ManageServiceServletForAPI';
}
/**
* @return array<string, string>
*/
public function buildManagedServicePayload(string $loginToken, string $orderId, string $serviceName): array
{
return [
'loginid' => $loginToken,
'orderid' => $orderId,
'service-name' => $serviceName,
];
}
public function autoLoginUrl(string $loginToken, string $role = 'customer'): string
{
return sprintf(
'%s/servlet/AutoLoginServlet?userLoginId=%s&role=%s',
rtrim($this->configuredControlPanelUrl(), '/'),
urlencode($loginToken),
urlencode($role)
);
}
private function configuredControlPanelUrl(): string
{
$value = trim((string) config('mailinfra.resellerclub_control_panel_url'));
return $value !== '' ? $value : self::DEFAULT_CONTROL_PANEL_URL;
}
}
@@ -0,0 +1,655 @@
<?php
namespace App\Services\ResellerClub;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubCustomerService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private string $apiUrl;
private string $authUserId;
private string $apiKey;
public function __construct()
{
$this->apiUrl = rtrim($this->configuredApiUrl(), '/');
$this->authUserId = (string) config('mailinfra.registrar_auth_userid');
$this->apiKey = (string) config('mailinfra.registrar_api_key');
}
public function isConfigured(): bool
{
return $this->authUserId !== '' && $this->apiKey !== '';
}
/**
* Build a URL with all parameters as query string values, including auth.
*
* @param array<string, mixed> $params
*/
private function buildQueryUrl(string $endpoint, array $params = []): string
{
$all = array_merge([
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
], $params);
$separator = str_contains($endpoint, '?') ? '&' : '?';
return $endpoint.$separator.http_build_query($all);
}
/**
* @return array{success: bool, customer_id?: string, created?: bool, message?: string}
*/
public function resolveCustomerForUser(User $user): array
{
if (! $this->isConfigured()) {
return ['success' => false, 'message' => 'ResellerClub is not configured.'];
}
if ($user->hasResellerClubCustomer()) {
return [
'success' => true,
'customer_id' => (string) $user->rc_customer_id,
'created' => false,
];
}
$email = strtolower(trim((string) $user->email));
if ($email === '') {
return ['success' => false, 'message' => 'User email is required to provision a ResellerClub customer.'];
}
$lookup = $this->findCustomerByEmail($email);
if (! ($lookup['success'] ?? false)) {
return ['success' => false, 'message' => $lookup['message'] ?? 'Could not verify the ResellerClub customer.'];
}
if ($lookup['found'] ?? false) {
return $this->attachCustomerToUser(
$user,
(string) $lookup['customer_id'],
$email,
false
);
}
$created = $this->createCustomerForUser($user);
if (! ($created['success'] ?? false)) {
return ['success' => false, 'message' => $created['message'] ?? 'Could not create the ResellerClub customer.'];
}
$attached = $this->attachCustomerToUser(
$user,
(string) $created['customer_id'],
$email,
false
);
if (! ($attached['success'] ?? false)) {
return $attached;
}
return [
'success' => true,
'customer_id' => (string) $attached['customer_id'],
'created' => true,
];
}
/**
* Best-effort customer provisioning for user creation hooks.
*/
public function provisionCustomerForUser(User $user): ?string
{
$result = $this->resolveCustomerForUser($user);
if (! ($result['success'] ?? false)) {
Log::warning('ResellerClubCustomerService: automatic provisioning skipped', [
'user_id' => $user->id,
'message' => $result['message'] ?? 'Unknown error.',
]);
return null;
}
return (string) ($result['customer_id'] ?? '');
}
/**
* @return array{success: bool, customer_id?: string, found?: bool, message?: string}
*/
public function findCustomerByEmail(string $email): array
{
try {
$response = Http::timeout(15)->get($this->apiUrl.'/customers/details.json', [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'username' => strtolower(trim($email)),
]);
$data = $response->json();
if ($response->successful() && is_array($data)) {
$customerId = (string) ($data['customerid'] ?? $data['customer-id'] ?? '');
if ($customerId !== '') {
return [
'success' => true,
'found' => true,
'customer_id' => $customerId,
];
}
}
if (in_array($response->status(), [400, 404], true)) {
return [
'success' => true,
'found' => false,
];
}
$message = $this->extractErrorMessage(is_array($data) ? $data : [], $response->body());
if ($message !== '' && $this->looksLikeMissingCustomerMessage($message)) {
return [
'success' => true,
'found' => false,
];
}
Log::warning('ResellerClubCustomerService: customer lookup failed', [
'email' => $email,
'status' => $response->status(),
'response' => $data ?: $response->body(),
]);
return [
'success' => false,
'message' => $message !== '' ? $message : 'Could not look up the ResellerClub customer.',
];
} catch (\Throwable $e) {
Log::error('ResellerClubCustomerService: findCustomerByEmail failed', [
'email' => $email,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not look up the ResellerClub customer.'];
}
}
/**
* @return array{success: bool, customer_id?: string, message?: string}
*/
public function createCustomerForUser(User $user): array
{
$defaults = (array) config('mailinfra.resellerclub_customer_defaults', []);
$email = strtolower(trim((string) $user->email));
$name = trim(implode(' ', array_filter([
trim((string) ($user->first_name ?? '')),
trim((string) ($user->last_name ?? '')),
])));
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->apiUrl.'/customers/v2/signup.xml', [
'username' => $email,
'passwd' => $this->generatePlatformPassword(),
'name' => $name !== '' ? $name : (trim((string) $user->name) !== '' ? (string) $user->name : $email),
'company' => trim((string) ($user->company ?? '')) !== '' ? (string) $user->company : (string) ($defaults['company'] ?? config('app.name', 'Ladill')),
'address-line-1' => trim((string) ($user->address_line_1 ?? '')) !== '' ? (string) $user->address_line_1 : (string) ($defaults['address_line_1'] ?? 'Not provided'),
'city' => trim((string) ($user->city ?? '')) !== '' ? (string) $user->city : (string) ($defaults['city'] ?? 'Accra'),
'state' => trim((string) ($user->state ?? '')) !== '' ? (string) $user->state : (string) ($defaults['state'] ?? 'Not Applicable'),
'country' => trim((string) ($user->country ?? '')) !== '' ? strtoupper((string) $user->country) : (string) ($defaults['country'] ?? 'GH'),
'zipcode' => trim((string) ($user->zipcode ?? '')) !== '' ? (string) $user->zipcode : (string) ($defaults['zipcode'] ?? '00000'),
'phone-cc' => trim((string) ($user->phone_cc ?? '')) !== '' ? ltrim((string) $user->phone_cc, '+') : (string) ($defaults['phone_cc'] ?? '233'),
'phone' => trim((string) ($user->phone ?? '')) !== '' ? (string) $user->phone : (string) ($defaults['phone'] ?? '000000000'),
'lang-pref' => (string) ($defaults['lang_pref'] ?? 'en'),
'address-line-2' => trim((string) ($user->address_line_2 ?? '')) !== '' ? (string) $user->address_line_2 : (string) ($defaults['address_line_2'] ?? ''),
'address-line-3' => (string) ($defaults['address_line_3'] ?? ''),
]));
$body = trim((string) $response->body());
$customerId = $this->extractCustomerIdFromBody($body);
if ($response->failed() || $customerId === '') {
$message = $this->extractXmlErrorMessage($body);
Log::warning('ResellerClubCustomerService: customer signup failed', [
'user_id' => $user->id,
'email' => $email,
'status' => $response->status(),
'body' => $body,
]);
return [
'success' => false,
'message' => $message !== '' ? $message : 'Could not create the ResellerClub customer.',
];
}
return [
'success' => true,
'customer_id' => $customerId,
];
} catch (\Throwable $e) {
Log::error('ResellerClubCustomerService: createCustomerForUser failed', [
'user_id' => $user->id,
'email' => $email,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not create the ResellerClub customer.'];
}
}
/**
* @return array{success: bool, customer_id?: string, message?: string}
*/
public function attachCustomerToUser(User $user, string $customerId, ?string $email = null, bool $markLinked = false): array
{
$customerId = trim($customerId);
if ($customerId === '') {
return ['success' => false, 'message' => 'A valid ResellerClub customer ID is required.'];
}
$existing = User::query()
->where('rc_customer_id', $customerId)
->where('id', '!=', $user->id)
->first();
if ($existing) {
return ['success' => false, 'message' => 'This ResellerClub account is already linked to another Ladill account.'];
}
$payload = [
'rc_customer_id' => $customerId,
'rc_email' => $email ?: $user->rc_email ?: $user->email,
];
if ($markLinked) {
$payload['rc_linked_at'] = now();
}
$user->forceFill($payload)->save();
return [
'success' => true,
'customer_id' => $customerId,
];
}
/**
* @return array{success: bool, token?: string, message?: string}
*/
public function generateLoginToken(User $user, string $ipAddress): array
{
if (! $user->hasResellerClubCustomer()) {
return ['success' => false, 'message' => 'ResellerClub customer is not set for this user.'];
}
try {
$response = Http::timeout(15)->get($this->apiUrl.'/customers/generate-login-token.json', [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'customer-id' => (string) $user->rc_customer_id,
'ip' => $ipAddress,
]);
$data = $response->json();
$rawBody = trim((string) $response->body());
if ($response->failed()) {
return [
'success' => false,
'message' => $this->extractErrorMessage(is_array($data) ? $data : [], $rawBody) ?: 'Could not create the ResellerClub login token.',
];
}
$token = is_array($data)
? (string) ($data['token'] ?? $data['loginid'] ?? $data['userLoginId'] ?? '')
: trim($rawBody, "\"'");
if ($token === '') {
return ['success' => false, 'message' => 'Could not create the ResellerClub login token.'];
}
return ['success' => true, 'token' => $token];
} catch (\Throwable $e) {
Log::error('ResellerClubCustomerService: generateLoginToken failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not create the ResellerClub login token.'];
}
}
public function generatePlatformPassword(): string
{
$upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
$lower = 'abcdefghijkmnopqrstuvwxyz';
$numbers = '23456789';
$special = '!@$#%_+?';
$pool = $upper.$lower.$numbers.$special;
$password = [
$upper[random_int(0, strlen($upper) - 1)],
$lower[random_int(0, strlen($lower) - 1)],
$numbers[random_int(0, strlen($numbers) - 1)],
$special[random_int(0, strlen($special) - 1)],
];
while (count($password) < 12) {
$password[] = $pool[random_int(0, strlen($pool) - 1)];
}
shuffle($password);
return implode('', $password);
}
/**
* @return array{
* success: bool,
* configured: bool,
* api_url: string,
* auth_userid_present: bool,
* current_customer_id: string,
* current_rc_email: string,
* linked: bool,
* steps: array<int, array{label: string, status: string, message: string}>
* }
*/
public function runDiagnostics(User $user, string $ipAddress): array
{
$steps = [];
if (! $this->isConfigured()) {
$steps[] = $this->diagnosticStep('Configuration', 'error', 'ResellerClub is not configured.');
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Configuration', 'success', 'ResellerClub credentials are configured.');
if ($user->hasResellerClubCustomer()) {
$steps[] = $this->diagnosticStep(
'Stored customer',
'success',
'Current user already has ResellerClub customer ID '.$user->rc_customer_id.'.'
);
} else {
$email = strtolower(trim((string) $user->email));
if ($email === '') {
$steps[] = $this->diagnosticStep('Customer lookup', 'error', 'User email is required to provision a ResellerClub customer.');
return $this->diagnosticSummary($user, $steps, false);
}
$lookup = $this->findCustomerByEmail($email);
if (! ($lookup['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Customer lookup',
'error',
$lookup['message'] ?? 'Could not look up the ResellerClub customer.'
);
return $this->diagnosticSummary($user, $steps, false);
}
if ($lookup['found'] ?? false) {
$customerId = (string) ($lookup['customer_id'] ?? '');
$steps[] = $this->diagnosticStep(
'Customer lookup',
'success',
'Existing ResellerClub customer found: '.$customerId.'.'
);
$attached = $this->attachCustomerToUser($user, $customerId, $email, false);
if (! ($attached['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Attach customer',
'error',
$attached['message'] ?? 'Could not attach the ResellerClub customer to this Ladill account.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Attach customer', 'success', 'Existing ResellerClub customer attached to this Ladill account.');
$user->refresh();
} else {
$steps[] = $this->diagnosticStep('Customer lookup', 'warning', 'No existing ResellerClub customer was found for this email.');
$created = $this->createCustomerForUser($user);
if (! ($created['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Create customer',
'error',
$created['message'] ?? 'Could not create the ResellerClub customer.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$customerId = (string) ($created['customer_id'] ?? '');
$steps[] = $this->diagnosticStep('Create customer', 'success', 'New ResellerClub customer created: '.$customerId.'.');
$attached = $this->attachCustomerToUser($user, $customerId, $email, false);
if (! ($attached['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Attach customer',
'error',
$attached['message'] ?? 'Could not attach the new ResellerClub customer to this Ladill account.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Attach customer', 'success', 'New ResellerClub customer attached to this Ladill account.');
$user->refresh();
}
}
$token = $this->generateLoginToken($user, $ipAddress);
if (! ($token['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Generate login token',
'error',
$token['message'] ?? 'Could not create the ResellerClub login token.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Generate login token', 'success', 'ResellerClub login token generated successfully.');
return $this->diagnosticSummary($user, $steps, true);
}
private function configuredApiUrl(): string
{
$value = trim((string) config('mailinfra.registrar_api_url'));
return $value !== '' ? $value : self::DEFAULT_API_URL;
}
private function looksLikeMissingCustomerMessage(string $message): bool
{
$value = strtolower(trim($message));
return str_contains($value, 'not found')
|| str_contains($value, 'does not exist')
|| str_contains($value, 'no customer')
|| str_contains($value, 'unable to find')
|| str_contains($value, 'entity not found')
|| str_contains($value, 'no entity found');
}
/**
* @param array<string, mixed> $data
*/
private function extractErrorMessage(array $data, string $fallbackBody = ''): string
{
foreach (['message', 'error', 'description', 'msg'] as $key) {
$value = $this->sanitizeErrorText((string) ($data[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return $this->sanitizeErrorText($fallbackBody);
}
private function extractCustomerIdFromBody(string $body): string
{
if ($body !== '' && preg_match('/^\d+$/', $body) === 1) {
return $body;
}
$xml = $this->parseXml($body);
if ($xml === null) {
return '';
}
$flattened = strtolower(preg_replace('/\s+/', ' ', strip_tags($body)) ?? '');
if (str_contains($flattened, 'error')) {
return '';
}
foreach (['customerid', 'customer-id', 'entityid', 'entity-id', 'id', 'description'] as $key) {
$value = trim((string) ($xml[$key] ?? ''));
if ($value !== '' && preg_match('/^\d+$/', $value) === 1) {
return $value;
}
}
if (preg_match('/\b(\d{3,})\b/', trim(strip_tags($body)), $matches) === 1) {
return $matches[1];
}
return '';
}
private function extractXmlErrorMessage(string $body): string
{
$xml = $this->parseXml($body);
if ($xml !== null) {
foreach (['message', 'error', 'description', 'msg', 'status'] as $key) {
$value = $this->sanitizeErrorText((string) ($xml[$key] ?? ''));
if ($value !== '' && ! preg_match('/^\d+$/', $value)) {
return $value;
}
}
}
return $this->sanitizeErrorText($body);
}
/**
* @return array<string, mixed>|null
*/
private function parseXml(string $body): ?array
{
$body = trim($body);
if ($body === '' || ! str_starts_with($body, '<')) {
return null;
}
$previous = libxml_use_internal_errors(true);
try {
$xml = simplexml_load_string($body, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET);
if (! $xml instanceof \SimpleXMLElement) {
return null;
}
$data = json_decode(json_encode($xml, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
return is_array($data) ? $data : null;
} catch (\Throwable) {
return null;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
private function sanitizeErrorText(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($this->looksLikeHtmlDocument($value)) {
return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.';
}
$plainText = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? '');
return $plainText;
}
private function looksLikeHtmlDocument(string $value): bool
{
$normalized = strtolower(ltrim($value));
return str_starts_with($normalized, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, '<title>')
|| str_contains($normalized, 'cloudflare');
}
/**
* @return array{label: string, status: string, message: string}
*/
private function diagnosticStep(string $label, string $status, string $message): array
{
return [
'label' => $label,
'status' => $status,
'message' => $message,
];
}
/**
* @param array<int, array{label: string, status: string, message: string}> $steps
* @return array{
* success: bool,
* configured: bool,
* api_url: string,
* auth_userid_present: bool,
* current_customer_id: string,
* current_rc_email: string,
* linked: bool,
* steps: array<int, array{label: string, status: string, message: string}>
* }
*/
private function diagnosticSummary(User $user, array $steps, bool $success): array
{
$user->refresh();
return [
'success' => $success,
'configured' => $this->isConfigured(),
'api_url' => $this->apiUrl,
'auth_userid_present' => $this->authUserId !== '',
'current_customer_id' => (string) ($user->rc_customer_id ?? ''),
'current_rc_email' => (string) ($user->rc_email ?? ''),
'linked' => $user->hasLinkedResellerClubAccount(),
'steps' => $steps,
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Ssl;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
class SslCertificateExpiryParser
{
public function parseOpenSslEndDate(string $output): ?CarbonInterface
{
$line = trim($output);
if ($line === '') {
return null;
}
if (str_contains($line, '=')) {
$line = trim((string) strstr($line, '='));
$line = ltrim($line, '=');
}
try {
return Carbon::parse($line);
} catch (\Throwable) {
return null;
}
}
}
+331
View File
@@ -0,0 +1,331 @@
<?php
namespace App\Services\Ssl;
use App\Models\Domain;
use App\Models\HostedSite;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Log;
class SslProvisioner
{
public function __construct(
private readonly SslCertificateExpiryParser $expiryParser = new SslCertificateExpiryParser(),
) {
}
public function isConfigured(): bool
{
$host = config('mailinfra.web_ssh_host');
$keyPath = config('mailinfra.web_ssh_key_path');
$email = config('mailinfra.ssl_email');
return ! empty($host) && ! empty($keyPath) && ! empty($email);
}
public function provisionCertificate(Domain $domain): bool
{
$domainName = rtrim($domain->host, '.');
$webroot = config('mailinfra.ssl_webroot');
$email = config('mailinfra.ssl_email');
$domains = escapeshellarg($domainName);
$wwwDomain = 'www.' . $domainName;
if ($this->domainResolvesToServer($wwwDomain)) {
$domains .= ' -d ' . escapeshellarg($wwwDomain);
} else {
Log::info('SslProvisioner: www subdomain does not resolve, skipping', ['domain' => $wwwDomain]);
}
$commands = [
sprintf(
'certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos -m %s',
escapeshellarg($webroot),
$domains,
escapeshellarg($email)
),
];
[$success, $output] = $this->sshExec($commands);
if (! $success) {
Log::error('SslProvisioner: certbot failed', [
'domain' => $domainName,
'output' => $output,
]);
return false;
}
Log::info('SslProvisioner: certificate issued', ['domain' => $domainName]);
return true;
}
/**
* @return array{success: bool, output: string, exit_code: int}
*/
public function renewCertificates(bool $force = false): array
{
$command = 'certbot renew --quiet --no-random-sleep-on-renew';
if ($force) {
$command .= ' --force-renewal';
}
[$success, $output, $exitCode] = $this->sshExecWithExitCode([$command]);
if (! $success) {
Log::warning('SslProvisioner: certbot renew completed with errors', [
'output' => $output,
'exit_code' => $exitCode,
]);
} else {
Log::info('SslProvisioner: certbot renew completed');
}
return [
'success' => $success,
'output' => $output,
'exit_code' => $exitCode,
];
}
public function readCertificateExpiry(string $domainName): ?CarbonInterface
{
$domainName = rtrim($domainName, '.');
$certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem";
$command = sprintf(
'openssl x509 -in %s -noout -enddate 2>/dev/null',
escapeshellarg($certPath)
);
[$success, $output] = $this->sshExec([$command]);
if (! $success) {
return null;
}
return $this->expiryParser->parseOpenSslEndDate($output);
}
private function domainResolvesToServer(string $hostname): bool
{
if (! function_exists('dns_get_record')) {
return false;
}
$records = @dns_get_record($hostname, DNS_A);
if (! is_array($records) || $records === []) {
$cname = @dns_get_record($hostname, DNS_CNAME);
return is_array($cname) && $cname !== [];
}
return true;
}
public function generateNginxConfig(Domain $domain): bool
{
$domainName = rtrim($domain->host, '.');
$confDir = config('mailinfra.ssl_nginx_conf_dir', '/etc/nginx/sites-enabled');
$confPath = "{$confDir}/{$domainName}.conf";
// Check if this domain has a hosted site - use its document root instead of default
$hostedSite = HostedSite::where('domain', $domainName)->first();
if ($hostedSite) {
$includeWww = $this->domainResolvesToServer('www.' . $domainName);
$config = $this->buildHostedSiteNginxServerBlock($hostedSite, $includeWww);
} else {
$webroot = config('mailinfra.ssl_webroot');
$includeWww = $this->domainResolvesToServer('www.' . $domainName);
$config = $this->buildNginxServerBlock($domainName, $webroot, $includeWww);
}
$escapedConfig = str_replace("'", "'\\''", $config);
$commands = [
"echo '{$escapedConfig}' > {$confPath}",
];
[$success, $output] = $this->sshExec($commands);
if (! $success) {
Log::error('SslProvisioner: Nginx config generation failed', [
'domain' => $domainName,
'output' => $output,
]);
return false;
}
Log::info('SslProvisioner: Nginx config written', ['domain' => $domainName, 'path' => $confPath]);
return true;
}
public function reloadNginx(): bool
{
[$success, $output] = $this->sshExec(['nginx -t && systemctl reload nginx']);
if (! $success) {
Log::error('SslProvisioner: Nginx reload failed', ['output' => $output]);
return false;
}
return true;
}
private function buildNginxServerBlock(string $domainName, string $webroot, bool $includeWww = true): string
{
$certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem";
$keyPath = "/etc/letsencrypt/live/{$domainName}/privkey.pem";
$serverNames = $includeWww ? "{$domainName} www.{$domainName}" : $domainName;
return <<<NGINX
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {$serverNames};
ssl_certificate {$certPath};
ssl_certificate_key {$keyPath};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root {$webroot};
index index.php;
charset utf-8;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
NGINX;
}
private function buildHostedSiteNginxServerBlock(HostedSite $site, bool $includeWww = true): string
{
$domainName = $site->domain;
$docRoot = $site->document_root;
$username = $site->account?->username ?? 'www-data';
$phpVersion = $site->php_version ?? '8.3';
$certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem";
$keyPath = "/etc/letsencrypt/live/{$domainName}/privkey.pem";
$serverNames = $includeWww ? "{$domainName} www.{$domainName}" : $domainName;
return <<<NGINX
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {$serverNames};
ssl_certificate {$certPath};
ssl_certificate_key {$keyPath};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root {$docRoot};
index index.php index.html index.htm;
charset utf-8;
access_log /home/{$username}/logs/{$domainName}-access.log;
error_log /home/{$username}/logs/{$domainName}-error.log;
location / {
try_files \$uri \$uri/ /index.php?\$args;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location ~ \.php\$ {
fastcgi_pass unix:/run/php/php{$phpVersion}-fpm.sock;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
location ~ /\.(?!well-known).* {
deny all;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg|eot)\$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Security: prevent PHP execution in upload directories
location ~* /(?:uploads|files|media)/.*\.php\$ {
deny all;
}
client_max_body_size 64M;
}
server {
listen 80;
listen [::]:80;
server_name {$serverNames};
return 301 https://\$host\$request_uri;
}
NGINX;
}
/**
* @return array{0: bool, 1: string}
*/
private function sshExec(array $commands): array
{
[$success, $output] = $this->sshExecWithExitCode($commands);
return [$success, $output];
}
/**
* @return array{0: bool, 1: string, 2: int}
*/
private function sshExecWithExitCode(array $commands): array
{
$sshHost = config('mailinfra.web_ssh_host');
$sshUser = config('mailinfra.web_ssh_user', 'root');
$sshKeyPath = config('mailinfra.web_ssh_key_path');
if (empty($sshHost) || empty($sshKeyPath)) {
return [false, 'SSH not configured', 1];
}
$sshBase = sprintf(
'ssh -i %s -o StrictHostKeyChecking=no %s@%s',
escapeshellarg($sshKeyPath),
escapeshellarg($sshUser),
escapeshellarg($sshHost)
);
$fullCommand = $sshBase . ' ' . escapeshellarg(implode(' && ', $commands));
$output = [];
$exitCode = 0;
exec($fullCommand . ' 2>&1', $output, $exitCode);
return [$exitCode === 0, implode("\n", $output), $exitCode];
}
}
+329
View File
@@ -0,0 +1,329 @@
<?php
namespace App\Services\Ssl;
use App\Models\CustomerHostingOrder;
use App\Models\Domain;
use App\Models\HostedSite;
use App\Models\HostingNode;
use App\Models\ServerSite;
use App\Models\ServerTask;
use App\Notifications\SslExpiringNotification;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class SslRenewalService
{
public function __construct(
private readonly SharedNodeProvider $nodeProvider,
private readonly SslProvisioner $sslProvisioner,
) {
}
/**
* @return array{nodes: int, central: bool, server_orders: int, sites_synced: int, domains_synced: int, fallbacks: int}
*/
public function renewAll(bool $force = false, bool $dryRun = false): array
{
$stats = [
'nodes' => 0,
'central' => false,
'server_orders' => 0,
'sites_synced' => 0,
'domains_synced' => 0,
'fallbacks' => 0,
];
if ($dryRun) {
$stats['nodes'] = $this->sharedNodesWithSsl()->count();
$stats['central'] = $this->sslProvisioner->isConfigured() && $this->centralDomainsNeedingRenewal()->isNotEmpty();
$stats['server_orders'] = $this->managedServerOrdersNeedingRenewal()->count();
return $stats;
}
foreach ($this->sharedNodesWithSsl() as $node) {
try {
$this->nodeProvider->renewCertificatesOnNode($node, $force);
$stats['nodes']++;
} catch (\Throwable $exception) {
Log::error('SslRenewalService: node renew failed', [
'node_id' => $node->id,
'error' => $exception->getMessage(),
]);
}
$stats['sites_synced'] += $this->syncHostedSiteExpiriesForNode($node);
}
if ($this->sslProvisioner->isConfigured()) {
$centralDomains = $this->centralDomainsNeedingRenewal();
if ($centralDomains->isNotEmpty()) {
$this->sslProvisioner->renewCertificates($force);
$this->sslProvisioner->reloadNginx();
$stats['central'] = true;
$stats['domains_synced'] += $this->syncCentralDomainExpiries($centralDomains);
}
}
$stats['server_orders'] = $this->queueManagedServerRenewals();
$stats['fallbacks'] = $this->reissueExpiringHostedSites();
$this->notifyExpiringDomains();
return $stats;
}
/**
* @return Collection<int, HostingNode>
*/
private function sharedNodesWithSsl(): Collection
{
$nodeIds = HostedSite::query()
->where('ssl_enabled', true)
->where('status', 'active')
->whereHas('account', function ($query): void {
$query->where('status', 'active')
->whereNotNull('hosting_node_id');
})
->with('account:id,hosting_node_id')
->get()
->pluck('account.hosting_node_id')
->filter()
->unique()
->values();
if ($nodeIds->isEmpty()) {
return collect();
}
return HostingNode::query()
->whereIn('id', $nodeIds)
->where('type', HostingNode::TYPE_SHARED)
->where('status', 'active')
->get();
}
public function syncHostedSiteExpiriesForNode(HostingNode $node): int
{
$sites = HostedSite::query()
->where('ssl_enabled', true)
->where('status', 'active')
->whereHas('account', function ($query) use ($node): void {
$query->where('status', 'active')
->where('hosting_node_id', $node->id);
})
->get();
$synced = 0;
foreach ($sites as $site) {
$expiresAt = $this->readHostedSiteExpiry($node, $site);
if ($expiresAt === null) {
continue;
}
$site->update(['ssl_expires_at' => $expiresAt]);
$synced++;
if ($site->domain_id) {
Domain::query()
->whereKey($site->domain_id)
->update([
'ssl_status' => 'active',
'ssl_expires_at' => $expiresAt,
]);
}
}
return $synced;
}
private function readHostedSiteExpiry(HostingNode $node, HostedSite $site): ?CarbonInterface
{
try {
return $this->nodeProvider->readCertificateExpiryOnNode($node, $site->domain);
} catch (\Throwable $exception) {
Log::warning('SslRenewalService: could not read certificate expiry', [
'site_id' => $site->id,
'domain' => $site->domain,
'error' => $exception->getMessage(),
]);
return null;
}
}
/**
* Domains with active SSL on the central web server (not served from a shared node cert).
*
* @return Collection<int, Domain>
*/
private function centralDomainsNeedingRenewal(): Collection
{
return Domain::query()
->where('ssl_status', 'active')
->whereDoesntHave('hostedSites', function ($query): void {
$query->where('ssl_enabled', true)
->where('status', 'active')
->whereHas('account', fn ($account) => $account->where('status', 'active'));
})
->get();
}
/**
* @param Collection<int, Domain> $domains
*/
private function syncCentralDomainExpiries(Collection $domains): int
{
$synced = 0;
foreach ($domains as $domain) {
$expiresAt = $this->sslProvisioner->readCertificateExpiry($domain->host);
if ($expiresAt === null) {
continue;
}
$domain->update(['ssl_expires_at' => $expiresAt]);
$synced++;
}
return $synced;
}
private function queueManagedServerRenewals(): int
{
$queued = 0;
foreach ($this->managedServerOrdersNeedingRenewal() as $order) {
$existing = ServerTask::query()
->where('customer_hosting_order_id', $order->id)
->where('type', ServerTask::TYPE_SSL_RENEW)
->whereIn('status', [
ServerTask::STATUS_QUEUED,
ServerTask::STATUS_DISPATCHED,
ServerTask::STATUS_RUNNING,
])
->exists();
if ($existing) {
continue;
}
ServerTask::create([
'customer_hosting_order_id' => $order->id,
'server_agent_id' => $order->serverAgent?->id,
'type' => ServerTask::TYPE_SSL_RENEW,
'status' => ServerTask::STATUS_QUEUED,
'payload' => [],
'progress' => 0,
'max_attempts' => 3,
'retry_backoff_seconds' => 60,
'stale_after_seconds' => 300,
'queued_at' => now(),
'available_at' => now(),
]);
$queued++;
}
return $queued;
}
/**
* @return Collection<int, CustomerHostingOrder>
*/
private function managedServerOrdersNeedingRenewal(): Collection
{
$orderIds = ServerSite::query()
->whereIn('ssl_status', ['issued', 'active'])
->pluck('customer_hosting_order_id')
->unique()
->filter();
if ($orderIds->isEmpty()) {
return collect();
}
return CustomerHostingOrder::query()
->whereIn('id', $orderIds)
->whereHas('serverAgent')
->with('serverAgent')
->get();
}
private function reissueExpiringHostedSites(): int
{
$thresholdDays = (int) config('ssl.fallback_reissue_before_days', 10);
$threshold = now()->addDays($thresholdDays);
$sites = HostedSite::query()
->where('ssl_enabled', true)
->where('status', 'active')
->where(function ($query) use ($threshold): void {
$query->whereNull('ssl_expires_at')
->orWhere('ssl_expires_at', '<=', $threshold);
})
->whereHas('account', fn ($query) => $query->where('status', 'active'))
->with(['account.node', 'account.user'])
->get();
$reissued = 0;
foreach ($sites as $site) {
if (! $site->account?->node) {
continue;
}
try {
$this->nodeProvider->requestLetsEncryptCertificate($site);
$expiresAt = $this->readHostedSiteExpiry($site->account->node, $site);
$site->update(array_filter([
'ssl_status' => 'issued',
'ssl_error' => null,
'ssl_expires_at' => $expiresAt,
'ssl_provisioned_at' => now(),
]));
$reissued++;
} catch (\Throwable $exception) {
Log::warning('SslRenewalService: fallback re-issue failed', [
'site_id' => $site->id,
'domain' => $site->domain,
'error' => $exception->getMessage(),
]);
}
}
return $reissued;
}
private function notifyExpiringDomains(): void
{
Domain::query()
->where('ssl_status', 'active')
->whereNotNull('ssl_expires_at')
->where('ssl_expires_at', '>', now())
->with('website.user')
->each(function (Domain $domain): void {
$user = $domain->website?->user;
if ($user === null) {
return;
}
$daysUntilExpiry = (int) now()->startOfDay()->diffInDays(
$domain->ssl_expires_at->copy()->startOfDay(),
false
);
$milestones = array_map('intval', (array) config('notifications.ssl_expiry_milestones', [14, 7, 3, 1]));
if (! in_array($daysUntilExpiry, $milestones, true)) {
return;
}
$user->notify(new SslExpiringNotification($domain, $daysUntilExpiry));
});
}
}