Skip to content
225 changes: 225 additions & 0 deletions app/Console/Commands/CreateLocalServerCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php

namespace App\Console\Commands;

use App\Enums\FirewallRuleStatus;
use App\Enums\OperatingSystem;
use App\Enums\ServerStatus;
use App\Enums\ServiceStatus;
use App\Models\FirewallRule;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\User;
use App\ServerProviders\Custom;
use App\Services\Firewall\Ufw;
use App\Services\Local\VitoLocal;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;

class CreateLocalServerCommand extends Command
{
protected $signature = 'servers:create-local
{ip : The local server IP address}
{--ports= : Comma-separated list of open ports}
{--nginx= : Whether Nginx is installed (Y/N)}
{--name=local-server : The server name}
{--user= : The user ID to assign the server to}
{--project= : The project ID to assign the server to}
{--domain= : The domain for the local server}
{--web-port=3000 : The web port for the local server}
{--ssl= : Whether SSL is enabled (Y/N)}';

protected $description = 'Create a local server with pre-installed services';

public function handle(): int
{
$ip = $this->argument('ip');
$ports = $this->option('ports') ? explode(',', $this->option('ports')) : [];
$nginxInstalled = strtoupper($this->option('nginx') ?? 'N') === 'Y';
$name = $this->option('name');
$domain = $this->option('domain');
$webPort = (int) $this->option('web-port');
$sslEnabled = strtoupper($this->option('ssl') ?? 'N') === 'Y';

// Validate IP address
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
$this->error("Invalid IP address: {$ip}");

return Command::FAILURE;
}

$user = $this->getUser();
if (! $user) {
$this->error('No user found. Please create a user first or specify a valid user ID.');

return Command::FAILURE;
}

$project = $this->getProject($user);
if (! $project) {
$this->error('No project found. Please create a project first or specify a valid project ID.');

return Command::FAILURE;
}

$existingServer = Server::query()->where('ip', $ip)->first();
if ($existingServer) {
$this->error("A server with IP {$ip} already exists.");

return Command::FAILURE;
}

$this->info("Creating local server '{$name}' with IP {$ip}...");

try {
/** @var Server $server */
$server = DB::transaction(function () use ($project, $user, $name, $ip, $domain, $webPort, $sslEnabled, $nginxInstalled, $ports) {
$server = Server::query()->create([
'project_id' => $project->id,
'user_id' => $user->id,
'name' => $name,
'ssh_user' => config('core.ssh_user'),
'ip' => $ip,
'local_ip' => $ip,
'port' => 22,
'os' => OperatingSystem::UBUNTU22,
'provider' => Custom::id(),
'authentication' => [
'user' => config('core.ssh_user'),
'pass' => '',
],
'public_key' => '',
'status' => ServerStatus::READY,
'progress' => 100,
'is_local' => true,
'local_data' => [
'domain' => $domain,
'port' => $webPort,
'ssl_enabled' => $sslEnabled,
],
]);

// Create default services for local server
$this->createVitoLocalService($server);
$this->createFirewallService($server);

if ($nginxInstalled) {
$this->createNginxService($server);
}

if (! empty($ports)) {
$this->createFirewallRules($server, $ports);
}

return $server;
});
} catch (Throwable $e) {
$this->error('Failed to create local server: '.$e->getMessage());

return Command::FAILURE;
}

$this->info("Server created with ID: {$server->id}");
$this->info('VitoLocal service created.');
$this->info('Firewall service created.');

if ($nginxInstalled) {
$this->info('Nginx service created.');
}

if (! empty($ports)) {
$this->info('Firewall rules created for ports: '.implode(', ', $ports));
}

$this->info('Local server created successfully!');

return Command::SUCCESS;
}

private function getUser(): ?User
{
$userId = $this->option('user');

if ($userId) {
return User::query()->find($userId);
}

return User::query()->first();
}

private function getProject(User $user): ?Project
{
$projectId = $this->option('project');

if ($projectId) {
return Project::query()->find($projectId);
}

/** @var Project|null $project */
$project = $user->currentProject ?? $user->projects()->first();

return $project;
}

private function createVitoLocalService(Server $server): void
{
Service::query()->create([
'server_id' => $server->id,
'type' => VitoLocal::type(),
'name' => VitoLocal::id(),
'version' => 'latest',
'status' => ServiceStatus::READY,
'is_default' => true,
]);
}

private function createFirewallService(Server $server): void
{
Service::query()->create([
'server_id' => $server->id,
'type' => Ufw::type(),
'name' => Ufw::id(),
'version' => 'latest',
'status' => ServiceStatus::READY,
'is_default' => true,
]);
}

private function createNginxService(Server $server): void
{
Service::query()->create([
'server_id' => $server->id,
'type' => 'webserver',
'name' => 'nginx',
'version' => 'latest',
'status' => ServiceStatus::READY,
'is_default' => true,
]);
}

private function createFirewallRules(Server $server, array $ports): void
{
foreach ($ports as $port) {
$port = trim($port);
if (! is_numeric($port)) {
$this->warn("Skipping invalid port: {$port}");

continue;
}

FirewallRule::query()->create([
'server_id' => $server->id,
'name' => "port-{$port}",
'type' => 'allow',
'protocol' => 'tcp',
'port' => (int) $port,
'source' => '0.0.0.0',
'mask' => '0',
'note' => 'Created by servers:create-local command',
'status' => FirewallRuleStatus::READY,
]);
}
}
}
32 changes: 32 additions & 0 deletions app/Contracts/ServerConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace App\Contracts;

use App\Models\Server;
use App\Models\ServerLog;
use Illuminate\Contracts\View\View;

interface ServerConnection
{
public function init(Server $server, ?string $asUser = null): self;

public function setLog(?ServerLog $log): self;

public function getLog(): ?ServerLog;

public function useLog(string $disk, string $path): self;

public function asUser(?string $user): self;

public function connect(bool $sftp = false): void;

public function exec(string|View $command, string $log = '', ?int $siteId = null, ?bool $stream = false, ?callable $streamCallback = null): string;

public function upload(string $local, string $remote, ?string $owner = null, ?string $log = null, ?int $siteId = null): void;

public function download(string $local, string $remote, ?string $log = null, ?int $siteId = null): void;

public function write(string $remotePath, string|View $content, ?string $owner = null, ?string $log = null, ?int $siteId = null): void;

public function disconnect(): void;
}
36 changes: 33 additions & 3 deletions app/Facades/SSH.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace App\Facades;

use App\Contracts\ServerConnection;
use App\Helpers\LocalSocket;
use App\Models\Server;
use App\Models\ServerLog;
use App\Support\Testing\SSHFake;
Expand All @@ -11,7 +13,6 @@
/**
* Class SSH
*
* @method static \App\Helpers\SSH|SSHFake init(Server $server, string $asUser = null)
* @method static setLog(?ServerLog $log)
* @method static \App\Helpers\SSH useLog(string $disk, string $path)
* @method static connect()
Expand All @@ -28,11 +29,40 @@
*/
class SSH extends FacadeAlias
{
protected static ?SSHFake $fake = null;

public static function fake(?string $output = null): SSHFake
{
static::swap($fake = new SSHFake($output));
static::$fake = new SSHFake($output);
static::swap(static::$fake);

return static::$fake;
}

public static function clearFake(): void
{
static::$fake = null;
static::clearResolvedInstance(static::getFacadeAccessor());
}

/**
* Initialize a connection to the server.
* Routes to LocalSocket for local servers, SSH for remote servers.
*/
public static function init(Server $server, ?string $asUser = null): ServerConnection|SSHFake
{
// If we're using a fake, return it
if (static::$fake !== null) {
return static::$fake->init($server, $asUser);
}

// Route to LocalSocket for local servers
if ($server->is_local) {
return app(LocalSocket::class)->init($server, $asUser);
}

return $fake;
// Use regular SSH for remote servers
return app('ssh')->init($server, $asUser);
}

protected static function getFacadeAccessor(): string
Expand Down
Loading