From a79a7ab6af5b606528d686d0e72193e6d16eb9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 26 Oct 2025 11:32:25 +0200 Subject: [PATCH 1/4] feat: add DigitalOcean key CRUD commands --- app/Console/Key/KeyAddDigitalOceanCommand.php | 128 ++++++++++++++ .../Key/KeyDeleteDigitalOceanCommand.php | 159 ++++++++++++++++++ .../Key/KeyListDigitalOceanCommand.php | 79 +++++++++ app/Contracts/BaseCommand.php | 2 +- .../DigitalOcean/BaseDigitalOceanService.php | 39 +++++ .../DigitalOceanAccountService.php | 35 +--- .../DigitalOceanDropletService.php | 27 +-- .../DigitalOcean/DigitalOceanKeyService.php | 50 +----- app/Services/DigitalOceanService.php | 72 ++++---- app/SymfonyApp.php | 6 + app/Traits/DigitalOceanCommandTrait.php | 58 +++++++ app/Traits/KeyHelpersTrait.php | 98 +++++++++++ app/Traits/KeyValidationTrait.php | 99 +++++++++++ 13 files changed, 712 insertions(+), 140 deletions(-) create mode 100644 app/Console/Key/KeyAddDigitalOceanCommand.php create mode 100644 app/Console/Key/KeyDeleteDigitalOceanCommand.php create mode 100644 app/Console/Key/KeyListDigitalOceanCommand.php create mode 100644 app/Services/DigitalOcean/BaseDigitalOceanService.php create mode 100644 app/Traits/DigitalOceanCommandTrait.php create mode 100644 app/Traits/KeyHelpersTrait.php create mode 100644 app/Traits/KeyValidationTrait.php diff --git a/app/Console/Key/KeyAddDigitalOceanCommand.php b/app/Console/Key/KeyAddDigitalOceanCommand.php new file mode 100644 index 00000000..5e2b34af --- /dev/null +++ b/app/Console/Key/KeyAddDigitalOceanCommand.php @@ -0,0 +1,128 @@ +addOption('name', null, InputOption::VALUE_REQUIRED, 'Key name in DigitalOcean account') + ->addOption('public-key-path', null, InputOption::VALUE_REQUIRED, 'SSH public key path'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->io->hr(); + $this->io->h1('Add SSH Key to DigitalOcean'); + + if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { + return Command::FAILURE; + } + + // + // Gather key details + + /** @var string|null $keyPath */ + $keyPath = $this->io->getValidatedOptionOrPrompt( + 'public-key-path', + fn ($validate) => $this->io->promptText( + label: 'Path to SSH public key:', + placeholder: '~/.ssh/id_ed25519.pub', + required: true, + validate: $validate + ), + fn ($value) => $this->validateKeyPathInput($value) + ); + + if ($keyPath === null) { + return Command::FAILURE; + } + + // Expand tilde to home directory + $keyPath = $this->expandKeyPath($keyPath); + + $defaultName = 'deployer-key'; + + /** @var string|null $keyName */ + $keyName = $this->io->getValidatedOptionOrPrompt( + 'name', + fn ($validate) => $this->io->promptText( + label: 'Key name:', + placeholder: $defaultName, + default: $defaultName, + required: true, + validate: $validate + ), + fn ($value) => $this->validateKeyNameInput($value) + ); + + if ($keyName === null) { + return Command::FAILURE; + } + + // + // Upload SSH key + + try { + $keyId = $this->io->promptSpin( + fn () => $this->digitalOcean->key->uploadKey($keyPath, $keyName), + 'Uploading SSH key...' + ); + + $this->io->success("SSH key uploaded successfully (ID: {$keyId})"); + $this->io->writeln(''); + } catch (\RuntimeException $e) { + $this->io->error('Failed to upload SSH key: ' . $e->getMessage()); + $this->io->writeln(''); + + return Command::FAILURE; + } + + // + // Show command hint + + $this->io->showCommandHint('key:add:digitalocean', [ + 'public-key-path' => $keyPath, + 'name' => $keyName, + ]); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Key/KeyDeleteDigitalOceanCommand.php b/app/Console/Key/KeyDeleteDigitalOceanCommand.php new file mode 100644 index 00000000..e7768e04 --- /dev/null +++ b/app/Console/Key/KeyDeleteDigitalOceanCommand.php @@ -0,0 +1,159 @@ +addOption('key', null, InputOption::VALUE_REQUIRED, 'SSH key ID') + ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing key ID (use with caution)') + ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->io->hr(); + $this->io->h1('Delete SSH Key from DigitalOcean'); + + if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { + return Command::FAILURE; + } + + // + // Fetch available keys + + try { + $availableKeys = $this->digitalOcean->account->getUserSshKeys(); + } catch (\RuntimeException $e) { + $this->io->error('Failed to fetch SSH keys: ' . $e->getMessage()); + $this->io->writeln(''); + + return Command::FAILURE; + } + + // + // Select key + + $selection = $this->selectKey($availableKeys); + + if ($selection['key'] === null) { + return $selection['exit_code']; + } + + $keyId = (int) $selection['key']; + $keyDescription = $availableKeys[$keyId]; + + // + // Display key details + + $this->io->hr(); + + $this->io->writeln([ + " ID: {$keyId}", + " Name: {$keyDescription}", + '', + ]); + + // + // Confirm deletion with extra safety + + /** @var bool $forceSkip */ + $forceSkip = $input->getOption('force') ?? false; + + if (!$forceSkip) { + $this->io->writeln(''); + + $typedKeyId = $this->io->promptText( + label: "Type the key ID '{$keyId}' to confirm deletion:", + required: true + ); + + if ($typedKeyId !== (string) $keyId) { + $this->io->error('Key ID does not match. Deletion cancelled.'); + $this->io->writeln(''); + + return Command::FAILURE; + } + } + + /** @var bool $confirmed */ + $confirmed = $this->io->getOptionOrPrompt( + 'yes', + fn (): bool => $this->io->promptConfirm( + label: 'Are you absolutely sure?', + default: false + ) + ); + + if (!$confirmed) { + $this->io->warning('Cancelled deleting SSH key'); + $this->io->writeln(''); + + return Command::SUCCESS; + } + + // + // Delete key + + try { + $this->io->promptSpin( + fn () => $this->digitalOcean->key->deleteKey($keyId), + 'Deleting SSH key...' + ); + + $this->io->success('SSH key deleted successfully'); + $this->io->writeln(''); + } catch (\RuntimeException $e) { + $this->io->error('Failed to delete SSH key: ' . $e->getMessage()); + $this->io->writeln(''); + + return Command::FAILURE; + } + + // + // Show command hint + + $this->io->showCommandHint('key:delete:digitalocean', [ + 'key' => (string) $keyId, + 'yes' => $confirmed, + 'force' => true, + ]); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Key/KeyListDigitalOceanCommand.php b/app/Console/Key/KeyListDigitalOceanCommand.php new file mode 100644 index 00000000..5b2cb9f3 --- /dev/null +++ b/app/Console/Key/KeyListDigitalOceanCommand.php @@ -0,0 +1,79 @@ +io->hr(); + $this->io->h1('List SSH Keys in DigitalOcean'); + + if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { + return Command::FAILURE; + } + + // + // Fetch SSH keys + // ------------------------------------------------------------------------------- + + try { + $keys = $this->io->promptSpin( + fn () => $this->digitalOcean->account->getUserSshKeys(), + 'Fetching SSH keys...' + ); + } catch (\RuntimeException $e) { + $this->io->error('Failed to fetch SSH keys: ' . $e->getMessage()); + $this->io->writeln(''); + + return Command::FAILURE; + } + + // + // Display keys + // ------------------------------------------------------------------------------- + + if (count($keys) === 0) { + $this->io->warning('No SSH keys found in your DigitalOcean account'); + $this->io->writeln([ + '', + 'Use key:add:digitalocean to add an SSH key', + '', + ]); + + return Command::SUCCESS; + } + + foreach ($keys as $keyId => $description) { + $this->io->writeln(" {$keyId} - {$description}"); + } + + $this->io->writeln(''); + + return Command::SUCCESS; + } +} diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index e6396f8d..ad35875c 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -52,7 +52,7 @@ public function __construct( protected readonly SiteRepository $sites, protected readonly SSHService $ssh, - // Providers + // Hosting providers protected readonly DigitalOceanService $digitalOcean, ) { parent::__construct(); diff --git a/app/Services/DigitalOcean/BaseDigitalOceanService.php b/app/Services/DigitalOcean/BaseDigitalOceanService.php new file mode 100644 index 00000000..24c7975f --- /dev/null +++ b/app/Services/DigitalOcean/BaseDigitalOceanService.php @@ -0,0 +1,39 @@ +api = $api; + } + + /** + * Get the configured DigitalOcean API client. + * + * @throws \RuntimeException If client not configured + */ + protected function getAPI(): Client + { + if ($this->api === null) { + throw new \RuntimeException('DigitalOcean API client not configured. Call setAPI() first.'); + } + + return $this->api; + } +} diff --git a/app/Services/DigitalOcean/DigitalOceanAccountService.php b/app/Services/DigitalOcean/DigitalOceanAccountService.php index 99934f6d..9f09898c 100644 --- a/app/Services/DigitalOcean/DigitalOceanAccountService.php +++ b/app/Services/DigitalOcean/DigitalOceanAccountService.php @@ -4,7 +4,6 @@ namespace Bigpixelrocket\DeployerPHP\Services\DigitalOcean; -use DigitalOceanV2\Client; use DigitalOceanV2\Entity\Image as ImageEntity; use DigitalOceanV2\Entity\Region as RegionEntity; use DigitalOceanV2\Entity\Size as SizeEntity; @@ -14,20 +13,14 @@ * * Handles fetching account-level resources: regions, sizes, images, VPCs, SSH keys. */ -class DigitalOceanAccountService +class DigitalOceanAccountService extends BaseDigitalOceanService { - private ?Client $api = null; + // + // Account data retrieval + // ------------------------------------------------------------------------------- /** - * Set the DigitalOcean API client. - */ - public function setAPI(Client $api): void - { - $this->api = $api; - } - - /** - * Get available DigitalOcean regions. + * Get available regions. * * @return array Array of region slug => description */ @@ -123,7 +116,7 @@ public function getAvailableImages(): array } /** - * Get user's VPCs for a specific region. + * Get available VPCs for a specific region. * * @return array Array of VPC UUID => name */ @@ -149,7 +142,7 @@ public function getUserVpcs(string $region): array } /** - * Get user's SSH keys. + * Get available SSH keys. * * @return array Array of key ID => description */ @@ -172,18 +165,4 @@ public function getUserSshKeys(): array throw new \RuntimeException('Failed to fetch SSH keys: ' . $e->getMessage(), 0, $e); } } - - /** - * Get the configured DigitalOcean API client. - * - * @throws \RuntimeException If client not configured - */ - private function getAPI(): Client - { - if ($this->api === null) { - throw new \RuntimeException('DigitalOcean API client not configured. Call setAPI() first.'); - } - - return $this->api; - } } diff --git a/app/Services/DigitalOcean/DigitalOceanDropletService.php b/app/Services/DigitalOcean/DigitalOceanDropletService.php index c994ddae..55e02037 100644 --- a/app/Services/DigitalOcean/DigitalOceanDropletService.php +++ b/app/Services/DigitalOcean/DigitalOceanDropletService.php @@ -4,7 +4,6 @@ namespace Bigpixelrocket\DeployerPHP\Services\DigitalOcean; -use DigitalOceanV2\Client; use DigitalOceanV2\Entity\Droplet as DropletEntity; /** @@ -12,18 +11,8 @@ * * Handles creating, destroying, and monitoring droplets. */ -class DigitalOceanDropletService +class DigitalOceanDropletService extends BaseDigitalOceanService { - private ?Client $api = null; - - /** - * Set the DigitalOcean API client. - */ - public function setAPI(Client $api): void - { - $this->api = $api; - } - /** * Create a new droplet with the specified configuration. * @@ -194,18 +183,4 @@ public function destroyDroplet(int $dropletId): void throw new \RuntimeException("Failed to destroy droplet: {$e->getMessage()}", 0, $e); } } - - /** - * Get the configured DigitalOcean API client. - * - * @throws \RuntimeException If client not configured - */ - private function getAPI(): Client - { - if ($this->api === null) { - throw new \RuntimeException('DigitalOcean API client not configured. Call setAPI() first.'); - } - - return $this->api; - } } diff --git a/app/Services/DigitalOcean/DigitalOceanKeyService.php b/app/Services/DigitalOcean/DigitalOceanKeyService.php index 5bed88b9..fcd97a60 100644 --- a/app/Services/DigitalOcean/DigitalOceanKeyService.php +++ b/app/Services/DigitalOcean/DigitalOceanKeyService.php @@ -5,34 +5,23 @@ namespace Bigpixelrocket\DeployerPHP\Services\DigitalOcean; use Bigpixelrocket\DeployerPHP\Services\FilesystemService; -use DigitalOceanV2\Client; /** * DigitalOcean SSH key management service. * * Handles uploading and deleting SSH keys from DigitalOcean account. */ -class DigitalOceanKeyService +class DigitalOceanKeyService extends BaseDigitalOceanService { - private ?Client $api = null; - public function __construct( private readonly FilesystemService $fs, ) { } - /** - * Set the DigitalOcean API client. - */ - public function setAPI(Client $api): void - { - $this->api = $api; - } - /** * Upload a local SSH public key to DigitalOcean account. * - * @param string $publicKeyPath Path to public key file (should be already expanded) + * @param string $publicKeyPath Path to public key file * @param string $keyName Name for the key in DO account * * @return int The new SSH key ID @@ -41,30 +30,9 @@ public function setAPI(Client $api): void */ public function uploadKey(string $publicKeyPath, string $keyName): int { - // Check if file exists - if (!$this->fs->exists($publicKeyPath)) { - throw new \RuntimeException("SSH public key file not found: {$publicKeyPath}"); - } - - // Read public key content $publicKey = $this->fs->readFile($publicKeyPath); $publicKey = trim($publicKey); - // Validate key format (should start with ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc.) - // except 'ssh-dss' which is effectively obsolete: - $validPrefixes = ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-dss']; - $isValid = false; - foreach ($validPrefixes as $prefix) { - if (str_starts_with($publicKey, $prefix)) { - $isValid = true; - break; - } - } - - if (!$isValid) { - throw new \RuntimeException("Invalid SSH public key format in {$publicKeyPath}"); - } - $client = $this->getAPI(); try { @@ -104,18 +72,4 @@ public function deleteKey(int $keyId): void throw new \RuntimeException("Failed to delete SSH key: {$e->getMessage()}", 0, $e); } } - - /** - * Get the configured DigitalOcean API client. - * - * @throws \RuntimeException If client not configured - */ - private function getAPI(): Client - { - if ($this->api === null) { - throw new \RuntimeException('DigitalOcean API client not configured. Call setAPI() first.'); - } - - return $this->api; - } } diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index 68217fd2..bec12870 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -31,11 +31,11 @@ public function __construct( } // - // API + // API Initialization // ------------------------------------------------------------------------------- /** - * Initialize the DigitalOcean API with the given token. + * Single function to initialize the DigitalOcean API and verify authentication. * * @param string $token The DigitalOcean API token * @@ -49,7 +49,7 @@ public function initialize(string $token): void } /** - * Set the DigitalOcean API token. + * Set a DigitalOcean API token. * * Must be called before making any API calls. */ @@ -62,7 +62,37 @@ public function setToken(string $token): void } /** - * Verify API token authentication by making a lightweight API call. + * Initialize and return the DigitalOcean API client. + * + * Must be called before making any API calls. + * + * @throws \RuntimeException If API token is not configured + */ + public function initializeAPI(): Client + { + if ($this->api !== null) { + return $this->api; + } + + if ($this->token === null || $this->token === '') { + throw new \RuntimeException( + 'DigitalOcean API token not set. '. + 'Set API token before making API requests.' + ); + } + + $this->api = new Client(); + $this->api->authenticate($this->token); + + $this->account->setAPI($this->api); + $this->key->setAPI($this->api); + $this->droplet->setAPI($this->api); + + return $this->api; + } + + /** + * Verify DigitalOcean API authentication. * * @throws \RuntimeException If authentication fails or API is unreachable */ @@ -71,7 +101,7 @@ public function verifyAuthentication(): void $api = $this->initializeAPI(); try { - // Use account endpoint - lightweight and verifies token validity + // Use account endpoint to verify token validity $api->account()->getUserInformation(); } catch (\Throwable $e) { throw new \RuntimeException('Failed to authenticate with DigitalOcean API: ' . $e->getMessage(), 0, $e); @@ -113,36 +143,4 @@ public function clearCache(string $key): void { unset($this->cache[$key]); } - - // - // Client access - // ------------------------------------------------------------------------------- - - /** - * Get or initialize the DigitalOcean API client. - * - * @throws \RuntimeException If API token is not configured - */ - private function initializeAPI(): Client - { - if ($this->api !== null) { - return $this->api; - } - - if ($this->token === null || $this->token === '') { - throw new \RuntimeException( - 'DigitalOcean API token not set. '. - 'Set API token before making API requests.' - ); - } - - $this->api = new Client(); - $this->api->authenticate($this->token); - - $this->account->setAPI($this->api); - $this->key->setAPI($this->api); - $this->droplet->setAPI($this->api); - - return $this->api; - } } diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 90bf5977..93f4ee76 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -5,6 +5,9 @@ namespace Bigpixelrocket\DeployerPHP; use Bigpixelrocket\DeployerPHP\Console\HelloCommand; +use Bigpixelrocket\DeployerPHP\Console\Key\KeyAddDigitalOceanCommand; +use Bigpixelrocket\DeployerPHP\Console\Key\KeyDeleteDigitalOceanCommand; +use Bigpixelrocket\DeployerPHP\Console\Key\KeyListDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; @@ -120,6 +123,9 @@ private function registerCommands(): void { $commands = [ HelloCommand::class, + KeyAddDigitalOceanCommand::class, + KeyDeleteDigitalOceanCommand::class, + KeyListDigitalOceanCommand::class, ServerAddCommand::class, ServerDeleteCommand::class, ServerListCommand::class, diff --git a/app/Traits/DigitalOceanCommandTrait.php b/app/Traits/DigitalOceanCommandTrait.php new file mode 100644 index 00000000..c8fb5926 --- /dev/null +++ b/app/Traits/DigitalOceanCommandTrait.php @@ -0,0 +1,58 @@ +env->get(['DIGITALOCEAN_API_TOKEN', 'DO_API_TOKEN']); + + if ($apiToken === null || $apiToken === '') { + throw new \InvalidArgumentException('DigitalOcean API token not found in environment'); + } + + // Initialize DigitalOcean API + $this->io->promptSpin( + fn () => $this->digitalOcean->initialize($apiToken), + 'Initializing DigitalOcean API...' + ); + + return Command::SUCCESS; + } catch (\InvalidArgumentException) { + // Token configuration issue + $this->io->error('DigitalOcean API token not found in environment.'); + $this->io->writeln(''); + $this->io->writeln('Set DIGITALOCEAN_API_TOKEN or DO_API_TOKEN in your .env file.'); + $this->io->writeln(''); + + return Command::FAILURE; + } catch (\RuntimeException $e) { + // API authentication failure + $this->io->error($e->getMessage()); + $this->io->writeln(''); + $this->io->writeln('Check that your API token is valid and has not expired.'); + $this->io->writeln(''); + + return Command::FAILURE; + } + } +} diff --git a/app/Traits/KeyHelpersTrait.php b/app/Traits/KeyHelpersTrait.php new file mode 100644 index 00000000..ff722699 --- /dev/null +++ b/app/Traits/KeyHelpersTrait.php @@ -0,0 +1,98 @@ +io->writeln([ + " ID: {$id}", + " Name: {$name}", + " Fingerprint: {$fingerprint}", + '', + ]); + } + + /** + * Expand tilde (~) in file path to absolute home directory path. + * + * @throws \RuntimeException If HOME environment variable not found when needed + */ + protected function expandKeyPath(string $path): string + { + if (!str_starts_with($path, '~')) { + return $path; + } + + $home = $this->env->get('HOME', required: false); + if ($home === null) { + throw new \RuntimeException('Could not determine home directory'); + } + + return $home . substr($path, 1); + } + + /** + * Select a key from available keys via option or interactive prompt. + * + * @param array $availableKeys Map of key IDs to descriptions + * @param string $optionName Option name to check for pre-provided value + * @param string $promptLabel Label for interactive prompt + * @return array{key: string|null, exit_code: int} Selected key ID and exit code + */ + protected function selectKey(array $availableKeys, string $optionName = 'key', string $promptLabel = 'Select SSH key:'): array + { + // + // Check if keys are available + + if (count($availableKeys) === 0) { + $this->io->warning('No SSH keys found'); + $this->io->writeln(''); + + return ['key' => null, 'exit_code' => Command::SUCCESS]; + } + + // + // Get key via option or prompt + + /** @var string $selectedKey */ + $selectedKey = $this->io->getOptionOrPrompt( + $optionName, + fn (): string => (string) $this->io->promptSelect( + label: $promptLabel, + options: $availableKeys + ) + ); + + // + // Validate key exists in available keys + + if (!isset($availableKeys[$selectedKey])) { + $this->io->error("SSH key '{$selectedKey}' not found"); + + return ['key' => null, 'exit_code' => Command::FAILURE]; + } + + return ['key' => $selectedKey, 'exit_code' => Command::SUCCESS]; + } +} diff --git a/app/Traits/KeyValidationTrait.php b/app/Traits/KeyValidationTrait.php new file mode 100644 index 00000000..5d18837c --- /dev/null +++ b/app/Traits/KeyValidationTrait.php @@ -0,0 +1,99 @@ +expandKeyPath($path); + } catch (\RuntimeException) { + return 'Could not determine home directory for path expansion'; + } + + // Check if file exists + if (!$this->fs->exists($expandedPath)) { + return "SSH key file not found: {$path}"; + } + + // Read and validate key format + try { + $publicKey = $this->fs->readFile($expandedPath); + $publicKey = trim((string) $publicKey); + + // Validate key format (should start with ssh-rsa, ssh-ed25519, etc.) + // except 'ssh-dss' which is effectively obsolete: + $validPrefixes = ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-dss']; + $isValid = false; + foreach ($validPrefixes as $prefix) { + if (str_starts_with($publicKey, $prefix)) { + $isValid = true; + break; + } + } + + if (!$isValid) { + return 'Invalid SSH public key format'; + } + } catch (\Throwable) { + return 'Could not read SSH key file'; + } + + return null; + } + + /** + * Validate SSH key name format. + * + * Ensures name contains only alphanumeric characters, hyphens, and underscores. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateKeyNameInput(mixed $name): ?string + { + if (!is_string($name)) { + return 'Key name must be a string'; + } + + // Check if empty + if (trim($name) === '') { + return 'Key name cannot be empty'; + } + + // Validate format (alphanumeric, hyphens, underscores) + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $name)) { + return 'Key name can only contain letters, numbers, hyphens, and underscores'; + } + + return null; + } +} From 8bee8d62c72faa0c460ef4fe827ff933083db53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 26 Oct 2025 11:47:50 +0200 Subject: [PATCH 2/4] refactor: make DigitalOcean API initialization methods private --- app/Services/DigitalOceanService.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index bec12870..6f04c431 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -37,6 +37,8 @@ public function __construct( /** * Single function to initialize the DigitalOcean API and verify authentication. * + * Must be called before making any API calls. + * * @param string $token The DigitalOcean API token * * @throws \RuntimeException If authentication fails or API is unreachable @@ -50,10 +52,8 @@ public function initialize(string $token): void /** * Set a DigitalOcean API token. - * - * Must be called before making any API calls. */ - public function setToken(string $token): void + private function setToken(string $token): void { $this->token = $token; @@ -64,11 +64,9 @@ public function setToken(string $token): void /** * Initialize and return the DigitalOcean API client. * - * Must be called before making any API calls. - * * @throws \RuntimeException If API token is not configured */ - public function initializeAPI(): Client + private function initializeAPI(): Client { if ($this->api !== null) { return $this->api; From ca7a1b1893c7c70371149604d1074d8958d8ffe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 26 Oct 2025 11:55:33 +0200 Subject: [PATCH 3/4] feat: add support for modern ssh keys and explicit DSA validation --- app/Traits/KeyValidationTrait.php | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/Traits/KeyValidationTrait.php b/app/Traits/KeyValidationTrait.php index 5d18837c..171b7b4a 100644 --- a/app/Traits/KeyValidationTrait.php +++ b/app/Traits/KeyValidationTrait.php @@ -50,9 +50,20 @@ protected function validateKeyPathInput(mixed $path): ?string $publicKey = $this->fs->readFile($expandedPath); $publicKey = trim((string) $publicKey); - // Validate key format (should start with ssh-rsa, ssh-ed25519, etc.) - // except 'ssh-dss' which is effectively obsolete: - $validPrefixes = ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-dss']; + // Validate key format (should start with supported SSH key types) + $validPrefixes = [ + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'ssh-rsa', + // Modern FIDO2/U2F security key types: + 'sk-ssh-ed25519@openssh.com', + 'sk-ecdsa-sha2-nistp256@openssh.com', + // Obsolete and insecure: + // 'ssh-dss', + ]; + $isValid = false; foreach ($validPrefixes as $prefix) { if (str_starts_with($publicKey, $prefix)) { @@ -62,6 +73,11 @@ protected function validateKeyPathInput(mixed $path): ?string } if (!$isValid) { + // Explicit error for obsolete DSA keys + if (str_starts_with($publicKey, 'ssh-dss')) { + return 'DSA (ssh-dss) keys are obsolete and insecure'; + } + return 'Invalid SSH public key format'; } } catch (\Throwable) { From 224129d107d507761420957b2edc30acdda45b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 26 Oct 2025 12:07:55 +0200 Subject: [PATCH 4/4] refactor: make DigitalOcean verifyAuthentication method private --- app/Services/DigitalOceanService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index 6f04c431..bcc8f384 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -94,7 +94,7 @@ private function initializeAPI(): Client * * @throws \RuntimeException If authentication fails or API is unreachable */ - public function verifyAuthentication(): void + private function verifyAuthentication(): void { $api = $this->initializeAPI();