diff --git a/app/Http/Controllers/Api/RootCaApiController.php b/app/Http/Controllers/Api/RootCaApiController.php index f7ce5e4..ec0999e 100644 --- a/app/Http/Controllers/Api/RootCaApiController.php +++ b/app/Http/Controllers/Api/RootCaApiController.php @@ -40,17 +40,35 @@ class RootCaApiController extends Controller try { $newData = $this->sslService->renewCaCertificate($certificate, $days); - $certificate->update([ + // 1. Unset 'is_latest' from all versions of this CA type/name + CaCertificate::where('ca_type', $certificate->ca_type) + ->where('common_name', $certificate->common_name) + ->update(['is_latest' => false]); + + // 2. Create NEW version record + $newCertificate = CaCertificate::create([ + 'ca_type' => $certificate->ca_type, + 'common_name' => $certificate->common_name, + 'organization' => $certificate->organization, + 'key_content' => $certificate->key_content, // Keep same private key for renewal 'cert_content' => $newData['cert_content'], 'serial_number' => $newData['serial_number'], 'valid_from' => $newData['valid_from'], 'valid_to' => $newData['valid_to'], + 'is_latest' => true, ]); + // 3. Automatically sync the new version to CDN (Both latest and archive locations) + $this->sslService->uploadPublicCertsOnly($newCertificate, 'both'); + $this->sslService->uploadIndividualInstallersOnly($newCertificate, 'both'); + + // 4. Update bundles + $this->sslService->syncAllBundles(); + return response()->json([ 'status' => 'success', - 'message' => 'Certificate renewed successfully.', - 'data' => $certificate + 'message' => 'Certificate renewed as a new version successfully.', + 'data' => $newCertificate ]); } catch (\Exception $e) { return response()->json([ @@ -60,35 +78,37 @@ class RootCaApiController extends Controller } } - public function syncCrtOnly() + public function syncCrtOnly(Request $request) { $this->authorizeAdminOrOwner(); try { + $mode = $request->input('mode', 'both'); $certificates = CaCertificate::all(); $count = 0; foreach ($certificates as $cert) { - if ($this->sslService->uploadPublicCertsOnly($cert)) { + if ($this->sslService->uploadPublicCertsOnly($cert, $mode)) { $count++; } } - return response()->json(['status' => 'success', 'message' => "Successfully synced {$count} CRT files."]); + return response()->json(['status' => 'success', 'message' => "Successfully synced {$count} CRT files (Mode: {$mode})."]); } catch (\Exception $e) { return response()->json(['status' => 'error', 'message' => 'Sync failed: ' . $e->getMessage()], 500); } } - public function syncInstallersOnly() + public function syncInstallersOnly(Request $request) { $this->authorizeAdminOrOwner(); try { + $mode = $request->input('mode', 'both'); $certificates = CaCertificate::all(); $count = 0; foreach ($certificates as $cert) { - if ($this->sslService->uploadIndividualInstallersOnly($cert)) { + if ($this->sslService->uploadIndividualInstallersOnly($cert, $mode)) { $count++; } } - return response()->json(['status' => 'success', 'message' => "Successfully synced {$count} installer sets."]); + return response()->json(['status' => 'success', 'message' => "Successfully synced {$count} installer sets (Mode: {$mode})."]); } catch (\Exception $e) { return response()->json(['status' => 'error', 'message' => 'Sync failed: ' . $e->getMessage()], 500); } @@ -107,23 +127,28 @@ class RootCaApiController extends Controller } } - public function syncToCdn() + public function syncToCdn(Request $request) { $this->authorizeAdminOrOwner(); + $mode = $request->input('mode', 'both'); try { $certificates = CaCertificate::all(); $count = 0; foreach ($certificates as $cert) { - if ($this->sslService->uploadToCdn($cert)) { + if ($this->sslService->uploadPublicCertsOnly($cert, $mode)) { + $this->sslService->uploadIndividualInstallersOnly($cert, $mode); $count++; } } + + // Also sync bundles (Always 'latest' as bundles are aggregate) + $this->sslService->syncAllBundles(); return response()->json([ 'status' => 'success', - 'message' => "Successfully synced everything ({$count} certs + bundles) to CDN." + 'message' => "Successfully synced everything ({$count} certs + bundles) to CDN (Mode: {$mode})." ]); } catch (\Exception $e) { return response()->json([ @@ -133,6 +158,27 @@ class RootCaApiController extends Controller } } + public function promote(CaCertificate $certificate) + { + $this->authorizeAdminOrOwner(); + try { + // 1. Unset 'is_latest' from all versions of this CA type/name + CaCertificate::where('ca_type', $certificate->ca_type) + ->where('common_name', $certificate->common_name) + ->update(['is_latest' => false]); + + // 2. Set this one as latest + $certificate->update(['is_latest' => true]); + + // 3. Promote on CDN + $this->sslService->promoteToLatest($certificate); + + return response()->json(['status' => 'success', 'message' => "Certificate version {$certificate->uuid} promoted to Latest successfully."]); + } catch (\Exception $e) { + return response()->json(['status' => 'error', 'message' => 'Promotion failed: ' . $e->getMessage()], 500); + } + } + protected function authorizeAdminOrOwner() { if (!auth()->user()->isAdminOrOwner()) { diff --git a/app/Models/CaCertificate.php b/app/Models/CaCertificate.php index 28a9073..55e5789 100644 --- a/app/Models/CaCertificate.php +++ b/app/Models/CaCertificate.php @@ -23,6 +23,7 @@ class CaCertificate extends Model 'organization', 'valid_from', 'valid_to', + 'is_latest', 'cert_path', 'der_path', 'bat_path', @@ -36,6 +37,7 @@ class CaCertificate extends Model protected $casts = [ 'valid_from' => 'datetime', 'valid_to' => 'datetime', + 'is_latest' => 'boolean', 'last_synced_at' => 'datetime', 'last_downloaded_at' => 'datetime', ]; diff --git a/app/Services/OpenSslService.php b/app/Services/OpenSslService.php index 5a56a74..7126ca7 100644 --- a/app/Services/OpenSslService.php +++ b/app/Services/OpenSslService.php @@ -68,6 +68,7 @@ class OpenSslService 'organization' => $rootDetails['subject']['O'] ?? null, 'valid_from' => date('Y-m-d H:i:s', $rootDetails['validFrom_time_t']), 'valid_to' => date('Y-m-d H:i:s', $rootDetails['validTo_time_t']), + 'is_latest' => true, ]); $this->uploadToCdn($ca); @@ -107,6 +108,7 @@ class OpenSslService 'organization' => $int4096Details['subject']['O'] ?? null, 'valid_from' => date('Y-m-d H:i:s', $int4096Details['validFrom_time_t']), 'valid_to' => date('Y-m-d H:i:s', $int4096Details['validTo_time_t']), + 'is_latest' => true, ]); $this->uploadToCdn($ca4096); @@ -146,6 +148,7 @@ class OpenSslService 'organization' => $int2048Details['subject']['O'] ?? null, 'valid_from' => date('Y-m-d H:i:s', $int2048Details['validFrom_time_t']), 'valid_to' => date('Y-m-d H:i:s', $int2048Details['validTo_time_t']), + 'is_latest' => true, ]); $this->uploadToCdn($ca2048); @@ -414,9 +417,15 @@ class OpenSslService /** * Generate Windows Installer (.bat) */ - public function generateWindowsInstaller(CaCertificate $cert): string + public function generateWindowsInstaller(CaCertificate $cert, bool $isArchive = false): string { - $cdnUrl = $cert->cert_path ? Storage::disk('r2-public')->url($cert->cert_path) : url("/api/public/ca/{$cert->uuid}/download/pem"); + $slug = Str::slug($cert->common_name); + if ($isArchive) { + $cdnUrl = Storage::disk('r2-public')->url("ca/archives/{$cert->uuid}/{$slug}.crt"); + } else { + $cdnUrl = Storage::disk('r2-public')->url("ca/{$slug}.crt"); + } + $typeLabel = $cert->ca_type === 'root' ? 'Root' : 'Intermediate'; $store = $cert->ca_type === 'root' ? 'Root' : 'CA'; @@ -496,10 +505,16 @@ class OpenSslService /** * Generate Linux Installer (.sh) */ - public function generateLinuxInstaller(CaCertificate $cert): string + public function generateLinuxInstaller(CaCertificate $cert, bool $isArchive = false): string { - $cdnUrl = $cert->cert_path ? Storage::disk('r2-public')->url($cert->cert_path) : url("/api/public/ca/{$cert->uuid}/download/pem"); - $filename = "trustlab-" . Str::slug($cert->common_name) . ".crt"; + $slug = Str::slug($cert->common_name); + if ($isArchive) { + $cdnUrl = Storage::disk('r2-public')->url("ca/archives/{$cert->uuid}/{$slug}.crt"); + } else { + $cdnUrl = Storage::disk('r2-public')->url("ca/{$slug}.crt"); + } + + $filename = "trustlab-" . $slug . ".crt"; return "#!/bin/bash\n" . "echo \"TrustLab - Installing CA Certificate: {$cert->common_name}\"\n" . @@ -544,36 +559,50 @@ class OpenSslService /** * Upload only PEM/DER (The CRT files) to CDN. */ - public function uploadPublicCertsOnly(CaCertificate $cert) + public function uploadPublicCertsOnly(CaCertificate $cert, string $mode = 'both') { - $baseFilename = 'ca/' . Str::slug($cert->common_name) . '-' . $cert->uuid; - $pemFilename = $baseFilename . '.crt'; - $derFilename = $baseFilename . '.der'; - - // 1. Upload PEM (.crt) - Storage::disk('r2-public')->put($pemFilename, $cert->cert_content, [ - 'visibility' => 'public', - 'ContentType' => 'application/x-x509-ca-cert' - ]); - - // 2. Convert to DER and Upload (.der) - $lines = explode("\n", trim($cert->cert_content)); - $payload = ''; - foreach ($lines as $line) { - if (!str_starts_with($line, '-----')) { - $payload .= trim($line); - } - } - $derContent = base64_decode($payload); + $slug = Str::slug($cert->common_name); + $paths = []; - Storage::disk('r2-public')->put($derFilename, $derContent, [ - 'visibility' => 'public', - 'ContentType' => 'application/x-x509-ca-cert' - ]); + if ($mode === 'archive' || $mode === 'both') { + $paths[] = "ca/archives/{$cert->uuid}/{$slug}"; + } + if ($mode === 'latest' || $mode === 'both') { + $paths[] = "ca/{$slug}"; + } + foreach ($paths as $basePath) { + $pemPath = $basePath . '.crt'; + $derPath = $basePath . '.der'; + + // 1. Upload PEM (.crt) + Storage::disk('r2-public')->put($pemPath, $cert->cert_content, [ + 'visibility' => 'public', + 'ContentType' => 'application/x-x509-ca-cert', + 'CacheControl' => 'no-cache, no-store, must-revalidate' + ]); + + // 2. Convert to DER and Upload (.der) + $lines = explode("\n", trim($cert->cert_content)); + $payload = ''; + foreach ($lines as $line) { + if (!str_starts_with($line, '-----')) { + $payload .= trim($line); + } + } + $derContent = base64_decode($payload); + + Storage::disk('r2-public')->put($derPath, $derContent, [ + 'visibility' => 'public', + 'ContentType' => 'application/x-x509-ca-cert', + 'CacheControl' => 'no-cache, no-store, must-revalidate' + ]); + } + + // Always point model paths to the 'latest' version for public UI $cert->update([ - 'cert_path' => $pemFilename, - 'der_path' => $derFilename, + 'cert_path' => "ca/{$slug}.crt", + 'der_path' => "ca/{$slug}.der", 'last_synced_at' => now() ]); @@ -583,52 +612,74 @@ class OpenSslService /** * Upload individual installers (SH, BAT, MAC) to CDN. */ - public function uploadIndividualInstallersOnly(CaCertificate $cert) + public function uploadIndividualInstallersOnly(CaCertificate $cert, string $mode = 'both') { - $baseFilename = 'ca/' . Str::slug($cert->common_name) . '-' . $cert->uuid; - $batFilename = $baseFilename . '.bat'; - $macFilename = $baseFilename . '.mobileconfig'; - $linuxFilename = $baseFilename . '.sh'; - + $slug = Str::slug($cert->common_name); $cacheControl = 'no-cache, no-store, must-revalidate'; + + $syncs = []; + if ($mode === 'archive' || $mode === 'both') { + $syncs[] = ['base' => "ca/archives/{$cert->uuid}/installers/trustlab-{$slug}", 'isArchive' => true]; + } + if ($mode === 'latest' || $mode === 'both') { + $syncs[] = ['base' => "ca/installers/trustlab-{$slug}", 'isArchive' => false]; + } - // 3. Generate and Upload Windows Installer (.bat) - $batContent = $this->generateWindowsInstaller($cert); - Storage::disk('r2-public')->delete($batFilename); - Storage::disk('r2-public')->put($batFilename, $batContent, [ - 'visibility' => 'public', - 'ContentType' => 'text/plain', - 'CacheControl' => $cacheControl - ]); + foreach ($syncs as $sync) { + $batPath = $sync['base'] . '.bat'; + $macPath = $sync['base'] . '.mobileconfig'; + $linuxPath = $sync['base'] . '.sh'; - // 4. Generate and Upload macOS Profile (.mobileconfig) - $macContent = $this->generateMacInstaller($cert); - Storage::disk('r2-public')->delete($macFilename); - Storage::disk('r2-public')->put($macFilename, $macContent, [ - 'visibility' => 'public', - 'ContentType' => 'application/x-apple-aspen-config', - 'CacheControl' => $cacheControl - ]); + // 3. Generate and Upload Windows Installer (.bat) + $batContent = $this->generateWindowsInstaller($cert, $sync['isArchive']); + Storage::disk('r2-public')->put($batPath, $batContent, [ + 'visibility' => 'public', + 'ContentType' => 'text/plain', + 'CacheControl' => $cacheControl + ]); - // 5. Generate and Upload Linux Script (.sh) - $linuxContent = $this->generateLinuxInstaller($cert); - Storage::disk('r2-public')->delete($linuxFilename); - Storage::disk('r2-public')->put($linuxFilename, $linuxContent, [ - 'visibility' => 'public', - 'ContentType' => 'text/plain', - 'CacheControl' => $cacheControl - ]); + // 4. Generate and Upload macOS Profile (.mobileconfig) + $macContent = $this->generateMacInstaller($cert); // macOS profiles are self-contained + Storage::disk('r2-public')->put($macPath, $macContent, [ + 'visibility' => 'public', + 'ContentType' => 'application/x-apple-aspen-config', + 'CacheControl' => $cacheControl + ]); + + // 5. Generate and Upload Linux Script (.sh) + $linuxContent = $this->generateLinuxInstaller($cert, $sync['isArchive']); + Storage::disk('r2-public')->put($linuxPath, $linuxContent, [ + 'visibility' => 'public', + 'ContentType' => 'text/plain', + 'CacheControl' => $cacheControl + ]); + } $cert->update([ - 'bat_path' => $batFilename, - 'mac_path' => $macFilename, - 'linux_path' => $linuxFilename, + 'bat_path' => "ca/installers/trustlab-{$slug}.bat", + 'mac_path' => "ca/installers/trustlab-{$slug}.mobileconfig", + 'linux_path' => "ca/installers/trustlab-{$slug}.sh", 'last_synced_at' => now() ]); return true; } + /** + * Promote an archived certificate version to 'Latest' (public root) + */ + public function promoteToLatest(CaCertificate $cert) + { + // Simply re-sync this specific certificate version as 'latest' + $this->uploadPublicCertsOnly($cert, 'latest'); + $this->uploadIndividualInstallersOnly($cert, 'latest'); + + // Also sync all bundles to ensure global installers are updated with this promoted version + $this->syncAllBundles(); + + return true; + } + /** * Generate Global Bundles (Installer Sapujagat) */ diff --git a/database/migrations/2025_12_23_123913_create_ca_certificates_table.php b/database/migrations/2025_12_23_123913_create_ca_certificates_table.php index ae1564c..313544b 100644 --- a/database/migrations/2025_12_23_123913_create_ca_certificates_table.php +++ b/database/migrations/2025_12_23_123913_create_ca_certificates_table.php @@ -26,6 +26,7 @@ return new class extends Migration $table->string('bat_path')->nullable(); $table->string('mac_path')->nullable(); $table->string('linux_path')->nullable(); + $table->boolean('is_latest')->default(false); $table->timestamp('last_synced_at')->nullable(); $table->string('common_name')->nullable(); @@ -58,6 +59,9 @@ return new class extends Migration if (!Schema::connection('mysql_ca')->hasColumn('ca_certificates', 'last_synced_at')) { $table->timestamp('last_synced_at')->nullable()->after('linux_path'); } + if (!Schema::connection('mysql_ca')->hasColumn('ca_certificates', 'is_latest')) { + $table->boolean('is_latest')->default(false)->after('last_synced_at'); + } }); } } diff --git a/routes/api.php b/routes/api.php index ee2b2ed..642524b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -65,6 +65,7 @@ Route::middleware(['auth:sanctum'])->group(function () { Route::post('/admin/ca-certificates/sync-installers', [RootCaApiController::class, 'syncInstallersOnly']); Route::post('/admin/ca-certificates/sync-bundles', [RootCaApiController::class, 'syncBundlesOnly']); Route::post('/admin/ca-certificates/{certificate}/renew', [RootCaApiController::class, 'renew']); + Route::post('/admin/ca-certificates/{certificate}/promote', [RootCaApiController::class, 'promote']); // API Keys Management Route::get('/api-keys', [ApiKeyController::class, 'index']);