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,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AuditHostingPhpFpmPoolsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:audit-php-fpm-pools
|
||||
{--account= : Limit to one hosting account id}
|
||||
{--repair : Remove duplicate pools and keep the account PHP version}';
|
||||
|
||||
protected $description = 'Scan shared hosting accounts for duplicate PHP-FPM pool configs across PHP versions.';
|
||||
|
||||
public function handle(SharedNodeProvider $provider): int
|
||||
{
|
||||
$accounts = HostingAccount::query()
|
||||
->with('node')
|
||||
->where('type', HostingAccount::TYPE_SHARED)
|
||||
->when($this->option('account'), fn ($query, $accountId) => $query->whereKey($accountId))
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No hosting accounts matched.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$issues = 0;
|
||||
$repaired = 0;
|
||||
$failures = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if (! $account->node) {
|
||||
$this->warn("[{$account->id}] {$account->username}: no node assigned");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$versions = $provider->findPhpFpmPoolVersions($account);
|
||||
} catch (\Throwable $e) {
|
||||
$failures++;
|
||||
$this->error("[{$account->id}] {$account->username}: {$e->getMessage()}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($versions === []) {
|
||||
$this->line("[{$account->id}] {$account->username}: no pool configs found");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetVersion = $account->php_version ?? '8.4';
|
||||
$status = count($versions) === 1 && $versions[0] === $targetVersion
|
||||
? 'ok'
|
||||
: 'duplicate';
|
||||
|
||||
$this->line(sprintf(
|
||||
'[%d] %s: pools=%s target=%s (%s)',
|
||||
$account->id,
|
||||
$account->username,
|
||||
implode(', ', $versions),
|
||||
$targetVersion,
|
||||
$status,
|
||||
));
|
||||
|
||||
if ($status === 'ok') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$issues++;
|
||||
|
||||
if (! $this->option('repair')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($provider->changeAccountPhpVersion($account, $targetVersion)) {
|
||||
$repaired++;
|
||||
$this->info(" repaired {$account->username}");
|
||||
} else {
|
||||
$failures++;
|
||||
$this->error(" failed to repair {$account->username}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failures++;
|
||||
$this->error(" failed to repair {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Accounts scanned: {$accounts->count()}");
|
||||
$this->info("Issues found: {$issues}");
|
||||
|
||||
if ($this->option('repair')) {
|
||||
$this->info("Repaired: {$repaired}");
|
||||
$this->info("Failures: {$failures}");
|
||||
}
|
||||
|
||||
return $failures > 0 ? self::FAILURE : ($issues > 0 && ! $this->option('repair') ? self::FAILURE : self::SUCCESS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\NodeMonitoringService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckNodeHealthCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:check-node-health
|
||||
{--node= : Check a specific node by ID}
|
||||
{--all : Check all active nodes}';
|
||||
|
||||
protected $description = 'Run health checks on hosting nodes to collect resource metrics';
|
||||
|
||||
public function handle(NodeMonitoringService $monitoringService): int
|
||||
{
|
||||
$nodeId = $this->option('node');
|
||||
|
||||
if ($nodeId) {
|
||||
return $this->checkSingleNode($monitoringService, (int) $nodeId);
|
||||
}
|
||||
|
||||
return $this->checkAllNodes($monitoringService);
|
||||
}
|
||||
|
||||
private function checkSingleNode(NodeMonitoringService $monitoringService, int $nodeId): int
|
||||
{
|
||||
$node = HostingNode::find($nodeId);
|
||||
|
||||
if (!$node) {
|
||||
$this->error("Node with ID {$nodeId} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Checking node: {$node->name} ({$node->ip_address})...");
|
||||
|
||||
$result = $monitoringService->checkNode($node);
|
||||
|
||||
$this->displayNodeResult($node, $result);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function checkAllNodes(NodeMonitoringService $monitoringService): int
|
||||
{
|
||||
$nodes = HostingNode::whereIn('status', [
|
||||
HostingNode::STATUS_ACTIVE,
|
||||
HostingNode::STATUS_FULL,
|
||||
HostingNode::STATUS_MAINTENANCE,
|
||||
])->get();
|
||||
|
||||
if ($nodes->isEmpty()) {
|
||||
$this->warn('No active nodes found to check.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Checking {$nodes->count()} node(s)...");
|
||||
$this->newLine();
|
||||
|
||||
$results = [];
|
||||
$bar = $this->output->createProgressBar($nodes->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$results[$node->id] = $monitoringService->checkNode($node);
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->displaySummaryTable($nodes, $results);
|
||||
|
||||
$unhealthy = collect($results)->filter(fn($r) => ($r['health'] ?? '') !== NodeMonitoringService::HEALTH_OK);
|
||||
if ($unhealthy->isNotEmpty()) {
|
||||
$this->newLine();
|
||||
$this->warn("⚠️ {$unhealthy->count()} node(s) require attention.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function displayNodeResult(HostingNode $node, array $result): void
|
||||
{
|
||||
$health = $result['health'] ?? 'unknown';
|
||||
$healthIcon = match ($health) {
|
||||
NodeMonitoringService::HEALTH_OK => '✅',
|
||||
NodeMonitoringService::HEALTH_WARNING => '⚠️',
|
||||
NodeMonitoringService::HEALTH_CRITICAL => '🔴',
|
||||
NodeMonitoringService::HEALTH_UNREACHABLE => '❌',
|
||||
default => '❓',
|
||||
};
|
||||
|
||||
$this->newLine();
|
||||
$this->info("{$healthIcon} Health: " . strtoupper($health));
|
||||
$this->newLine();
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$this->error("Error: {$result['error']}");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['CPU Usage', ($result['cpu']['usage_percent'] ?? 'N/A') . '%'],
|
||||
['RAM Usage', ($result['memory']['usage_percent'] ?? 'N/A') . '%'],
|
||||
['Disk Usage (root)', ($result['disk']['root']['usage_percent'] ?? 'N/A') . '%'],
|
||||
['Load (1m)', $result['load']['load_1'] ?? 'N/A'],
|
||||
['Load per Core', $result['load']['load_per_core'] ?? 'N/A'],
|
||||
['Uptime', $result['uptime']['formatted'] ?? 'N/A'],
|
||||
['Processes', $result['processes']['total'] ?? 'N/A'],
|
||||
['Response Time', ($result['response_time_ms'] ?? 'N/A') . 'ms'],
|
||||
]
|
||||
);
|
||||
|
||||
if (!empty($result['services'])) {
|
||||
$this->newLine();
|
||||
$this->info('Services:');
|
||||
foreach ($result['services'] as $service => $running) {
|
||||
$icon = $running ? '✅' : '❌';
|
||||
$this->line(" {$icon} {$service}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function displaySummaryTable($nodes, array $results): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$result = $results[$node->id] ?? [];
|
||||
$health = $result['health'] ?? 'unknown';
|
||||
|
||||
$healthIcon = match ($health) {
|
||||
NodeMonitoringService::HEALTH_OK => '✅',
|
||||
NodeMonitoringService::HEALTH_WARNING => '⚠️',
|
||||
NodeMonitoringService::HEALTH_CRITICAL => '🔴',
|
||||
NodeMonitoringService::HEALTH_UNREACHABLE => '❌',
|
||||
default => '❓',
|
||||
};
|
||||
|
||||
$rows[] = [
|
||||
$node->name,
|
||||
$node->ip_address,
|
||||
"{$healthIcon} " . strtoupper($health),
|
||||
isset($result['cpu']['usage_percent']) ? $result['cpu']['usage_percent'] . '%' : 'N/A',
|
||||
isset($result['memory']['usage_percent']) ? $result['memory']['usage_percent'] . '%' : 'N/A',
|
||||
isset($result['disk']['root']['usage_percent']) ? $result['disk']['root']['usage_percent'] . '%' : 'N/A',
|
||||
$result['load']['load_1'] ?? 'N/A',
|
||||
"{$node->current_accounts}/{$node->max_accounts}",
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Node', 'IP', 'Health', 'CPU', 'RAM', 'Disk', 'Load', 'Accounts'],
|
||||
$rows
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class HardenHostingAccountIsolationCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:harden-account-isolation {--account= : Limit to one hosting account id} {--dry-run : Show affected accounts without applying changes}';
|
||||
|
||||
protected $description = 'Apply filesystem isolation permissions to shared hosting accounts.';
|
||||
|
||||
public function handle(SharedNodeProvider $provider): int
|
||||
{
|
||||
$accounts = HostingAccount::query()
|
||||
->with(['node', 'sites'])
|
||||
->where('type', HostingAccount::TYPE_SHARED)
|
||||
->when($this->option('account'), fn ($query, $accountId) => $query->whereKey($accountId))
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No hosting accounts matched.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$this->line(sprintf('[%d] %s (%s)', $account->id, $account->username, $account->primary_domain ?: 'no-domain'));
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('Dry run mode: no changes applied.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$failures = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
try {
|
||||
$provider->hardenAccountFilesystem($account);
|
||||
$this->info("Hardened {$account->username}");
|
||||
} catch (\Throwable $e) {
|
||||
$failures++;
|
||||
$this->error("Failed {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return $failures === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\HostingBillingInvoice;
|
||||
use App\Models\HostingNode;
|
||||
use App\Models\HostingOrder;
|
||||
use App\Models\HostingPlanChange;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Zero-loss import of hosting data exported from the platform (`hosting:export`).
|
||||
* Dry-run by default; pass --commit to write.
|
||||
*/
|
||||
class ImportHostingCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:import
|
||||
{file : Path to hosting-export.json from the platform}
|
||||
{--commit : Actually write changes}';
|
||||
|
||||
protected $description = 'Import hosting accounts and orders from the platform export';
|
||||
|
||||
/** @var array<int,int> platform user_id → local user id */
|
||||
private array $userMap = [];
|
||||
|
||||
/** @var array<int,int> platform account id → local account id */
|
||||
private array $accountMap = [];
|
||||
|
||||
/** @var array<int,int> platform node id → local node id */
|
||||
private array $nodeMap = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$commit = (bool) $this->option('commit');
|
||||
|
||||
if (! $commit) {
|
||||
$this->warn('DRY RUN — no changes will be written. Re-run with --commit to apply.');
|
||||
}
|
||||
|
||||
$path = $this->argument('file');
|
||||
if (! is_file($path)) {
|
||||
$this->error("File not found: {$path}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($path), true);
|
||||
if (! is_array($payload)) {
|
||||
$this->error('Invalid JSON export file.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$apply = function (callable $callback) use ($commit): void {
|
||||
if ($commit) {
|
||||
DB::transaction($callback);
|
||||
} else {
|
||||
$callback();
|
||||
}
|
||||
};
|
||||
|
||||
$apply(function () use ($payload, $commit): void {
|
||||
foreach ($payload['hosting_nodes'] ?? [] as $row) {
|
||||
$this->importNode($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_accounts'] ?? [] as $row) {
|
||||
$this->importAccount($row, $commit);
|
||||
}
|
||||
foreach ($payload['customer_hosting_orders'] ?? [] as $row) {
|
||||
$this->importCustomerOrder($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_orders'] ?? [] as $row) {
|
||||
$this->importLegacyOrder($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_account_members'] ?? [] as $row) {
|
||||
$this->importMember($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_billing_invoices'] ?? [] as $row) {
|
||||
$this->importInvoice($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_plan_changes'] ?? [] as $row) {
|
||||
$this->importPlanChange($row, $commit);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(($commit ? 'Imported' : 'Would import').' '.count($this->accountMap).' hosting account(s).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importNode(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
unset($row['id'], $row['primary_hosting_node_id']);
|
||||
|
||||
if ($commit) {
|
||||
$node = HostingNode::updateOrCreate(['hostname' => $row['hostname']], $row);
|
||||
$this->nodeMap[$platformId] = $node->id;
|
||||
} else {
|
||||
$this->nodeMap[$platformId] = $platformId;
|
||||
$this->line(" node {$platformId} → {$row['hostname']}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importAccount(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser(
|
||||
(string) ($row['owner_public_id'] ?? ''),
|
||||
(string) ($row['owner_email'] ?? ''),
|
||||
);
|
||||
if (! $owner) {
|
||||
$this->warn("Skipping account #{$platformId}: no owner public_id");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['platform_id'] = $platformId;
|
||||
$this->mapProductId($row);
|
||||
|
||||
$platformNodeId = (int) ($row['hosting_node_id'] ?? 0);
|
||||
if ($platformNodeId && isset($this->nodeMap[$platformNodeId])) {
|
||||
$row['hosting_node_id'] = $this->nodeMap[$platformNodeId];
|
||||
}
|
||||
|
||||
if ($commit) {
|
||||
$account = HostingAccount::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
$this->accountMap[$platformId] = $account->id;
|
||||
} else {
|
||||
$this->accountMap[$platformId] = $platformId;
|
||||
$this->line(" account {$platformId} → user {$owner->email}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importCustomerOrder(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
if (! $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['platform_id'] = $platformId;
|
||||
$this->mapProductId($row);
|
||||
|
||||
if ($commit) {
|
||||
CustomerHostingOrder::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importLegacyOrder(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
if (! $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['platform_id'] = $platformId;
|
||||
|
||||
if ($commit) {
|
||||
HostingOrder::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importMember(array $row, bool $commit): void
|
||||
{
|
||||
$member = $this->resolveUser((string) ($row['member_public_id'] ?? ''));
|
||||
$platformAccountId = (int) ($row['hosting_account_id'] ?? 0);
|
||||
$localAccountId = $this->accountMap[$platformAccountId] ?? null;
|
||||
|
||||
if (! $member || ! $localAccountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['member_public_id']);
|
||||
$row['user_id'] = $member->id;
|
||||
$row['hosting_account_id'] = $localAccountId;
|
||||
|
||||
if (! empty($row['invited_by_user_id']) && ! User::query()->whereKey($row['invited_by_user_id'])->exists()) {
|
||||
$row['invited_by_user_id'] = null;
|
||||
}
|
||||
|
||||
if ($commit) {
|
||||
HostingAccountMember::updateOrCreate(
|
||||
['hosting_account_id' => $localAccountId, 'user_id' => $member->id],
|
||||
$row
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importInvoice(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
$platformAccountId = (int) ($row['hosting_account_id'] ?? 0);
|
||||
$localAccountId = $this->accountMap[$platformAccountId] ?? null;
|
||||
|
||||
if (! $owner || ! $localAccountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['hosting_account_id'] = $localAccountId;
|
||||
$row['platform_id'] = $platformId;
|
||||
|
||||
if ($commit) {
|
||||
HostingBillingInvoice::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importPlanChange(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
$platformAccountId = (int) ($row['hosting_account_id'] ?? 0);
|
||||
$localAccountId = $this->accountMap[$platformAccountId] ?? null;
|
||||
|
||||
if (! $owner || ! $localAccountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['hosting_account_id'] = $localAccountId;
|
||||
$row['platform_id'] = $platformId;
|
||||
|
||||
if ($commit) {
|
||||
HostingPlanChange::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveUser(string $publicId, string $email = '', string $name = ''): ?User
|
||||
{
|
||||
if ($publicId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->where('public_id', $publicId)->first();
|
||||
if ($user || $email === '') {
|
||||
return $user;
|
||||
}
|
||||
|
||||
return User::query()->create([
|
||||
'public_id' => $publicId,
|
||||
'email' => $email,
|
||||
'name' => $name !== '' ? $name : $email,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function mapProductId(array &$row): void
|
||||
{
|
||||
$slug = (string) ($row['hosting_product_slug'] ?? '');
|
||||
unset($row['hosting_product_slug']);
|
||||
|
||||
if ($slug === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$product = HostingProduct::query()->where('slug', $slug)->first();
|
||||
if ($product) {
|
||||
$row['hosting_product_id'] = $product->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Hosting\HostingExpiryAlertService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class NotifyExpiringHostingCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:notify-expiring';
|
||||
|
||||
protected $description = 'Send hosting plan expiry reminder emails';
|
||||
|
||||
public function handle(HostingExpiryAlertService $alerts): int
|
||||
{
|
||||
$sent = $alerts->checkAll();
|
||||
|
||||
$this->info($sent > 0
|
||||
? "Sent {$sent} hosting expiry reminder(s)."
|
||||
: 'No hosting expiry reminders to send.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Notifications\HostingSuspendedNotification;
|
||||
use App\Services\Hosting\HostingProvisioningService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessExpiredHostingAccountsCommand extends Command
|
||||
{
|
||||
private const EXPIRY_SUSPENSION_GRACE_DAYS = 7;
|
||||
|
||||
protected $signature = 'hosting:process-expired-accounts';
|
||||
|
||||
protected $description = 'Suspend expired hosting accounts (serve expired page) and terminate those past the grace period';
|
||||
|
||||
public function handle(SharedNodeProvider $nodeProvider, HostingProvisioningService $provisioning): int
|
||||
{
|
||||
$this->suspendNewlyExpired($nodeProvider);
|
||||
$this->terminatePastGracePeriod($provisioning);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function suspendNewlyExpired(SharedNodeProvider $nodeProvider): void
|
||||
{
|
||||
$accounts = HostingAccount::query()
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now()->subDays(self::EXPIRY_SUSPENSION_GRACE_DAYS))
|
||||
->with(['node', 'sites', 'user'])
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No newly expired accounts to suspend.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("Found {$accounts->count()} expired account(s) to suspend.");
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
try {
|
||||
$nodeProvider->serveExpiredPage($account);
|
||||
|
||||
$reason = 'Hosting account expired on '.$account->expires_at->format('M d, Y').'. Renew to restore service.';
|
||||
|
||||
$account->update([
|
||||
'status' => HostingAccount::STATUS_SUSPENDED,
|
||||
'suspension_reason' => $reason,
|
||||
'suspended_at' => now(),
|
||||
'metadata' => array_merge((array) ($account->metadata ?? []), [
|
||||
'suspension_origin' => 'expiry',
|
||||
'expired_at' => $account->expires_at->toIso8601String(),
|
||||
'expiry_suspension_grace_days' => self::EXPIRY_SUSPENSION_GRACE_DAYS,
|
||||
'grace_period_ends_at' => $account->gracePeriodEndsAt()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
|
||||
if ($account->user) {
|
||||
$account->user->notify(new HostingSuspendedNotification($account->fresh(), $reason));
|
||||
}
|
||||
|
||||
Log::info('ProcessExpiredHostingAccounts: Suspended expired account', [
|
||||
'account_id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'expired_at' => $account->expires_at,
|
||||
]);
|
||||
|
||||
$this->line(" Suspended: {$account->username} (expired {$account->expires_at->format('M d, Y')})");
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ProcessExpiredHostingAccounts: Failed to suspend expired account', [
|
||||
'account_id' => $account->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->error(" Failed to suspend {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function terminatePastGracePeriod(HostingProvisioningService $provisioning): void
|
||||
{
|
||||
$graceDeadline = now()->subMonths(HostingAccount::GRACE_PERIOD_MONTHS);
|
||||
|
||||
$accounts = HostingAccount::query()
|
||||
->where('status', HostingAccount::STATUS_SUSPENDED)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', $graceDeadline)
|
||||
->whereJsonContains('metadata->suspension_origin', 'expiry')
|
||||
->with(['node', 'sites'])
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No accounts past grace period to terminate.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("Found {$accounts->count()} account(s) past grace period to terminate.");
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
try {
|
||||
$provisioning->terminateAccount($account);
|
||||
|
||||
Log::info('ProcessExpiredHostingAccounts: Terminated account past grace period', [
|
||||
'account_id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'expired_at' => $account->expires_at,
|
||||
]);
|
||||
|
||||
$this->line(" Terminated: {$account->username} (expired {$account->expires_at->format('M d, Y')})");
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ProcessExpiredHostingAccounts: Failed to terminate account', [
|
||||
'account_id' => $account->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->error(" Failed to terminate {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ProvisionHostingAccountJob;
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProvisionPendingHostingAccountsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:provision-pending {--dry-run : Show accounts without dispatching jobs}';
|
||||
|
||||
protected $description = 'Dispatch provisioning jobs for hosting accounts stuck in pending status';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$pendingAccounts = HostingAccount::query()
|
||||
->whereIn('status', ['pending', 'failed'])
|
||||
->whereNotNull('hosting_node_id')
|
||||
->whereNull('provisioned_at')
|
||||
->with(['user:id,name,email', 'product:id,name', 'node:id,name'])
|
||||
->get();
|
||||
|
||||
if ($pendingAccounts->isEmpty()) {
|
||||
$this->info('No pending hosting accounts found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$pendingAccounts->count()} pending account(s):");
|
||||
$this->newLine();
|
||||
|
||||
foreach ($pendingAccounts as $account) {
|
||||
$this->line(" • [{$account->id}] {$account->username} - {$account->user?->name} ({$account->user?->email})");
|
||||
$this->line(" Product: {$account->product?->name}, Node: {$account->node?->name}, Status: {$account->status}");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('Dry run mode - no jobs dispatched.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->confirm('Dispatch provisioning jobs for these accounts?')) {
|
||||
$this->info('Aborted.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dispatched = 0;
|
||||
foreach ($pendingAccounts as $account) {
|
||||
// Reset status to pending if it was failed
|
||||
if ($account->status === 'failed') {
|
||||
$account->update(['status' => 'pending', 'notes' => null]);
|
||||
}
|
||||
|
||||
ProvisionHostingAccountJob::dispatch($account->id);
|
||||
$dispatched++;
|
||||
$this->info("Dispatched job for account #{$account->id} ({$account->username})");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Dispatched {$dispatched} provisioning job(s). Check queue worker for progress.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\NodeCapacityService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RecalculateHostingNodeCapacityCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:recalculate-node-capacity {--node=} {--shared-only}';
|
||||
|
||||
protected $description = 'Recalculate logical capacity, allocated disk, and load for hosting nodes.';
|
||||
|
||||
public function handle(NodeCapacityService $capacityService): int
|
||||
{
|
||||
$nodes = HostingNode::query()
|
||||
->when($this->option('node'), fn ($query, $nodeId) => $query->where('id', $nodeId))
|
||||
->when($this->option('shared-only'), fn ($query) => $query->where('type', HostingNode::TYPE_SHARED))
|
||||
->get();
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$node = $capacityService->refreshNodeResourceTotals($node);
|
||||
$capacityService->checkNode($node);
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s: accounts=%d allocated=%dGB used=%dGB logical=%.2fGB load=%.2f%%',
|
||||
$node->name,
|
||||
$node->current_accounts,
|
||||
$node->allocated_disk_gb,
|
||||
$node->used_disk_gb,
|
||||
$node->logicalCapacityGb(),
|
||||
(float) $node->current_load_percent
|
||||
));
|
||||
}
|
||||
|
||||
$this->info("Recalculated capacity for {$nodes->count()} node(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Ssl\SslRenewalService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RenewSslCertificatesCommand extends Command
|
||||
{
|
||||
protected $signature = 'ssl:renew
|
||||
{--force : Force renewal even if certificates are not due}
|
||||
{--dry-run : Report how many targets would be processed without making changes}';
|
||||
|
||||
protected $description = 'Renew Let\'s Encrypt SSL certificates for all Ladill-hosted domains';
|
||||
|
||||
public function handle(SslRenewalService $renewalService): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$stats = $renewalService->renewAll($force, true);
|
||||
$this->info('SSL renewal dry run:');
|
||||
$this->line(" Shared hosting nodes with SSL: {$stats['nodes']}");
|
||||
$this->line(' Central web server renewal: ' . ($stats['central'] ? 'yes' : 'no'));
|
||||
$this->line(" Managed VPS orders to queue: {$stats['server_orders']}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Renewing SSL certificates...');
|
||||
$stats = $renewalService->renewAll($force, false);
|
||||
|
||||
$this->info("Renewed on {$stats['nodes']} shared hosting node(s).");
|
||||
if ($stats['central']) {
|
||||
$this->info('Central web server certificates renewed.');
|
||||
}
|
||||
if ($stats['server_orders'] > 0) {
|
||||
$this->info("Queued ssl.renew for {$stats['server_orders']} managed server(s).");
|
||||
}
|
||||
$this->line("Synced expiry for {$stats['sites_synced']} hosted site(s) and {$stats['domains_synced']} domain record(s).");
|
||||
if ($stats['fallbacks'] > 0) {
|
||||
$this->warn("Re-issued {$stats['fallbacks']} certificate(s) via fallback provisioning.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostedSite;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RepairSslCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:repair-ssl
|
||||
{domain? : Specific domain to repair (e.g., dailya.cc)}
|
||||
{--all : Repair SSL for all sites with ssl_enabled=true}';
|
||||
|
||||
protected $description = 'Repair SSL configuration for hosted sites by regenerating nginx config with SSL';
|
||||
|
||||
public function handle(SharedNodeProvider $nodeProvider): int
|
||||
{
|
||||
$domain = $this->argument('domain');
|
||||
$all = $this->option('all');
|
||||
|
||||
if (!$domain && !$all) {
|
||||
$this->error('Please specify a domain or use --all flag');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$query = HostedSite::query()->where('ssl_enabled', true);
|
||||
|
||||
if ($domain) {
|
||||
$query->where('domain', $domain);
|
||||
}
|
||||
|
||||
$sites = $query->get();
|
||||
|
||||
if ($sites->isEmpty()) {
|
||||
$this->warn('No sites found matching criteria');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$sites->count()} site(s) to repair");
|
||||
|
||||
foreach ($sites as $site) {
|
||||
$this->line("Processing {$site->domain}...");
|
||||
|
||||
try {
|
||||
// Re-request SSL certificate (certbot will reuse existing cert if valid)
|
||||
$nodeProvider->requestLetsEncryptCertificate($site);
|
||||
$this->info(" ✓ SSL repaired for {$site->domain}");
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ✗ Failed for {$site->domain}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Done!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReprovisionHostingAccount extends Command
|
||||
{
|
||||
protected $signature = 'hosting:reprovision {account_id}';
|
||||
protected $description = 'Re-provision a hosting account on its node (creates user, directories, etc.)';
|
||||
|
||||
public function handle(SharedNodeProvider $provider): int
|
||||
{
|
||||
$account = HostingAccount::with('node')->find($this->argument('account_id'));
|
||||
|
||||
if (!$account) {
|
||||
$this->error('Account not found');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$account->node) {
|
||||
$this->error('Account has no hosting node assigned');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Re-provisioning account: {$account->username} on node {$account->node->hostname}");
|
||||
|
||||
try {
|
||||
$result = $provider->createAccount($account);
|
||||
|
||||
$account->update([
|
||||
'status' => HostingAccount::STATUS_ACTIVE,
|
||||
'home_directory' => $result['home_directory'],
|
||||
'provisioned_at' => now(),
|
||||
]);
|
||||
|
||||
$this->info("Account provisioned successfully!");
|
||||
$this->info("Username: {$result['username']}");
|
||||
$this->info("Password: {$result['password']}");
|
||||
$this->info("Home Directory: {$result['home_directory']}");
|
||||
|
||||
// Also provision any existing sites
|
||||
foreach ($account->sites as $site) {
|
||||
$this->info("Provisioning site: {$site->domain}");
|
||||
try {
|
||||
$provider->addSite($site);
|
||||
$site->update(['status' => 'active']);
|
||||
$this->info(" - Site provisioned");
|
||||
} catch (\Exception $e) {
|
||||
$this->warn(" - Failed: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Failed to provision: {$e->getMessage()}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Jobs\ProvisionHostingOrderJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RetryPendingHostingFulfillmentCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:retry-pending-fulfillment {--limit=50 : Maximum orders to process}';
|
||||
|
||||
protected $description = 'Re-queue provisioning for hosting orders held after an upstream failure (e.g. Contabo balance)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->where('status', CustomerHostingOrder::STATUS_PENDING_APPROVAL)
|
||||
->whereNotNull('meta->provisioning_hold')
|
||||
->with('product')
|
||||
->latest('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$queued = 0;
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$order->update([
|
||||
'status' => CustomerHostingOrder::STATUS_APPROVED,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
ProvisionHostingOrderJob::dispatch($order->fresh());
|
||||
$queued++;
|
||||
$this->line("Queued provisioning for order #{$order->id} ({$order->domain_name})");
|
||||
}
|
||||
|
||||
$this->info("Queued {$queued} order(s) for reprovisioning.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\BrowserTerminalSessionManager;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use App\Services\Hosting\Terminal\LocalPtyShellSession;
|
||||
use App\Services\Hosting\Terminal\RemoteSshShellSession;
|
||||
use App\Services\Hosting\Terminal\ShellSession;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RunHostingTerminalWorkerCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:terminal-worker {session}';
|
||||
|
||||
protected $description = 'Run a live browser terminal worker for a hosting account session.';
|
||||
|
||||
public function handle(
|
||||
BrowserTerminalSessionManager $sessionManager,
|
||||
SharedNodeProvider $provider,
|
||||
): int {
|
||||
$sessionId = (string) $this->argument('session');
|
||||
$shell = null;
|
||||
$status = 'closed';
|
||||
$error = null;
|
||||
$eventOffset = 0;
|
||||
|
||||
try {
|
||||
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
||||
$account = HostingAccount::query()
|
||||
->with('node')
|
||||
->find($metadata['account_id'] ?? null);
|
||||
|
||||
if (! $account || ! $account->node) {
|
||||
throw new \RuntimeException('Hosting account or node was not found for this terminal session.');
|
||||
}
|
||||
|
||||
$shell = $this->makeShell($account, $metadata, $provider);
|
||||
$shell->boot();
|
||||
|
||||
$metadata['status'] = 'running';
|
||||
$metadata['error'] = null;
|
||||
$sessionManager->writeWorkerMetadata($sessionId, $metadata);
|
||||
$status = 'closed';
|
||||
|
||||
while (true) {
|
||||
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
||||
|
||||
if ($sessionManager->isSessionIdle($metadata)) {
|
||||
$sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal disconnected after inactivity]\r\n");
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($sessionManager->readWorkerEvents($sessionId, $eventOffset) as $event) {
|
||||
$type = (string) ($event['type'] ?? '');
|
||||
|
||||
if ($type === 'input') {
|
||||
$shell->write((string) ($event['data'] ?? ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'resize') {
|
||||
$shell->resize((int) ($event['cols'] ?? 120), (int) ($event['rows'] ?? 30));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'close') {
|
||||
$sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal closed]\r\n");
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
|
||||
$output = $shell->read();
|
||||
if ($output !== '') {
|
||||
$sessionManager->appendWorkerOutput($sessionId, $output);
|
||||
}
|
||||
|
||||
if (! $shell->isAlive()) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(50000);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$status = 'failed';
|
||||
$error = $e->getMessage();
|
||||
Log::error('Hosting terminal worker failed.', [
|
||||
'session_id' => $sessionId,
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
$sessionManager->appendWorkerOutput(
|
||||
$sessionId,
|
||||
sprintf(
|
||||
"\r\n[Terminal error] %s (%s:%d)\r\n",
|
||||
$error,
|
||||
basename($e->getFile()),
|
||||
$e->getLine()
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
if ($shell instanceof ShellSession) {
|
||||
$shell->close();
|
||||
}
|
||||
|
||||
try {
|
||||
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
||||
$metadata['status'] = $status;
|
||||
$metadata['error'] = $error;
|
||||
$metadata['exit_code'] = $status === 'failed' ? 1 : 0;
|
||||
$metadata['closed_at'] = now()->toIso8601String();
|
||||
$sessionManager->writeWorkerMetadata($sessionId, $metadata);
|
||||
} catch (\Throwable) {
|
||||
// Ignore metadata write failures during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
return $status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function makeShell(HostingAccount $account, array $metadata, SharedNodeProvider $provider): ShellSession
|
||||
{
|
||||
$relativePath = $this->normalizeRelativePath((string) ($metadata['relative_path'] ?? '/public_html'));
|
||||
$cols = max(40, min((int) ($metadata['cols'] ?? 120), 240));
|
||||
$rows = max(10, min((int) ($metadata['rows'] ?? 30), 80));
|
||||
$launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows);
|
||||
|
||||
if ($this->shouldUseLocalExecution($account->node)) {
|
||||
return new LocalPtyShellSession($this->buildLocalLaunchCommand($account, $launchCommand));
|
||||
}
|
||||
|
||||
$ssh = $provider->connect($account->node);
|
||||
|
||||
if (! $ssh) {
|
||||
throw new \RuntimeException('Unable to establish an SSH connection for the browser terminal.');
|
||||
}
|
||||
|
||||
$remoteCommand = "runuser -u {$account->username} -- bash -lc ".escapeshellarg($launchCommand);
|
||||
|
||||
return new RemoteSshShellSession($ssh, $remoteCommand, $cols, $rows);
|
||||
}
|
||||
|
||||
private function buildUserShellBootstrap(HostingAccount $account, string $relativePath, int $cols, int $rows): string
|
||||
{
|
||||
$homeDirectory = "/home/{$account->username}";
|
||||
$workingDirectory = $relativePath === '/'
|
||||
? $homeDirectory
|
||||
: $homeDirectory.$relativePath;
|
||||
|
||||
return implode('; ', [
|
||||
'export HOME='.escapeshellarg($homeDirectory),
|
||||
'export USER='.escapeshellarg($account->username),
|
||||
'export LOGNAME='.escapeshellarg($account->username),
|
||||
'export SHELL=/bin/bash',
|
||||
'export TERM=xterm-256color',
|
||||
'export COLORTERM=truecolor',
|
||||
'export COLUMNS='.(int) $cols,
|
||||
'export LINES='.(int) $rows,
|
||||
'export PS1='.escapeshellarg($account->username.'@ladill:\w\$ '),
|
||||
'cd '.escapeshellarg($workingDirectory).' 2>/dev/null || cd '.escapeshellarg($homeDirectory),
|
||||
'stty rows '.(int) $rows.' cols '.(int) $cols.' echo 2>/dev/null || true',
|
||||
'exec /bin/bash --noprofile --norc -i',
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildLocalLaunchCommand(HostingAccount $account, string $launchCommand): string
|
||||
{
|
||||
// For local development, fall back to running as current user if user switching isn't available
|
||||
$devFallback = app()->environment('local')
|
||||
? 'exec bash -lc "$LAUNCH_CMD";'
|
||||
: 'echo '.escapeshellarg('Local hosting commands require passwordless sudo, runuser access, or running the app as the hosting account user.').'; exit 1;';
|
||||
|
||||
return implode(' ', [
|
||||
'TARGET_USER='.escapeshellarg($account->username).';',
|
||||
'LAUNCH_CMD='.escapeshellarg($launchCommand).';',
|
||||
'if [ "$(id -un)" = "$TARGET_USER" ]; then exec bash -lc "$LAUNCH_CMD"; fi;',
|
||||
'if command -v sudo >/dev/null 2>&1 && sudo -n -u "$TARGET_USER" -- true >/dev/null 2>&1; then exec sudo -n -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;',
|
||||
'if command -v runuser >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then exec runuser -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;',
|
||||
$devFallback,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeRelativePath(string $path): string
|
||||
{
|
||||
$path = '/'.ltrim(trim($path), '/');
|
||||
$path = preg_replace('#/+#', '/', $path) ?: '/public_html';
|
||||
|
||||
$segments = [];
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($segment === '..') {
|
||||
array_pop($segments);
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = $segment;
|
||||
}
|
||||
|
||||
$normalized = '/'.implode('/', $segments);
|
||||
|
||||
return $normalized === '/' ? '/' : $normalized;
|
||||
}
|
||||
|
||||
private function shouldUseLocalExecution(HostingNode $node): bool
|
||||
{
|
||||
return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost')
|
||||
&& blank($node->ssh_private_key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingNode;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SetupNodeSshKey extends Command
|
||||
{
|
||||
protected $signature = 'hosting:setup-node-ssh {node_id} {--key-path= : Path to existing private key} {--generate : Generate a new key pair}';
|
||||
|
||||
protected $description = 'Configure SSH key authentication for a hosting node (required for all node operations)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$node = HostingNode::find($this->argument('node_id'));
|
||||
|
||||
if (!$node) {
|
||||
$this->error("Hosting node #{$this->argument('node_id')} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Node: {$node->name} ({$node->ip_address})");
|
||||
|
||||
if ($this->option('generate')) {
|
||||
return $this->generateAndInstallKey($node);
|
||||
}
|
||||
|
||||
if ($this->option('key-path')) {
|
||||
return $this->installExistingKey($node, $this->option('key-path'));
|
||||
}
|
||||
|
||||
// Try common key locations
|
||||
$commonPaths = [
|
||||
'/root/.ssh/id_ed25519',
|
||||
'/root/.ssh/id_rsa',
|
||||
getenv('HOME') . '/.ssh/id_ed25519',
|
||||
getenv('HOME') . '/.ssh/id_rsa',
|
||||
];
|
||||
|
||||
foreach ($commonPaths as $path) {
|
||||
if (file_exists($path) && is_readable($path)) {
|
||||
$this->info("Found existing key at: {$path}");
|
||||
if ($this->confirm("Use this key?", true)) {
|
||||
return $this->installExistingKey($node, $path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->warn("No SSH key found. Use --generate to create one, or --key-path to specify an existing key.");
|
||||
$this->line("");
|
||||
$this->line("Examples:");
|
||||
$this->line(" php artisan hosting:setup-node-ssh {$node->id} --generate");
|
||||
$this->line(" php artisan hosting:setup-node-ssh {$node->id} --key-path=/root/.ssh/id_rsa");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function generateAndInstallKey(HostingNode $node): int
|
||||
{
|
||||
$keyDir = storage_path('app/ssh-keys');
|
||||
if (!is_dir($keyDir)) {
|
||||
mkdir($keyDir, 0700, true);
|
||||
}
|
||||
|
||||
$keyPath = "{$keyDir}/node_{$node->id}_ed25519";
|
||||
|
||||
if (file_exists($keyPath)) {
|
||||
if (!$this->confirm("Key already exists at {$keyPath}. Overwrite?", false)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
unlink($keyPath);
|
||||
if (file_exists("{$keyPath}.pub")) {
|
||||
unlink("{$keyPath}.pub");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Ed25519 key pair
|
||||
$this->info("Generating Ed25519 key pair...");
|
||||
$result = shell_exec("ssh-keygen -t ed25519 -f " . escapeshellarg($keyPath) . " -N '' -C 'ladill-node-{$node->id}' 2>&1");
|
||||
|
||||
if (!file_exists($keyPath) || !file_exists("{$keyPath}.pub")) {
|
||||
$this->error("Failed to generate key pair: {$result}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
chmod($keyPath, 0600);
|
||||
|
||||
$privateKey = file_get_contents($keyPath);
|
||||
$publicKey = trim(file_get_contents("{$keyPath}.pub"));
|
||||
|
||||
// Store private key in the database
|
||||
$node->update(['ssh_private_key' => $privateKey]);
|
||||
$this->info("Private key stored in database for node #{$node->id}.");
|
||||
|
||||
// Show instructions for installing the public key
|
||||
$this->line("");
|
||||
$this->warn("=== IMPORTANT: Install the public key on the node ===");
|
||||
$this->line("");
|
||||
|
||||
$isLocal = $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost' || $node->provider === 'local';
|
||||
|
||||
if ($isLocal) {
|
||||
$this->info("This is a local node. Installing public key automatically...");
|
||||
$authorizedKeysFile = '/root/.ssh/authorized_keys';
|
||||
|
||||
if (!is_dir('/root/.ssh')) {
|
||||
mkdir('/root/.ssh', 0700, true);
|
||||
}
|
||||
|
||||
// Check if key already installed
|
||||
$existingKeys = file_exists($authorizedKeysFile) ? file_get_contents($authorizedKeysFile) : '';
|
||||
if (str_contains($existingKeys, $publicKey)) {
|
||||
$this->info("Public key already installed in {$authorizedKeysFile}");
|
||||
} else {
|
||||
file_put_contents($authorizedKeysFile, $publicKey . "\n", FILE_APPEND);
|
||||
chmod($authorizedKeysFile, 0600);
|
||||
$this->info("Public key installed in {$authorizedKeysFile}");
|
||||
}
|
||||
} else {
|
||||
$this->line("Run this on the node ({$node->ip_address}):");
|
||||
$this->line("");
|
||||
$this->line(" mkdir -p /root/.ssh && chmod 700 /root/.ssh");
|
||||
$this->line(" echo '{$publicKey}' >> /root/.ssh/authorized_keys");
|
||||
$this->line(" chmod 600 /root/.ssh/authorized_keys");
|
||||
$this->line("");
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
$this->info("Testing SSH connection...");
|
||||
return $this->testConnection($node);
|
||||
}
|
||||
|
||||
private function installExistingKey(HostingNode $node, string $keyPath): int
|
||||
{
|
||||
if (!file_exists($keyPath)) {
|
||||
$this->error("Key file not found: {$keyPath}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privateKey = file_get_contents($keyPath);
|
||||
|
||||
if (!str_contains($privateKey, '-----BEGIN') && !str_contains($privateKey, 'PRIVATE KEY')) {
|
||||
$this->error("File does not appear to be a valid SSH private key.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$node->update(['ssh_private_key' => $privateKey]);
|
||||
$this->info("Private key stored in database for node #{$node->id}.");
|
||||
|
||||
// Check if public key needs to be installed
|
||||
$pubKeyPath = "{$keyPath}.pub";
|
||||
if (file_exists($pubKeyPath)) {
|
||||
$publicKey = trim(file_get_contents($pubKeyPath));
|
||||
$isLocal = $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost' || $node->provider === 'local';
|
||||
|
||||
if ($isLocal) {
|
||||
$authorizedKeysFile = '/root/.ssh/authorized_keys';
|
||||
$existingKeys = file_exists($authorizedKeysFile) ? file_get_contents($authorizedKeysFile) : '';
|
||||
if (!str_contains($existingKeys, $publicKey)) {
|
||||
$this->info("Installing public key on local node...");
|
||||
if (!is_dir('/root/.ssh')) {
|
||||
mkdir('/root/.ssh', 0700, true);
|
||||
}
|
||||
file_put_contents($authorizedKeysFile, $publicKey . "\n", FILE_APPEND);
|
||||
chmod($authorizedKeysFile, 0600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Testing SSH connection...");
|
||||
return $this->testConnection($node);
|
||||
}
|
||||
|
||||
private function testConnection(HostingNode $node): int
|
||||
{
|
||||
try {
|
||||
$node->refresh();
|
||||
$provider = app(\App\Services\Hosting\Providers\SharedNodeProvider::class);
|
||||
|
||||
// Use reflection to call private connect method for testing
|
||||
$reflection = new \ReflectionClass($provider);
|
||||
$method = $reflection->getMethod('connect');
|
||||
$method->setAccessible(true);
|
||||
$ssh = $method->invoke($provider, $node);
|
||||
|
||||
if ($ssh === null) {
|
||||
$this->warn("Node is using local execution (no SSH). This means the SSH key is not configured properly.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output = $ssh->exec('whoami');
|
||||
$this->info("SSH connection successful! Connected as: " . trim($output));
|
||||
|
||||
// Verify mysql access
|
||||
$mysqlTest = $ssh->exec('mysql -e "SELECT 1" 2>&1');
|
||||
if (str_contains($mysqlTest, '1')) {
|
||||
$this->info("MySQL access via auth_socket: OK");
|
||||
} else {
|
||||
$this->warn("MySQL access via auth_socket: FAILED ({$mysqlTest})");
|
||||
$this->warn("MySQL may not be installed or auth_socket may not be configured for root.");
|
||||
}
|
||||
|
||||
// Verify runuser access
|
||||
$runuserTest = $ssh->exec('runuser -u nobody -- whoami 2>&1');
|
||||
if (trim($runuserTest) === 'nobody') {
|
||||
$this->info("runuser access: OK");
|
||||
} else {
|
||||
$this->warn("runuser access: FAILED");
|
||||
}
|
||||
|
||||
$ssh->disconnect();
|
||||
$this->info("");
|
||||
$this->info("Node #{$node->id} is fully configured for SSH access.");
|
||||
return self::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("SSH connection failed: " . $e->getMessage());
|
||||
$this->line("");
|
||||
$this->line("Ensure:");
|
||||
$this->line(" 1. SSH is running on the node ({$node->ip_address}:{$node->ssh_port})");
|
||||
$this->line(" 2. Root login is permitted (PermitRootLogin yes in sshd_config)");
|
||||
$this->line(" 3. The public key is installed in /root/.ssh/authorized_keys");
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\NodeCapacityService;
|
||||
use App\Services\Hosting\HostingResourcePolicyService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SyncHostingAccountUsageCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:sync-account-usage {--account=}';
|
||||
|
||||
protected $description = 'Sync per-account shared hosting usage from hosting nodes.';
|
||||
|
||||
private const BYTES_PER_GB = 1073741824;
|
||||
|
||||
public function handle(
|
||||
SharedNodeProvider $sharedNodeProvider,
|
||||
NodeCapacityService $capacityService,
|
||||
HostingResourcePolicyService $resourcePolicyService,
|
||||
): int {
|
||||
$accounts = HostingAccount::query()
|
||||
->with(['product', 'node', 'databases', 'user', 'latestOrder'])
|
||||
->where('type', 'shared')
|
||||
->whereIn('status', ['active', 'provisioning', 'suspended'])
|
||||
->when($this->option('account'), fn ($query, $accountId) => $query->where('id', $accountId))
|
||||
->get();
|
||||
|
||||
$touchedNodeIds = [];
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if (! $account->node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$usage = $sharedNodeProvider->getUsageStats($account);
|
||||
$requestedDiskGb = $account->requestedDiskGb();
|
||||
|
||||
$account->update([
|
||||
'allocated_disk_gb' => $requestedDiskGb,
|
||||
'disk_used_bytes' => (int) ($usage['disk_used_bytes'] ?? $account->disk_used_bytes),
|
||||
'inode_count' => (int) ($usage['inode_count'] ?? $account->inode_count),
|
||||
'cpu_usage_percent' => $usage['cpu_usage_percent'] ?? $account->cpu_usage_percent,
|
||||
'memory_used_mb' => (int) ($usage['memory_used_mb'] ?? $account->memory_used_mb),
|
||||
'process_count' => (int) ($usage['process_count'] ?? $account->process_count),
|
||||
'io_usage_mb' => $usage['io_usage_mb'] ?? $account->io_usage_mb,
|
||||
'last_usage_sync_at' => now(),
|
||||
]);
|
||||
|
||||
$resourcePolicyService->process($account->fresh(['product', 'node', 'databases', 'user', 'latestOrder']));
|
||||
$touchedNodeIds[$account->hosting_node_id] = true;
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s: disk=%dGB inodes=%d cpu=%s%% mem=%dMB proc=%d',
|
||||
$account->username,
|
||||
(int) ceil(((int) ($usage['disk_used_bytes'] ?? 0)) / self::BYTES_PER_GB),
|
||||
(int) ($usage['inode_count'] ?? 0),
|
||||
$usage['cpu_usage_percent'] ?? 'n/a',
|
||||
(int) ($usage['memory_used_mb'] ?? 0),
|
||||
(int) ($usage['process_count'] ?? 0)
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sync hosting account usage.', [
|
||||
'hosting_account_id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->warn("Failed to sync {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($touchedNodeIds) as $nodeId) {
|
||||
$node = HostingNode::query()->find($nodeId);
|
||||
|
||||
if ($node) {
|
||||
$capacityService->refreshNodeResourceTotals($node);
|
||||
$capacityService->checkNode($node);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Synced {$accounts->count()} hosting account(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user