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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user