Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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() ?? []);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user