init of cloud providers
This commit is contained in:
parent
b803a137f6
commit
c1bcc41546
13 changed files with 548 additions and 2 deletions
119
app/Livewire/Security/CloudProviderTokens.php
Normal file
119
app/Livewire/Security/CloudProviderTokens.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudProviderTokens extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $tokens;
|
||||
|
||||
public string $provider = 'hetzner';
|
||||
|
||||
public string $token = '';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'provider.required' => 'Please select a cloud provider.',
|
||||
'provider.in' => 'Invalid cloud provider selected.',
|
||||
'token.required' => 'API token is required.',
|
||||
'name.required' => 'Token name is required.',
|
||||
];
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
{
|
||||
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
|
||||
}
|
||||
|
||||
private function validateToken(string $provider, string $token): bool
|
||||
{
|
||||
try {
|
||||
if ($provider === 'hetzner') {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
// Add other providers here in the future
|
||||
// if ($provider === 'digitalocean') { ... }
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function addNewToken()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
$this->authorize('create', CloudProviderToken::class);
|
||||
|
||||
// Validate the token with the provider's API
|
||||
if (! $this->validateToken($this->provider, $this->token)) {
|
||||
return $this->dispatch('error', 'Invalid API token. Please check your token and try again.');
|
||||
}
|
||||
|
||||
CloudProviderToken::create([
|
||||
'team_id' => currentTeam()->id,
|
||||
'provider' => $this->provider,
|
||||
'token' => $this->token,
|
||||
'name' => $this->name,
|
||||
]);
|
||||
|
||||
$this->reset(['token', 'name']);
|
||||
$this->loadTokens();
|
||||
|
||||
$this->dispatch('success', 'Cloud provider token added successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId)
|
||||
{
|
||||
try {
|
||||
$token = CloudProviderToken::findOrFail($tokenId);
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
$token->delete();
|
||||
$this->loadTokens();
|
||||
|
||||
$this->dispatch('success', 'Cloud provider token deleted successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-provider-tokens');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Security/CloudTokens.php
Normal file
13
app/Livewire/Security/CloudTokens.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudTokens extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.cloud-tokens');
|
||||
}
|
||||
}
|
||||
128
app/Livewire/Server/New/ByHetzner.php
Normal file
128
app/Livewire/Server/New/ByHetzner.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class ByHetzner extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
#[Locked]
|
||||
public Collection $available_tokens;
|
||||
|
||||
#[Locked]
|
||||
public $private_keys;
|
||||
|
||||
#[Locked]
|
||||
public $limit_reached;
|
||||
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public string $hetzner_token = '';
|
||||
|
||||
public bool $save_token = false;
|
||||
|
||||
public ?string $token_name = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hetzner')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'selected_token_id' => 'nullable|integer',
|
||||
'hetzner_token' => 'required_without:selected_token_id|string',
|
||||
'save_token' => 'boolean',
|
||||
'token_name' => 'required_if:save_token,true|nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'hetzner_token.required_without' => 'Please provide a Hetzner API token or select a saved token.',
|
||||
'token_name.required_if' => 'Please provide a name for the token.',
|
||||
];
|
||||
}
|
||||
|
||||
public function selectToken(int $tokenId)
|
||||
{
|
||||
$this->selected_token_id = $tokenId;
|
||||
}
|
||||
|
||||
private function validateHetznerToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
|
||||
return $response->successful();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
if (Team::serverLimitReached()) {
|
||||
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
|
||||
}
|
||||
|
||||
// Determine which token to use
|
||||
if ($this->selected_token_id) {
|
||||
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
|
||||
if (! $token) {
|
||||
return $this->dispatch('error', 'Selected token not found.');
|
||||
}
|
||||
$hetznerToken = $token->token;
|
||||
} else {
|
||||
$hetznerToken = $this->hetzner_token;
|
||||
|
||||
// Validate the new token before saving
|
||||
if (! $this->validateHetznerToken($hetznerToken)) {
|
||||
return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.');
|
||||
}
|
||||
|
||||
// If saving the new token
|
||||
if ($this->save_token) {
|
||||
CloudProviderToken::create([
|
||||
'team_id' => currentTeam()->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => $this->hetzner_token,
|
||||
'name' => $this->token_name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Actual Hetzner server provisioning will be implemented in future phase
|
||||
// The $hetznerToken variable contains the token to use
|
||||
return $this->dispatch('success', 'Hetzner token validated successfully! Server provisioning coming soon.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.new.by-hetzner');
|
||||
}
|
||||
}
|
||||
31
app/Models/CloudProviderToken.php
Normal file
31
app/Models/CloudProviderToken.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CloudProviderToken extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'token' => 'encrypted',
|
||||
];
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam(array $select = ['*'])
|
||||
{
|
||||
$selectArray = collect($select)->concat(['id']);
|
||||
|
||||
return self::whereTeamId(currentTeam()->id)->select($selectArray->all());
|
||||
}
|
||||
|
||||
public function scopeForProvider($query, string $provider)
|
||||
{
|
||||
return $query->where('provider', $provider);
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +258,11 @@ public function privateKeys()
|
|||
return $this->hasMany(PrivateKey::class);
|
||||
}
|
||||
|
||||
public function cloudProviderTokens()
|
||||
{
|
||||
return $this->hasMany(CloudProviderToken::class);
|
||||
}
|
||||
|
||||
public function sources()
|
||||
{
|
||||
$sources = collect([]);
|
||||
|
|
|
|||
65
app/Policies/CloudProviderTokenPolicy.php
Normal file
65
app/Policies/CloudProviderTokenPolicy.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\User;
|
||||
|
||||
class CloudProviderTokenPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, CloudProviderToken $cloudProviderToken): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cloud_provider_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id')->constrained()->onDelete('cascade');
|
||||
$table->string('provider');
|
||||
$table->text('token');
|
||||
$table->string('name')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['team_id', 'provider']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cloud_provider_tokens');
|
||||
}
|
||||
};
|
||||
|
|
@ -6,8 +6,13 @@
|
|||
<a href="{{ route('security.private-key.index') }}">
|
||||
<button>Private Keys</button>
|
||||
</a>
|
||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<a href="{{ route('security.cloud-tokens') }}">
|
||||
<button>Cloud Tokens</button>
|
||||
</a>
|
||||
@endcan
|
||||
<a href="{{ route('security.api-tokens') }}">
|
||||
<button>API tokens</button>
|
||||
<button>API Tokens</button>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
<div>
|
||||
<h2>Cloud Provider Tokens</h2>
|
||||
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.). Tokens are saved encrypted and shared with your team.</div>
|
||||
|
||||
<h3>New Token</h3>
|
||||
@can('create', App\Models\CloudProviderToken::class)
|
||||
<form class="flex flex-col gap-2" wire:submit='addNewToken'>
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div class="w-64">
|
||||
<x-forms.select required id="provider" label="Provider">
|
||||
<option value="hetzner">Hetzner</option>
|
||||
<option value="digitalocean">DigitalOcean</option>
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production Hetzner" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
|
||||
</div>
|
||||
<x-forms.button type="submit">Add Token</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
@endcan
|
||||
|
||||
<h3 class="py-4">Saved Tokens</h3>
|
||||
<div class="grid gap-2 lg:grid-cols-1">
|
||||
@forelse ($tokens as $savedToken)
|
||||
<div wire:key="token-{{ $savedToken->id }}"
|
||||
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs font-bold rounded dark:bg-coolgray-300 dark:text-white">
|
||||
{{ strtoupper($savedToken->provider) }}
|
||||
</span>
|
||||
<span class="font-bold dark:text-white">{{ $savedToken->name }}</span>
|
||||
</div>
|
||||
<div class="text-sm">Token: ***{{ substr($savedToken->token, -4) }}</div>
|
||||
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
|
||||
|
||||
@can('delete', $savedToken)
|
||||
<x-modal-confirmation
|
||||
title="Confirm Token Deletion?"
|
||||
isErrorButton
|
||||
buttonTitle="Delete Token"
|
||||
submitAction="deleteToken({{ $savedToken->id }})"
|
||||
:actions="[
|
||||
'This cloud provider token will be permanently deleted.',
|
||||
'Any servers using this token will need to be reconfigured.',
|
||||
]"
|
||||
confirmationText="{{ $savedToken->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the token name below"
|
||||
shortConfirmationLabel="Token Name"
|
||||
:confirmWithPassword="false"
|
||||
step2ButtonText="Delete Token"
|
||||
/>
|
||||
@endcan
|
||||
</div>
|
||||
@empty
|
||||
<div>
|
||||
<div>No cloud provider tokens found.</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
7
resources/views/livewire/security/cloud-tokens.blade.php
Normal file
7
resources/views/livewire/security/cloud-tokens.blade.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
Cloud Tokens | Coolify
|
||||
</x-slot>
|
||||
<x-security.navbar />
|
||||
<livewire:security.cloud-provider-tokens />
|
||||
</div>
|
||||
|
|
@ -1,3 +1,23 @@
|
|||
<div class="w-full">
|
||||
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||
<div class="flex flex-col gap-4">
|
||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<div>
|
||||
<h3 class="pb-2">Add Server from Cloud Provider</h3>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<x-modal-input
|
||||
buttonTitle="+ Hetzner"
|
||||
title="Connect to Hetzner">
|
||||
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t dark:border-coolgray-300 my-4"></div>
|
||||
@endcan
|
||||
|
||||
<div>
|
||||
<h3 class="pb-2">Add Server by IP Address</h3>
|
||||
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
52
resources/views/livewire/server/new/by-hetzner.blade.php
Normal file
52
resources/views/livewire/server/new/by-hetzner.blade.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<div class="w-full">
|
||||
@if ($limit_reached)
|
||||
<x-limit-reached name="servers" />
|
||||
@else
|
||||
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
|
||||
@if ($available_tokens->count() > 0)
|
||||
<div>
|
||||
<x-forms.select label="Use Saved Token" id="selected_token_id" wire:change="selectToken($event.target.value)">
|
||||
<option value="">Select a saved token...</option>
|
||||
@foreach ($available_tokens as $token)
|
||||
<option value="{{ $token->id }}">
|
||||
{{ $token->name ?? 'Hetzner Token' }} (***{{ substr($token->token, -4) }})
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="text-center text-sm dark:text-neutral-500 py-2">OR</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<x-forms.input
|
||||
type="password"
|
||||
id="hetzner_token"
|
||||
label="New Hetzner API Token"
|
||||
helper="Your Hetzner Cloud API token. You can create one in your <a href='https://console.hetzner.cloud/' target='_blank' class='underline dark:text-white'>Hetzner Cloud Console</a>."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.checkbox
|
||||
id="save_token"
|
||||
label="Save this token for my team"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if ($save_token)
|
||||
<div>
|
||||
<x-forms.input
|
||||
id="token_name"
|
||||
label="Token Name"
|
||||
placeholder="e.g., Production Hetzner"
|
||||
helper="Give this token a friendly name to identify it later."
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-forms.button canGate="create" :canResource="App\Models\Server::class" type="submit">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow;
|
||||
use App\Livewire\Project\Show as ProjectShow;
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Livewire\Security\CloudTokens;
|
||||
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
|
||||
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
|
||||
use App\Livewire\Server\Advanced as ServerAdvanced;
|
||||
|
|
@ -271,6 +272,7 @@
|
|||
// Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create');
|
||||
Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show');
|
||||
|
||||
Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens');
|
||||
Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue