ssl with letsencrypt for websites

This commit is contained in:
Alex Crivion
2025-10-20 11:54:43 +03:00
parent 5e6aff8b38
commit 046ab2cf98
6 changed files with 577 additions and 9 deletions

View File

@@ -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);
}
}
}

View File

@@ -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'
};
}
}

View File

@@ -0,0 +1,31 @@
<?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::table('websites', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -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
<VirtualHost *:443>
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
</VirtualHost>
# Redirect HTTP to HTTPS
<VirtualHost *:80>
ServerName $domain
Redirect permanent / https://$domain/
</VirtualHost>
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 <domain> <email>"
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 <domain>"
exit 1
fi
domain=$2
remove_ssl_certificate "$domain"
;;
"status")
if [ $# -ne 2 ]; then
echo "Usage: $0 status <domain>"
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 <domain> <email> [document_root] - Generate SSL certificate for domain"
echo " remove <domain> - Remove SSL certificate for domain"
echo " status <domain> - Check SSL certificate status"
echo " renew - Renew all SSL certificates"
exit 1
;;
esac

View File

@@ -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 (
<AuthenticatedLayout
header={
@@ -61,6 +107,7 @@ export default function Websites({ websites, serverIp }) {
<thead className="text-gray-700 uppercase bg-gray-200 dark:bg-gray-700 dark:text-gray-300 text-sm">
<tr>
<th className="px-6 py-3">URL</th>
<th className="px-6 py-3">SSL Status</th>
<th className="px-6 py-3">Document Root</th>
<th className="px-6 py-3">PHP Version</th>
{auth.user.role == 'admin' && (
@@ -73,7 +120,30 @@ export default function Websites({ websites, serverIp }) {
{websites?.map((website, index) => (
<tr key={`website-${index}`} className="bg-white border-b text-gray-700 dark:text-gray-200 dark:bg-gray-850 dark:border-gray-700 border-gray-200">
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
{website.url}
<Link href={`https://${website.url}`} target="_blank">
<TbWorldWww className='w-4 h-4' /> {website.url}
</Link>
</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<div className={`inline-flex items-center px-3 py-1 rounded-md text-sm font-medium ${
website.ssl_status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: website.ssl_status === 'expired'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: website.ssl_status === 'pending'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}>
{website.ssl_status === 'active' ? (
<MdLock className="w-4 h-4 mr-1" />
) : (
<MdLockOpen className="w-4 h-4 mr-1" />
)}
{website.ssl_status === 'active' ? 'SSL Active' :
website.ssl_status === 'expired' ? 'SSL Expired' :
website.ssl_status === 'pending' ? 'SSL Pending' : 'SSL Inactive'}
</div>
</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<div className="text-xs inline-flex items-center px-3 rounded-md border border-gray-300 bg-gray-100 text-gray-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400">
@@ -116,7 +186,21 @@ export default function Websites({ websites, serverIp }) {
whitespace-nowrap dark:text-white">
<div className='flex items-center space-x-2'>
{/* <EditAccountForm account={account} /> */}
<button
onClick={() => toggleSsl(website)}
className={`p-2 rounded-lg transition-colors ${
website.ssl_enabled
? 'bg-green-100 hover:bg-green-200 text-green-600 dark:bg-green-900 dark:hover:bg-green-800 dark:text-green-300'
: 'bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-400'
}`}
title={website.ssl_enabled ? 'Disable SSL' : 'Enable SSL'}
>
{website.ssl_enabled ? (
<FaToggleOn className='w-5 h-5' />
) : (
<FaToggleOff className='w-5 h-5' />
)}
</button>
<ConfirmationButton doAction={() => deleteWebsite(website.id)}>
<TiDelete className='w-6 h-6 text-red-500' />

View File

@@ -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');