-
-
Notifications
You must be signed in to change notification settings - Fork 416
[Feat] Add AWS Lightsail server provider #1252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 4.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,249 @@ | ||
| <?php | ||
|
|
||
| namespace App\ServerProviders; | ||
|
|
||
| use App\Exceptions\ServerProviderError; | ||
| use Aws\Exception\AwsException; | ||
| use Aws\Lightsail\LightsailClient; | ||
| use Illuminate\Filesystem\FilesystemAdapter; | ||
| use Illuminate\Support\Facades\Storage; | ||
| use Illuminate\Support\Str; | ||
| use Illuminate\Validation\ValidationException; | ||
| use phpseclib3\Crypt\RSA; | ||
| use SensitiveParameter; | ||
|
|
||
| class Lightsail extends AbstractProvider | ||
| { | ||
| public static function id(): string | ||
| { | ||
| return 'lightsail'; | ||
| } | ||
|
|
||
| public function createRules(array $input): array | ||
| { | ||
| return [ | ||
| 'plan' => ['required', 'string'], | ||
| 'region' => ['required', 'string', 'regex:/^[a-z]{2}(?:-[a-z]+)+-\d+$/'], | ||
| ]; | ||
| } | ||
|
|
||
| public function credentialValidationRules(array $input): array | ||
| { | ||
| return [ | ||
| 'key' => ['required', 'string'], | ||
| 'secret' => ['required', 'string'], | ||
| ]; | ||
| } | ||
|
|
||
| public function credentialData(array $input): array | ||
| { | ||
| return [ | ||
| 'key' => $input['key'], | ||
| 'secret' => $input['secret'], | ||
| ]; | ||
| } | ||
|
|
||
| public function data(array $input): array | ||
| { | ||
| return [ | ||
| 'plan' => $input['plan'], | ||
| 'region' => $input['region'], | ||
| ]; | ||
| } | ||
|
|
||
| public function connect(#[SensitiveParameter] array $credentials): bool | ||
| { | ||
| $this->request('GetRegions', credentials: $this->credentialData($credentials)); | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| public function regions(): array | ||
| { | ||
| return collect($this->request('GetRegions')['regions'] ?? []) | ||
| ->mapWithKeys(fn (array $region): array => [ | ||
| $region['name'] => $region['displayName'].' ('.$region['name'].')', | ||
| ]) | ||
| ->all(); | ||
| } | ||
|
|
||
| public function plans(?string $region): array | ||
| { | ||
| if (! $region) { | ||
| return []; | ||
| } | ||
|
|
||
| return collect($this->paginate('GetBundles', 'bundles', $region)) | ||
| ->filter(fn (array $bundle): bool => ($bundle['isActive'] ?? false) | ||
| && in_array('LINUX_UNIX', $bundle['supportedPlatforms'] ?? [], true) | ||
| && ($bundle['publicIpv4AddressCount'] ?? 0) > 0) | ||
| ->mapWithKeys(fn (array $bundle): array => [ | ||
| $bundle['bundleId'] => __('server_providers.plan', [ | ||
| 'name' => $bundle['name'], | ||
| 'cpu' => $bundle['cpuCount'], | ||
| 'memory' => $bundle['ramSizeInGb'] * 1024, | ||
| 'disk' => $bundle['diskSizeInGb'], | ||
| ]).' ('.number_format($bundle['price'], 2).'/mo)', | ||
| ]) | ||
| ->all(); | ||
| } | ||
|
|
||
| public function create(): void | ||
| { | ||
| $region = $this->server->provider_data['region']; | ||
| $regions = $this->request('GetRegions', ['includeAvailabilityZones' => true]); | ||
| $location = collect($regions['regions'] ?? [])->firstWhere('name', $region); | ||
| $zone = $location['availabilityZones'][0]['zoneName'] ?? null; | ||
|
|
||
| if (! $zone) { | ||
| throw new ServerProviderError('The selected AWS Lightsail region is unavailable.'); | ||
| } | ||
|
|
||
| if (! isset($this->plans($region)[$this->server->provider_data['plan']])) { | ||
| throw new ServerProviderError('The selected AWS Lightsail plan is unavailable.'); | ||
| } | ||
|
|
||
| $blueprint = collect($this->paginate('GetBlueprints', 'blueprints', $region)) | ||
| ->first(fn (array $blueprint): bool => ($blueprint['isActive'] ?? false) | ||
| && ($blueprint['group'] ?? '') === 'ubuntu' | ||
| && ($blueprint['type'] ?? '') === 'os' | ||
| && str_starts_with($blueprint['version'] ?? '', $this->server->os->getVersion())); | ||
|
|
||
| if (! $blueprint) { | ||
| throw new ServerProviderError('The selected Ubuntu version is unavailable on AWS Lightsail.'); | ||
| } | ||
|
|
||
| $name = 'vito-'.$this->server->id.'-'.Str::lower(Str::random(12)); | ||
| $this->generateKeyPair(); | ||
| $this->server->jsonUpdate('provider_data', 'ssh_key_name', $name); | ||
| $this->request('ImportKeyPair', [ | ||
| 'keyPairName' => $name, | ||
| 'publicKeyBase64' => base64_encode($this->server->sshKey()['public_key']), | ||
| ]); | ||
|
|
||
| $this->server->jsonUpdate('provider_data', 'instance_name', $name); | ||
| $this->request('CreateInstances', [ | ||
| 'instanceNames' => [$name], | ||
| 'availabilityZone' => $zone, | ||
| 'blueprintId' => $blueprint['blueprintId'], | ||
| 'bundleId' => $this->server->provider_data['plan'], | ||
| 'keyPairName' => $name, | ||
| 'ipAddressType' => 'ipv4', | ||
| ]); | ||
| } | ||
|
|
||
| public function generateKeyPair(): void | ||
| { | ||
| $key = RSA::createKey(2048); | ||
| /** @var FilesystemAdapter $disk */ | ||
| $disk = Storage::disk(config('core.key_pairs_disk')); | ||
| $disk->put((string) $this->server->id, $key->toString('PKCS8')); | ||
| chmod($disk->path((string) $this->server->id), 0400); | ||
| $disk->put($this->server->id.'.pub', $key->getPublicKey()->toString('OpenSSH')); | ||
| } | ||
|
|
||
| public function isRunning(): bool | ||
| { | ||
| if (! isset($this->server->provider_data['instance_name'])) { | ||
| return false; | ||
| } | ||
|
|
||
| $result = $this->request('GetInstance', [ | ||
| 'instanceName' => $this->server->provider_data['instance_name'], | ||
| ]); | ||
| $instance = $result['instance'] ?? []; | ||
|
|
||
| if (($instance['state']['name'] ?? '') !== 'running' || empty($instance['publicIpAddress'])) { | ||
| return false; | ||
| } | ||
|
|
||
| if (! ($this->server->provider_data['firewall_configured'] ?? false)) { | ||
| $this->request('PutInstancePublicPorts', [ | ||
| 'instanceName' => $this->server->provider_data['instance_name'], | ||
| 'portInfos' => [[ | ||
| 'fromPort' => 0, | ||
| 'toPort' => 65535, | ||
| 'protocol' => 'all', | ||
| 'cidrs' => ['0.0.0.0/0'], | ||
| ]], | ||
| ]); | ||
| $this->server->jsonUpdate('provider_data', 'firewall_configured', true, false); | ||
| } | ||
|
|
||
| $this->server->ip = $instance['publicIpAddress']; | ||
| $this->server->local_ip = $instance['privateIpAddress'] ?? null; | ||
| $this->server->save(); | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| public function delete(): void | ||
| { | ||
| if (isset($this->server->provider_data['instance_name'])) { | ||
| $this->request('DeleteInstance', [ | ||
| 'instanceName' => $this->server->provider_data['instance_name'], | ||
| ]); | ||
| } | ||
|
|
||
| if (isset($this->server->provider_data['ssh_key_name'])) { | ||
| $this->request('DeleteKeyPair', [ | ||
| 'keyPairName' => $this->server->provider_data['ssh_key_name'], | ||
| ]); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @return array<int, array<string, mixed>> | ||
| */ | ||
| private function paginate(string $operation, string $key, string $region): array | ||
| { | ||
| $items = []; | ||
| $parameters = ['includeInactive' => false]; | ||
|
|
||
| do { | ||
| $result = $this->request($operation, $parameters, $region); | ||
| $items = array_merge($items, $result[$key] ?? []); | ||
| $parameters['pageToken'] = $result['nextPageToken'] ?? null; | ||
| } while ($parameters['pageToken']); | ||
|
|
||
| return $items; | ||
| } | ||
|
|
||
| /** | ||
| * @param array<string, mixed> $parameters | ||
| * @param array{key: string, secret: string}|null $credentials | ||
| * @return array<string, mixed> | ||
| */ | ||
| private function request(string $operation, array $parameters = [], ?string $region = null, #[SensitiveParameter] ?array $credentials = null): array | ||
| { | ||
| $region ??= $this->server->provider_data['region'] ?? 'us-east-1'; | ||
|
|
||
| if (! preg_match('/^[a-z]{2}(?:-[a-z]+)+-\d+$/', $region)) { | ||
| throw ValidationException::withMessages(['region' => 'Invalid AWS Lightsail region.']); | ||
| } | ||
|
|
||
| try { | ||
| $client = app(LightsailClient::class, ['args' => [ | ||
| 'version' => '2016-11-28', | ||
| 'region' => $region, | ||
| 'credentials' => $credentials ?? $this->serverProvider->getCredentials(), | ||
| ]]); | ||
| $result = $client->execute($client->getCommand($operation, $parameters))->toArray(); | ||
| } catch (AwsException $exception) { | ||
| if ($exception->getAwsErrorCode() === 'NotFoundException' | ||
| && in_array($operation, ['GetInstance', 'DeleteInstance', 'DeleteKeyPair'], true)) { | ||
| return []; | ||
| } | ||
|
|
||
| throw new ServerProviderError('AWS Lightsail could not complete '.$operation.'. Check the provider permissions and try again.'); | ||
| } | ||
|
|
||
| foreach ($result['operations'] ?? (isset($result['operation']) ? [$result['operation']] : []) as $operationResult) { | ||
| if (($operationResult['status'] ?? '') === 'Failed') { | ||
| throw new ServerProviderError('AWS Lightsail could not complete '.$operation.'.'); | ||
| } | ||
| } | ||
|
|
||
| return $result; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,22 +74,28 @@ paths: | |
| required: | ||
| - name | ||
| - provider | ||
| - credentials | ||
| properties: | ||
| name: | ||
| type: string | ||
| description: Server provider name | ||
| example: 'DigitalOcean' | ||
| provider: | ||
| type: string | ||
| enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr'] | ||
| enum: ['aws', 'lightsail', 'hetzner', 'digitalocean', 'linode', 'vultr'] | ||
| description: Server provider type | ||
| example: 'digitalocean' | ||
| credentials: | ||
| type: object | ||
| description: Provider-specific credentials | ||
| example: | ||
| token: 'dop_v1_xxxxxxxxxxxx' | ||
| token: | ||
| type: string | ||
| writeOnly: true | ||
| description: API token (required for Hetzner, DigitalOcean, Linode, and Vultr) | ||
| key: | ||
| type: string | ||
| writeOnly: true | ||
| description: Access key ID (required for AWS and AWS Lightsail) | ||
| secret: | ||
| type: string | ||
| writeOnly: true | ||
| description: Secret access key (required for AWS and AWS Lightsail) | ||
|
Comment on lines
+87
to
+98
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Encode provider-specific credential requirements. Both create request schemas require only Add provider-specific 🤖 Prompt for AI Agents |
||
| global: | ||
| type: boolean | ||
| description: Whether this provider should be available globally (not tied to current project) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: vitodeploy/vito
Length of output: 31471
🏁 Script executed:
Repository: vitodeploy/vito
Length of output: 20312
Security Misconfiguration
Reachability: External
Exploitability: Moderate
CWE: CWE-284
Make UFW mandatory before exposing the Lightsail perimeter.
Lightsail::isRunning()opens ports 0–65535 to0.0.0.0/0before installation starts.InstallServer::install()then installs services in the supplied order, so another service can run before UFW. If UFW is omitted or fails beforeufw --force enable,InstallJob::failed()records the failure but does not close the Lightsail ports or delete the instance. Require UFW as the first successful installation step, or restrict the Lightsail rule to SSH until UFW is enabled. Do not replace this with fixed provider ports because UFW manages dynamic service rules.🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 15-249: The class Lightsail has 11 public methods. Consider refactoring Lightsail to keep number of public methods under 10. (undefined)
(TooManyPublicMethods)
🤖 Prompt for AI Agents