2023-03-24 14:47:58 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
2025-08-19 10:14:48 +00:00
|
|
|
use App\Traits\HasSafeStringAttribute;
|
2024-09-20 10:27:55 +00:00
|
|
|
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
|
2025-09-09 14:46:38 +00:00
|
|
|
use Illuminate\Support\Facades\DB;
|
2024-09-16 15:24:42 +00:00
|
|
|
use Illuminate\Support\Facades\Storage;
|
2024-09-16 11:02:48 +00:00
|
|
|
use Illuminate\Validation\ValidationException;
|
2024-09-20 10:27:55 +00:00
|
|
|
use OpenApi\Attributes as OA;
|
2024-09-16 15:24:42 +00:00
|
|
|
use phpseclib3\Crypt\PublicKeyLoader;
|
2023-06-07 13:08:35 +00:00
|
|
|
|
2024-07-09 08:45:10 +00:00
|
|
|
#[OA\Schema(
|
|
|
|
|
description: 'Private Key model',
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: [
|
|
|
|
|
'id' => ['type' => 'integer'],
|
|
|
|
|
'uuid' => ['type' => 'string'],
|
|
|
|
|
'name' => ['type' => 'string'],
|
|
|
|
|
'description' => ['type' => 'string'],
|
|
|
|
|
'private_key' => ['type' => 'string', 'format' => 'private-key'],
|
2025-04-23 09:59:01 +00:00
|
|
|
'public_key' => ['type' => 'string', 'description' => 'The public key of the private key.'],
|
|
|
|
|
'fingerprint' => ['type' => 'string', 'description' => 'The fingerprint of the private key.'],
|
2024-07-09 08:45:10 +00:00
|
|
|
'is_git_related' => ['type' => 'boolean'],
|
|
|
|
|
'team_id' => ['type' => 'integer'],
|
|
|
|
|
'created_at' => ['type' => 'string'],
|
|
|
|
|
'updated_at' => ['type' => 'string'],
|
|
|
|
|
],
|
|
|
|
|
)]
|
2023-03-24 14:47:58 +00:00
|
|
|
class PrivateKey extends BaseModel
|
|
|
|
|
{
|
2025-08-19 10:14:48 +00:00
|
|
|
use HasSafeStringAttribute, WithRateLimiting;
|
2024-09-16 15:24:42 +00:00
|
|
|
|
2023-04-26 13:38:50 +00:00
|
|
|
protected $fillable = [
|
|
|
|
|
'name',
|
|
|
|
|
'description',
|
|
|
|
|
'private_key',
|
2023-06-19 08:58:00 +00:00
|
|
|
'is_git_related',
|
2023-04-26 13:38:50 +00:00
|
|
|
'team_id',
|
2024-09-17 11:20:27 +00:00
|
|
|
'fingerprint',
|
2023-04-26 13:38:50 +00:00
|
|
|
];
|
2023-08-08 09:51:36 +00:00
|
|
|
|
2025-01-07 14:31:43 +00:00
|
|
|
protected $casts = [
|
|
|
|
|
'private_key' => 'encrypted',
|
|
|
|
|
];
|
|
|
|
|
|
2025-01-20 12:57:40 +00:00
|
|
|
protected $appends = ['public_key'];
|
|
|
|
|
|
2024-07-05 11:35:51 +00:00
|
|
|
protected static function booted()
|
|
|
|
|
{
|
|
|
|
|
static::saving(function ($key) {
|
2024-09-17 12:47:02 +00:00
|
|
|
$key->private_key = formatPrivateKey($key->private_key);
|
2024-09-20 10:27:55 +00:00
|
|
|
|
|
|
|
|
if (! self::validatePrivateKey($key->private_key)) {
|
2024-09-16 11:02:48 +00:00
|
|
|
throw ValidationException::withMessages([
|
|
|
|
|
'private_key' => ['The private key is invalid.'],
|
|
|
|
|
]);
|
|
|
|
|
}
|
2024-09-17 11:20:27 +00:00
|
|
|
|
|
|
|
|
$key->fingerprint = self::generateFingerprint($key->private_key);
|
|
|
|
|
if (self::fingerprintExists($key->fingerprint, $key->id)) {
|
|
|
|
|
throw ValidationException::withMessages([
|
|
|
|
|
'private_key' => ['This private key already exists.'],
|
|
|
|
|
]);
|
|
|
|
|
}
|
2024-09-16 15:24:42 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
static::deleted(function ($key) {
|
|
|
|
|
self::deleteFromStorage($key);
|
2024-07-05 11:35:51 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-20 12:57:40 +00:00
|
|
|
public function getPublicKeyAttribute()
|
|
|
|
|
{
|
|
|
|
|
return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key';
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
public function getPublicKey()
|
|
|
|
|
{
|
|
|
|
|
return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key';
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 12:39:33 +00:00
|
|
|
/**
|
|
|
|
|
* Get query builder for private keys owned by current team.
|
|
|
|
|
* If you need all private keys without further query chaining, use ownedByCurrentTeamCached() instead.
|
|
|
|
|
*/
|
2024-06-10 20:43:34 +00:00
|
|
|
public static function ownedByCurrentTeam(array $select = ['*'])
|
2023-06-07 13:08:35 +00:00
|
|
|
{
|
2025-10-17 21:04:24 +00:00
|
|
|
$teamId = currentTeam()->id;
|
2023-06-16 10:05:52 +00:00
|
|
|
$selectArray = collect($select)->concat(['id']);
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2025-10-17 21:04:24 +00:00
|
|
|
return self::whereTeamId($teamId)->select($selectArray->all());
|
2024-09-16 15:24:42 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-08 12:39:33 +00:00
|
|
|
/**
|
|
|
|
|
* Get all private keys owned by current team (cached for request duration).
|
|
|
|
|
*/
|
|
|
|
|
public static function ownedByCurrentTeamCached()
|
|
|
|
|
{
|
|
|
|
|
return once(function () {
|
|
|
|
|
return PrivateKey::ownedByCurrentTeam()->get();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-23 14:18:20 +00:00
|
|
|
public static function ownedAndOnlySShKeys(array $select = ['*'])
|
|
|
|
|
{
|
|
|
|
|
$teamId = currentTeam()->id;
|
|
|
|
|
$selectArray = collect($select)->concat(['id']);
|
|
|
|
|
|
|
|
|
|
return self::whereTeamId($teamId)
|
|
|
|
|
->where('is_git_related', false)
|
|
|
|
|
->select($selectArray->all());
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
public static function validatePrivateKey($privateKey)
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
PublicKeyLoader::load($privateKey);
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
return true;
|
2025-01-07 14:31:43 +00:00
|
|
|
} catch (\Throwable $e) {
|
2024-09-16 15:24:42 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-06-10 20:43:34 +00:00
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
public static function createAndStore(array $data)
|
|
|
|
|
{
|
2025-09-09 14:46:38 +00:00
|
|
|
return DB::transaction(function () use ($data) {
|
|
|
|
|
$privateKey = new self($data);
|
|
|
|
|
$privateKey->save();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$privateKey->storeInFileSystem();
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
throw new \Exception('Failed to store SSH key: '.$e->getMessage());
|
|
|
|
|
}
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2025-09-09 14:46:38 +00:00
|
|
|
return $privateKey;
|
|
|
|
|
});
|
2024-09-16 15:24:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function generateNewKeyPair($type = 'rsa')
|
2023-09-11 08:15:45 +00:00
|
|
|
{
|
|
|
|
|
try {
|
2024-09-20 10:27:55 +00:00
|
|
|
$instance = new self;
|
2024-09-16 15:24:42 +00:00
|
|
|
$instance->rateLimit(10);
|
|
|
|
|
$name = generate_random_name();
|
|
|
|
|
$description = 'Created by Coolify';
|
2024-09-17 12:47:02 +00:00
|
|
|
$keyPair = generateSSHKey($type === 'ed25519' ? 'ed25519' : 'rsa');
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
return [
|
|
|
|
|
'name' => $name,
|
|
|
|
|
'description' => $description,
|
2024-09-17 12:47:02 +00:00
|
|
|
'private_key' => $keyPair['private'],
|
|
|
|
|
'public_key' => $keyPair['public'],
|
2024-09-16 15:24:42 +00:00
|
|
|
];
|
2025-01-07 14:31:43 +00:00
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
throw new \Exception("Failed to generate new {$type} key: ".$e->getMessage());
|
2023-09-11 08:15:45 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
public static function extractPublicKeyFromPrivate($privateKey)
|
2023-08-08 09:51:36 +00:00
|
|
|
{
|
2024-09-16 15:24:42 +00:00
|
|
|
try {
|
|
|
|
|
$key = PublicKeyLoader::load($privateKey);
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
return $key->getPublicKey()->toString('OpenSSH', ['comment' => '']);
|
2025-01-07 14:31:43 +00:00
|
|
|
} catch (\Throwable $e) {
|
2024-09-16 15:24:42 +00:00
|
|
|
return null;
|
2023-08-08 09:51:36 +00:00
|
|
|
}
|
2024-09-16 15:24:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function validateAndExtractPublicKey($privateKey)
|
|
|
|
|
{
|
|
|
|
|
$isValid = self::validatePrivateKey($privateKey);
|
|
|
|
|
$publicKey = $isValid ? self::extractPublicKeyFromPrivate($privateKey) : '';
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
return [
|
|
|
|
|
'isValid' => $isValid,
|
|
|
|
|
'publicKey' => $publicKey,
|
|
|
|
|
];
|
|
|
|
|
}
|
2024-06-10 20:43:34 +00:00
|
|
|
|
2024-09-16 15:24:42 +00:00
|
|
|
public function storeInFileSystem()
|
|
|
|
|
{
|
2024-09-17 14:22:53 +00:00
|
|
|
$filename = "ssh_key@{$this->uuid}";
|
2025-09-09 14:46:38 +00:00
|
|
|
$disk = Storage::disk('ssh-keys');
|
|
|
|
|
|
|
|
|
|
// Ensure the storage directory exists and is writable
|
|
|
|
|
$this->ensureStorageDirectoryExists();
|
|
|
|
|
|
|
|
|
|
// Attempt to store the private key
|
|
|
|
|
$success = $disk->put($filename, $this->private_key);
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2025-09-09 14:46:38 +00:00
|
|
|
if (! $success) {
|
|
|
|
|
throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify the file was actually created and has content
|
|
|
|
|
if (! $disk->exists($filename)) {
|
|
|
|
|
throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$storedContent = $disk->get($filename);
|
|
|
|
|
if (empty($storedContent) || $storedContent !== $this->private_key) {
|
|
|
|
|
$disk->delete($filename); // Clean up the bad file
|
|
|
|
|
throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->getKeyLocation();
|
2024-09-16 15:24:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function deleteFromStorage(self $privateKey)
|
2024-09-20 10:27:55 +00:00
|
|
|
{
|
2024-09-17 14:22:53 +00:00
|
|
|
$filename = "ssh_key@{$privateKey->uuid}";
|
2025-09-09 14:46:38 +00:00
|
|
|
$disk = Storage::disk('ssh-keys');
|
|
|
|
|
|
|
|
|
|
if ($disk->exists($filename)) {
|
|
|
|
|
$disk->delete($filename);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function ensureStorageDirectoryExists()
|
|
|
|
|
{
|
|
|
|
|
$disk = Storage::disk('ssh-keys');
|
|
|
|
|
$directoryPath = '';
|
|
|
|
|
|
|
|
|
|
if (! $disk->exists($directoryPath)) {
|
|
|
|
|
$success = $disk->makeDirectory($directoryPath);
|
|
|
|
|
if (! $success) {
|
|
|
|
|
throw new \Exception('Failed to create SSH keys storage directory');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if directory is writable by attempting a test file
|
|
|
|
|
$testFilename = '.test_write_'.uniqid();
|
|
|
|
|
$testSuccess = $disk->put($testFilename, 'test');
|
|
|
|
|
|
|
|
|
|
if (! $testSuccess) {
|
2026-02-26 15:27:02 +00:00
|
|
|
throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
|
2025-09-09 14:46:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clean up test file
|
|
|
|
|
$disk->delete($testFilename);
|
2024-09-16 15:24:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getKeyLocation()
|
|
|
|
|
{
|
2024-09-17 14:22:53 +00:00
|
|
|
return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}";
|
2024-09-16 15:24:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function updatePrivateKey(array $data)
|
|
|
|
|
{
|
2025-09-09 14:46:38 +00:00
|
|
|
return DB::transaction(function () use ($data) {
|
|
|
|
|
$this->update($data);
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2025-09-09 14:46:38 +00:00
|
|
|
try {
|
|
|
|
|
$this->storeInFileSystem();
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
throw new \Exception('Failed to update SSH key: '.$e->getMessage());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
|
});
|
2023-08-08 09:51:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function servers()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Server::class);
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-19 07:44:39 +00:00
|
|
|
public function applications()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Application::class);
|
|
|
|
|
}
|
2023-08-08 09:51:36 +00:00
|
|
|
|
2023-06-19 07:44:39 +00:00
|
|
|
public function githubApps()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(GithubApp::class);
|
|
|
|
|
}
|
2023-08-08 09:51:36 +00:00
|
|
|
|
2023-06-19 07:44:39 +00:00
|
|
|
public function gitlabApps()
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(GitlabApp::class);
|
|
|
|
|
}
|
2024-09-16 10:54:48 +00:00
|
|
|
|
2024-09-17 11:06:50 +00:00
|
|
|
public function isInUse()
|
|
|
|
|
{
|
2025-01-07 14:31:43 +00:00
|
|
|
return $this->servers()->exists()
|
|
|
|
|
|| $this->applications()->exists()
|
|
|
|
|
|| $this->githubApps()->exists()
|
|
|
|
|
|| $this->gitlabApps()->exists();
|
2024-09-17 11:06:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function safeDelete()
|
|
|
|
|
{
|
2024-09-20 10:27:55 +00:00
|
|
|
if (! $this->isInUse()) {
|
2024-09-17 12:43:02 +00:00
|
|
|
$this->delete();
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2024-09-17 12:43:02 +00:00
|
|
|
return true;
|
2024-09-17 11:06:50 +00:00
|
|
|
}
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2024-09-17 12:43:02 +00:00
|
|
|
return false;
|
2024-09-17 11:06:50 +00:00
|
|
|
}
|
2024-09-17 11:20:27 +00:00
|
|
|
|
|
|
|
|
public static function generateFingerprint($privateKey)
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$key = PublicKeyLoader::load($privateKey);
|
2024-09-20 10:27:55 +00:00
|
|
|
|
2025-01-20 12:57:40 +00:00
|
|
|
return $key->getPublicKey()->getFingerprint('sha256');
|
2025-01-07 14:31:43 +00:00
|
|
|
} catch (\Throwable $e) {
|
2024-09-17 11:20:27 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 08:41:29 +00:00
|
|
|
public static function generateMd5Fingerprint($privateKey)
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$key = PublicKeyLoader::load($privateKey);
|
|
|
|
|
|
|
|
|
|
return $key->getPublicKey()->getFingerprint('md5');
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-20 12:57:40 +00:00
|
|
|
public static function fingerprintExists($fingerprint, $excludeId = null)
|
2024-09-17 11:20:27 +00:00
|
|
|
{
|
2025-01-07 14:31:43 +00:00
|
|
|
$query = self::query()
|
2024-11-15 20:22:11 +00:00
|
|
|
->where('fingerprint', $fingerprint)
|
|
|
|
|
->where('id', '!=', $excludeId);
|
2024-11-15 11:10:39 +00:00
|
|
|
|
|
|
|
|
if (currentTeam()) {
|
2025-01-07 14:31:43 +00:00
|
|
|
$query->where('team_id', currentTeam()->id);
|
2024-11-15 11:10:39 +00:00
|
|
|
}
|
|
|
|
|
|
2025-01-07 14:31:43 +00:00
|
|
|
return $query->exists();
|
2024-09-17 11:20:27 +00:00
|
|
|
}
|
2024-09-19 17:27:25 +00:00
|
|
|
|
|
|
|
|
public static function cleanupUnusedKeys()
|
|
|
|
|
{
|
2024-09-20 11:05:51 +00:00
|
|
|
self::ownedByCurrentTeam()->each(function ($privateKey) {
|
2024-09-19 17:27:25 +00:00
|
|
|
$privateKey->safeDelete();
|
|
|
|
|
});
|
|
|
|
|
}
|
2023-03-24 14:47:58 +00:00
|
|
|
}
|