From 046ab2cf98e88331655556f6c1f219aa13c49d7d Mon Sep 17 00:00:00 2001 From: Alex Crivion Date: Mon, 20 Oct 2025 11:54:43 +0300 Subject: [PATCH] ssl with letsencrypt for websites --- app/Http/Controllers/WebsiteController.php | 140 ++++++++++ app/Models/Website.php | 60 +++- ...83948_add_ssl_fields_to_websites_table.php | 31 +++ laranode-scripts/bin/laranode-ssl-manager.sh | 259 ++++++++++++++++++ resources/js/Pages/Websites/Index.jsx | 94 ++++++- routes/web.php | 2 + 6 files changed, 577 insertions(+), 9 deletions(-) create mode 100644 database/migrations/2025_10_20_083948_add_ssl_fields_to_websites_table.php create mode 100755 laranode-scripts/bin/laranode-ssl-manager.sh diff --git a/app/Http/Controllers/WebsiteController.php b/app/Http/Controllers/WebsiteController.php index 3634b9a..9276ace 100644 --- a/app/Http/Controllers/WebsiteController.php +++ b/app/Http/Controllers/WebsiteController.php @@ -12,6 +12,7 @@ use App\Services\Websites\UpdateWebsitePHPVersionService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Process; use Inertia\Inertia; class WebsiteController extends Controller @@ -80,4 +81,143 @@ class WebsiteController extends Controller return redirect()->route('websites.index'); } + + /** + * Toggle SSL certificate for a website + */ + public function toggleSsl(Request $request, Website $website) + { + Gate::authorize('update', $website); + + $request->validate([ + 'enabled' => 'required|boolean', + 'email' => 'required_if:enabled,true|email' + ]); + + try { + if ($request->enabled) { + // Generate SSL certificate + $this->generateSslCertificate($website, $request->email); + } else { + // Remove SSL certificate + $this->removeSslCertificate($website); + } + + return response()->json([ + 'success' => true, + 'message' => $request->enabled ? 'SSL certificate generated successfully' : 'SSL certificate removed successfully', + 'ssl_status' => $website->fresh()->ssl_status, + 'ssl_enabled' => $website->fresh()->ssl_enabled + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to ' . ($request->enabled ? 'generate' : 'remove') . ' SSL certificate: ' . $e->getMessage() + ], 500); + } + } + + /** + * Generate SSL certificate for a website + */ + private function generateSslCertificate(Website $website, string $email): void + { + // Update status to pending + $website->update([ + 'ssl_status' => 'pending', + 'ssl_enabled' => true + ]); + + // Run SSL generation script + $scriptPath = base_path('laranode-scripts/bin/laranode-ssl-manager.sh'); + $result = Process::run([ + 'bash', $scriptPath, 'generate', + $website->url, + $email, + $website->fullDocumentRoot + ]); + + if ($result->failed()) { + $website->update([ + 'ssl_status' => 'inactive', + 'ssl_enabled' => false + ]); + throw new \Exception($result->errorOutput()); + } + + // Check SSL status + $statusResult = Process::run([ + 'bash', $scriptPath, 'status', $website->url + ]); + + $sslStatus = trim($statusResult->output()); + + // Update website with SSL information + $website->update([ + 'ssl_status' => $sslStatus === 'active' ? 'active' : 'inactive', + 'ssl_generated_at' => now(), + 'ssl_expires_at' => $sslStatus === 'active' ? now()->addDays(90) : null + ]); + } + + /** + * Remove SSL certificate for a website + */ + private function removeSslCertificate(Website $website): void + { + // Run SSL removal script + $scriptPath = base_path('laranode-scripts/bin/laranode-ssl-manager.sh'); + $result = Process::run([ + 'bash', $scriptPath, 'remove', $website->url + ]); + + if ($result->failed()) { + throw new \Exception($result->errorOutput()); + } + + // Update website SSL status + $website->update([ + 'ssl_enabled' => false, + 'ssl_status' => 'inactive', + 'ssl_expires_at' => null, + 'ssl_generated_at' => null + ]); + } + + /** + * Check SSL status for a website + */ + public function checkSslStatus(Website $website) + { + Gate::authorize('view', $website); + + try { + $scriptPath = base_path('laranode-scripts/bin/laranode-ssl-manager.sh'); + $result = Process::run([ + 'bash', $scriptPath, 'status', $website->url + ]); + + $sslStatus = trim($result->output()); + + // Update website SSL status + $website->update([ + 'ssl_status' => $sslStatus, + 'ssl_enabled' => $sslStatus === 'active' + ]); + + return response()->json([ + 'success' => true, + 'ssl_status' => $sslStatus, + 'ssl_enabled' => $sslStatus === 'active', + 'status_text' => $website->getSslStatusText() + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to check SSL status: ' . $e->getMessage() + ], 500); + } + } } diff --git a/app/Models/Website.php b/app/Models/Website.php index 452dbb8..6344d47 100644 --- a/app/Models/Website.php +++ b/app/Models/Website.php @@ -7,29 +7,38 @@ use Illuminate\Database\Eloquent\Model; class Website extends Model { - public $appends = ['fullDocumentRoot']; + protected $casts = [ + 'ssl_enabled' => 'boolean', + 'ssl_expires_at' => 'datetime', + 'ssl_generated_at' => 'datetime', + ]; protected $fillable = [ 'url', 'document_root', 'website_root', 'php_version_id', + 'ssl_enabled', + 'ssl_status', + 'ssl_expires_at', + 'ssl_generated_at', ]; public function getWebsiteRootAttribute(): string { - return $this->user->homedir . '/domains/' . $this->url; + return $this->user?->homedir . '/domains/' . $this->url; } // not using casts as it's not working in some scenarios public function getFullDocumentRootAttribute(): string { - return $this->user->homedir . '/domains/' . $this->url . $this->document_root; + return $this->user?->homedir . '/domains/' . $this->url . $this->document_root; } public function scopeMine(Builder $query): Builder { - return $query->when(!auth()->user()->isAdmin(), fn($query) => $query->where('user_id', auth()->id())); + $user = auth()->user(); + return $query->when($user && !$user->isAdmin(), fn($query) => $query->where('user_id', $user->id)); } public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo @@ -41,4 +50,47 @@ class Website extends Model { return $this->belongsTo(PhpVersion::class); } + + /** + * Check if SSL certificate is active and valid + */ + public function isSslActive(): bool + { + return $this->ssl_enabled && $this->ssl_status === 'active'; + } + + /** + * Check if SSL certificate is expired + */ + public function isSslExpired(): bool + { + return $this->ssl_status === 'expired' || + ($this->ssl_expires_at && $this->ssl_expires_at->isPast()); + } + + /** + * Get SSL status display text + */ + public function getSslStatusText(): string + { + return match($this->ssl_status) { + 'active' => 'SSL Active', + 'expired' => 'SSL Expired', + 'pending' => 'SSL Pending', + default => 'SSL Inactive' + }; + } + + /** + * Get SSL status color class for frontend + */ + public function getSslStatusColor(): string + { + return match($this->ssl_status) { + 'active' => 'text-green-600', + 'expired' => 'text-red-600', + 'pending' => 'text-yellow-600', + default => 'text-gray-500' + }; + } } diff --git a/database/migrations/2025_10_20_083948_add_ssl_fields_to_websites_table.php b/database/migrations/2025_10_20_083948_add_ssl_fields_to_websites_table.php new file mode 100644 index 0000000..35d93ce --- /dev/null +++ b/database/migrations/2025_10_20_083948_add_ssl_fields_to_websites_table.php @@ -0,0 +1,31 @@ +boolean('ssl_enabled')->default(false); + $table->string('ssl_status')->default('inactive'); // inactive, active, expired, pending + $table->timestamp('ssl_expires_at')->nullable(); + $table->timestamp('ssl_generated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('websites', function (Blueprint $table) { + $table->dropColumn(['ssl_enabled', 'ssl_status', 'ssl_expires_at', 'ssl_generated_at']); + }); + } +}; diff --git a/laranode-scripts/bin/laranode-ssl-manager.sh b/laranode-scripts/bin/laranode-ssl-manager.sh new file mode 100755 index 0000000..811387e --- /dev/null +++ b/laranode-scripts/bin/laranode-ssl-manager.sh @@ -0,0 +1,259 @@ +#!/bin/bash + +# SSL Certificate Manager for Laranode +# This script handles SSL certificate generation and management using Let's Encrypt + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +WEBROOT_PATH="/var/www/html" +CERTBOT_PATH="/usr/bin/certbot" +APACHE_SITES_PATH="/etc/apache2/sites-available" +APACHE_ENABLED_PATH="/etc/apache2/sites-enabled" +SSL_CERTS_PATH="/etc/letsencrypt/live" + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if certbot is installed +check_certbot() { + if ! command -v certbot &> /dev/null; then + print_error "Certbot is not installed. Please install it first:" + echo "sudo apt update && sudo apt install certbot python3-certbot-apache" + exit 1 + fi +} + +# Function to check if domain is accessible +check_domain_accessibility() { + local domain=$1 + print_status "Checking if domain $domain is accessible..." + + if ! curl -s --connect-timeout 10 "http://$domain" > /dev/null; then + print_error "Domain $domain is not accessible. Please ensure:" + echo "1. Domain DNS points to this server" + echo "2. Apache virtual host is configured and enabled" + echo "3. Domain is accessible via HTTP" + exit 1 + fi + + print_status "Domain $domain is accessible" +} + +# Function to generate SSL certificate +generate_ssl_certificate() { + local domain=$1 + local email=$2 + + print_status "Generating SSL certificate for $domain..." + + # Check if certificate already exists + if [ -d "$SSL_CERTS_PATH/$domain" ]; then + print_warning "SSL certificate for $domain already exists" + return 0 + fi + + # Generate certificate using certbot + if certbot certonly \ + --webroot \ + --webroot-path="$WEBROOT_PATH" \ + --email "$email" \ + --agree-tos \ + --no-eff-email \ + --domains "$domain" \ + --non-interactive; then + print_status "SSL certificate generated successfully for $domain" + return 0 + else + print_error "Failed to generate SSL certificate for $domain" + return 1 + fi +} + +# Function to create SSL-enabled Apache virtual host +create_ssl_vhost() { + local domain=$1 + local document_root=$2 + + print_status "Creating SSL-enabled virtual host for $domain..." + + local vhost_file="$APACHE_SITES_PATH/$domain-ssl.conf" + + cat > "$vhost_file" << EOF + + ServerName $domain + DocumentRoot $document_root + + SSLEngine on + SSLCertificateFile $SSL_CERTS_PATH/$domain/fullchain.pem + SSLCertificateKeyFile $SSL_CERTS_PATH/$domain/privkey.pem + + # Include SSL configuration + Include /etc/apache2/conf-available/ssl-params.conf + + # Security headers + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" + Header always set X-Content-Type-Options nosniff + Header always set X-Frame-Options DENY + Header always set X-XSS-Protection "1; mode=block" + + # Logging + ErrorLog \${APACHE_LOG_DIR}/$domain-ssl_error.log + CustomLog \${APACHE_LOG_DIR}/$domain-ssl_access.log combined + + +# Redirect HTTP to HTTPS + + ServerName $domain + Redirect permanent / https://$domain/ + +EOF + + # Enable the site + a2ensite "$domain-ssl.conf" + + # Test Apache configuration + if apache2ctl configtest; then + systemctl reload apache2 + print_status "SSL virtual host created and enabled for $domain" + return 0 + else + print_error "Apache configuration test failed" + return 1 + fi +} + +# Function to remove SSL certificate +remove_ssl_certificate() { + local domain=$1 + + print_status "Removing SSL certificate for $domain..." + + # Disable SSL site + if [ -f "$APACHE_SITES_PATH/$domain-ssl.conf" ]; then + a2dissite "$domain-ssl.conf" + rm -f "$APACHE_SITES_PATH/$domain-ssl.conf" + fi + + # Remove certificate files + if [ -d "$SSL_CERTS_PATH/$domain" ]; then + certbot delete --cert-name "$domain" --non-interactive + print_status "SSL certificate removed for $domain" + else + print_warning "No SSL certificate found for $domain" + fi + + # Reload Apache + systemctl reload apache2 + print_status "SSL configuration removed for $domain" +} + +# Function to check SSL certificate status +check_ssl_status() { + local domain=$1 + + if [ -d "$SSL_CERTS_PATH/$domain" ]; then + # Check if certificate is valid and not expired + local cert_file="$SSL_CERTS_PATH/$domain/fullchain.pem" + if [ -f "$cert_file" ]; then + local expiry_date=$(openssl x509 -in "$cert_file" -noout -enddate | cut -d= -f2) + local expiry_timestamp=$(date -d "$expiry_date" +%s) + local current_timestamp=$(date +%s) + + if [ $expiry_timestamp -gt $current_timestamp ]; then + echo "active" + return 0 + else + echo "expired" + return 1 + fi + fi + fi + + echo "inactive" + return 1 +} + +# Function to renew SSL certificates +renew_ssl_certificates() { + print_status "Renewing SSL certificates..." + + if certbot renew --quiet; then + systemctl reload apache2 + print_status "SSL certificates renewed successfully" + return 0 + else + print_error "Failed to renew SSL certificates" + return 1 + fi +} + +# Main script logic +case "$1" in + "generate") + if [ $# -ne 3 ]; then + echo "Usage: $0 generate " + exit 1 + fi + + domain=$2 + email=$3 + + check_certbot + check_domain_accessibility "$domain" + generate_ssl_certificate "$domain" "$email" + create_ssl_vhost "$domain" "$4" + ;; + + "remove") + if [ $# -ne 2 ]; then + echo "Usage: $0 remove " + exit 1 + fi + + domain=$2 + remove_ssl_certificate "$domain" + ;; + + "status") + if [ $# -ne 2 ]; then + echo "Usage: $0 status " + exit 1 + fi + + domain=$2 + status=$(check_ssl_status "$domain") + echo "$status" + ;; + + "renew") + renew_ssl_certificates + ;; + + *) + echo "Usage: $0 {generate|remove|status|renew}" + echo "" + echo "Commands:" + echo " generate [document_root] - Generate SSL certificate for domain" + echo " remove - Remove SSL certificate for domain" + echo " status - Check SSL certificate status" + echo " renew - Renew all SSL certificates" + exit 1 + ;; +esac diff --git a/resources/js/Pages/Websites/Index.jsx b/resources/js/Pages/Websites/Index.jsx index e344dde..ab06035 100644 --- a/resources/js/Pages/Websites/Index.jsx +++ b/resources/js/Pages/Websites/Index.jsx @@ -1,4 +1,3 @@ -import { FaUsers } from "react-icons/fa6"; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head, Link, usePage } from '@inertiajs/react'; import ConfirmationButton from "@/Components/ConfirmationButton"; @@ -6,8 +5,8 @@ import { TiDelete } from "react-icons/ti"; import { toast } from "react-toastify"; import { router } from '@inertiajs/react' import { TbWorldWww } from "react-icons/tb"; -import { FaDatabase, FaEdit } from "react-icons/fa"; -import { Tooltip } from 'react-tooltip' +import { MdLock, MdLockOpen } from "react-icons/md"; +import { FaToggleOn, FaToggleOff } from "react-icons/fa"; import CreateWebsiteForm from "./Partials/CreateWebsiteForm"; import { useEffect, useState } from "react"; @@ -40,6 +39,53 @@ export default function Websites({ websites, serverIp }) { }); }; + const toggleSsl = (website) => { + const isEnabled = website.ssl_enabled; + const action = isEnabled ? 'disable' : 'enable'; + + if (!isEnabled) { + // If enabling SSL, prompt for email + const email = prompt('Please enter your email address for SSL certificate generation:'); + if (!email) return; + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + toast.error('Please enter a valid email address'); + return; + } + } + + const requestData = { + enabled: !isEnabled, + ...(isEnabled ? {} : { email: email }) + }; + + fetch(route('websites.ssl.toggle', { website: website.id }), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json' + }, + body: JSON.stringify(requestData) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + toast.success(data.message); + // Refresh the page to show updated SSL status + router.reload(); + } else { + toast.error(data.message); + } + }) + .catch(error => { + console.error('Error:', error); + toast.error('Failed to toggle SSL certificate'); + }); + }; + return ( URL + SSL Status Document Root PHP Version {auth.user.role == 'admin' && ( @@ -73,7 +120,30 @@ export default function Websites({ websites, serverIp }) { {websites?.map((website, index) => ( - {website.url} + + {website.url} + + + + +
+ {website.ssl_status === 'active' ? ( + + ) : ( + + )} + {website.ssl_status === 'active' ? 'SSL Active' : + website.ssl_status === 'expired' ? 'SSL Expired' : + website.ssl_status === 'pending' ? 'SSL Pending' : 'SSL Inactive'} +
@@ -116,7 +186,21 @@ export default function Websites({ websites, serverIp }) { whitespace-nowrap dark:text-white">
- {/* */} + deleteWebsite(website.id)}> diff --git a/routes/web.php b/routes/web.php index b52e613..6667c23 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,6 +30,8 @@ Route::get('/accounts/leave-impersonation', [AccountsController::class, 'leaveIm // Websites [Admin | User] Route::resource('/websites', WebsiteController::class)->middleware(['auth'])->except(['create', 'edit', 'show']); +Route::post('/websites/{website}/ssl/toggle', [WebsiteController::class, 'toggleSsl'])->middleware(['auth'])->name('websites.ssl.toggle'); +Route::get('/websites/{website}/ssl/status', [WebsiteController::class, 'checkSslStatus'])->middleware(['auth'])->name('websites.ssl.status'); // PHP FPM Pools [Admin | User] Route::get('/php/get-versions', [PHPManagerController::class, 'getVersions'])->middleware(['auth'])->name('php.get-versions');