Automate SSL Generation at Scale using Laravel
|

How to Automate SSL Generation and Renewals Using Laravel

Spread the love

Last Updated on April 5, 2024

Securing your Laravel application with an SSL certificate is crucial in ensuring traffic from and to your application is always encrypted. Some SSL Certificates are free while others have to be purchased and are provided by Certificate authorities such as letsencrypt, and Cloudflare among others.

Manually generating these certificates can become a tedious task especially if you have no idea where to start.

Certbot comes to the rescue by providing a free and automated way of generating and renewing these certificates.

It does require manually generating the certificates through Certbot. You can read more on how to add SSL certificates once you have deployed your application to production.

At times we might want to generate these certificates at scale, especially in a multi-tenant application. A good example is Slack. Slack provides each team with a unique subdomain such as team.slack.com.

Using the same analogy, we might also provide users with free subdomains as soon as they are onboarded to our multi-tenant app or as soon as they provide their own custom domains. We will also have to generate SSL certificates for these domain names.

We need to find a way on how we can generate SSL certificates at scale. There are multiple ways of solving this problem such as using the Caddy server. Caddy server provisions free SSL certificates for each host making it an easy tech to add to our stack.

The other option is to use Certbot and generate these certificates if we are using Apache or Nginx. In this tutorial, I will demonstrate how to automate this task for an Nginx-based approach.

This is a continuation of this article where we created a simple URL Shortener service and added the feature of allowing users to add their custom domains.

We will generate SSL certificates as soon as users have added their custom domain names to the system and updated their DNS records to point to our URL Shortener.

Refactoring the code

In the previous part of this series, we were storing domain names in the user’s table. While this is good for a simple URL shortener, most of the time a customer might have multiple domain names. Therefore, we will need a way of handling multiple domains for the same user.

We will create a new migration file that will drop the domain column from the users table

php artisan make:migration drop_domain_column_from_users_table --table=users
<?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('users', function (Blueprint $table) {
            $table->dropColumn('domain');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('domain')->nullable()->after('email');
        });
    }
};

We will then create a new Domain model that will contain all domain names.

php artisan make:model Domains -m
<?php

namespace App\Models;

use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Domains extends Model
{
    use HasFactory;

    protected $guarded= [];

    public function user()
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

Domains Model

<?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('domains', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
            $table->string('domain');
            $table->boolean('status')->default(0);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('domains');
    }
};

Domains Migration File

A user will now be able to add multiple domains to the system.

Let’s now figure out the DNS side of things.

Working with DNS

Domain name system is a service that helps transform human-readable domain names such as iankumu.com into IP addresses

For our URL shortener, we were allowing users to add their custom domains. This means that we need to provide them with some DNS records that they will add to their DNS providers to complete the mapping to our shortener application

There are two records that we might use: A records or Cname records. A record maps a domain name to an IP address while a Cname record maps one domain name to another.

You can read this guide to understand their differences and which one to choose. Depending on your needs you might choose one or the other.

For my case, I will choose Cname records. I will ask users to add a Cname record pointing to short.app.com

So in my DNS records at Namecheap, I will add this record to the custom domain I want to use in the URL shortener.

TypeHostValueTTL
CNAME Recordgoshort.app.comAutomatic
Namecheap DNS Records

This will map the domain go.example.com to short.app.com.

With that, my custom domain is ready to be used.

Installing certbot

In the previous part of this tutorial, we installed Nginx onto our server. We now want to install Certbot.

We can follow this tutorial to install Certbot on Nginx.

Generating SSL certificates using laravel and certbot.

Now that the DNS level is set and Certbot is installed, we can now generate SSL certificates for users.

Before we generate any SSL certificates, we first need to verify that the DNS records have propagated correctly. We will use the dns_get_record helper function to get the DNS records of a domain name.

//App/Utils/DomainHelper.php

<?php

namespace App\Utils;

class DomainHelper
{
    public $isfound = false;

    public function verifycname($domain)
    {
        $domain = parse_url($domain);

        $host = array_key_exists('host', $domain) ? $domain['host'] : $domain['path'];

        $records = dns_get_record($host, DNS_CNAME);

        if (!empty($records)) {
            foreach ($records as $record) {
                if ($record['target'] == "short.app.com") {
                    $this->isfound = true;
                    break;
                } else {
                    $this->isfound = false;
                }
            }
        } else {
            $this->isfound = false;
        }

        return $this->isfound;
    }
}

DNS records can take up to 48 hours to propagate, and thus we need to have a way of checking if the DNS records have propagated.

You can schedule a job to perform these checks. In my case, I will just skip this part. As soon as the records have propagated, we will add the domain to a queue for SSL generation.

Generating the SSL certificates

The next step is to generate the SSL certificates. We will use the new Process facade to work with Certbot. This will allow us to run external processes from our Laravel application.

We will create a new config in the config/services.php file that will allow us to change the Certbot environment based on our needs.

//config/services.php
<?php

...

'certbot' => [
        'test' => env('CERTBOT_TEST', true)
    ]

...

This configuration will help us determine which Certbot command to run.

//App/Utils/SSLManager.php
<?php

namespace App\Utils;

use App\Models\User;
use App\Models\Domains;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;

class SSLManager
{
    public function generateSSL($domain, $email = null)
    {
        $command = $this->certbot_command($domain, $email);

        $certbot = Process::path(base_path())->start($command);

        $process = $certbot->wait();

        if ($process->successful()) {
            return $process->output();
        }


    }

    public function certbot_command($domain, $email): string
    {

        if (config('services.certbot.test') == true) {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email --test-cert";
        } else {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email";
        }

        return $command;
    }

We are using the certonly flag to instruct certbot to only generate an SSL certificate but not update the Nginx config file. This is because we want to prepare a custom Nginx config file for all domains in our system.

We are also using a –test-cert flag to use a staging environment provided by let’s encrypt. This will help increase the certificate per domain limit to 30,000 per week and provide us with test SSL Certificates that we can use to test our logic.

In a production environment, a single domain can only request 5 certificates per week.

Let’s now prepare a simple config file that will proxy requests to our URL shortener.

//App/Utils/SSLManager.php
<?php

...

public function nginx_config($domain): string
    {
        $host = "short.app.com";

               $content = "
        server {
            listen 443 ssl;
            server_name $domain;

            resolver 8.8.8.8;

            location / {
                proxy_pass https://$host\$request_uri;

                proxy_set_header X-Forwarded-Host \$host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;
                proxy_set_header X-Forwarded-Uri \$request_uri;
            }

            ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
            ssl_trusted_certificate /etc/letsencrypt/live/$domain/chain.pem;

            include /etc/letsencrypt/options-ssl-nginx.conf;
            ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
        }

        server {
            if (\$host = $domain) {
                return 301 https://\$host\$request_uri;
            }

            listen 80;
            listen [::]:80;

            server_name $domain;
        }";

        return $content;
    }

...

Our application will prefill the $host and $domain variables with the correct parameters for each domain name

We will create these config files in the sites-available folder for Nginx.

The configurations will be created dynamically with our application.

//App/Utils/SSLManager
<?php

namespace App\Utils;

use App\Models\User;
use App\Models\Domains;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;

class SSLManager
{
    public function generateSSL($domain, $email = null): bool
    {
        $command = $this->certbot_command($domain, $email);

        $certbot = Process::path(base_path())->start($command);

        $process = $certbot->wait();


        if ($process->successful()) {
            $file_path = "/etc/nginx/sites-available/$domain";

            if (File::exists($file_path)) {
                $nginx = File::put($file_path, $this->nginx_config($domain));
            } else {
                touch($file_path);
                $nginx = File::put($file_path, $this->nginx_config($domain));
            }

            if ($nginx) {
                $symlink = $this->execute($this->symlink($domain));
                if ($symlink->successful()) {
                    $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
                    if ($finally->successful()) {
                        return true;
                    } else {
                        return false;
                    }
                }
            }
        } else {
            return false;
        }
    }

    public function certbot_command($domain, $email): string
    {

        if (config('services.certbot.test') == true) {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email --test-cert";
        } else {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email";
        }

        return $command;
    }

    public function nginx_config($domain): string
    {
        $host = "short.app.com";

                $content = "
        server {
            listen 443 ssl;
            server_name $domain;

            resolver 8.8.8.8;

            location / {
                proxy_pass https://$host\$request_uri;

                proxy_set_header X-Forwarded-Host \$host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;
                proxy_set_header X-Forwarded-Uri \$request_uri;
            }

            ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
            ssl_trusted_certificate /etc/letsencrypt/live/$domain/chain.pem;

            include /etc/letsencrypt/options-ssl-nginx.conf;
            ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
        }

        server {
            if (\$host = $domain) {
                return 301 https://\$host\$request_uri;
            }

            listen 80;
            listen [::]:80;

            server_name $domain;
        }";

        return $content;
    }

    private function symlink($domain): string
    {
        return "sudo ln -s /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/";
    }

    private function execute($command)
    {
        return Process::path(base_path())->run($command);
    }

We will now create a job to handle the generation of SSL Certificates.

php artisan make:job GenerateSSLJob
//App/Jobs/GenerateSSLJob
<?php

namespace App\Jobs;

use App\Utils\SSLManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

class GenerateSSLJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $domain;

    public $email;

    public function __construct($domain, $email = null)
    {
        $this->domain = $domain;
        $this->email = $email;
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        (new SSLManager())->generateSSL($this->domain, $this->email);
    }
}

With that, our SSL certificate generator is ready.

We can now use the custom domain in our URL shortener.

Deleting the SSL Certificates

Since we are creating SSL certificates at scale, we need a way of deleting them as soon as a user has left our platform.

We can create another method that will handle this task for us.

//App/Utils/SSLManager.php
<?php


...
  public function deleteSSL(User $user)
    {
        $domains = $this->getUserDomains($user);

        if (!empty($domains)) {
            foreach ($domains as $domain) {
                $nginx_path = "/etc/nginx/sites-available/$domain";
                $this->deleteConfig($nginx_path);
                $this->execute("sudo rm -rf /etc/nginx/sites-enabled/$domain");
                $this->execute("sudo certbot delete --cert-name $domain --non-interactive");
            }

            $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
            if ($finally->successful()) {

                return true;
            } else {
                return false;
            }
        }
    }

    public function getUserDomains(User $user)
    {
        return Domains::where('user_id', $user->id)->pluck('domain')->toArray();
    }

    public function deleteConfig($file_path)
    {
        if (File::exists($file_path)) {
            return File::delete($file_path);
        } else {
            return false;
        }
    }

...

You might also want to revoke certificates at this step so you might also add the logic here.

Refining our code

We can add some helper functions that will help make our application even better.

Validate domain names

Since we are allowing users to add custom domains, we may have cases where users “accidentally” add invalid domain names. Therefore, we need a way of ensuring that only valid domain names will be eligible for SSL generation.

Let’s add another method that will validate a domain name

//App/Utils/DomainHelper.php
<?php

...

public function is_valid_domain_name($domain_name)
    {
        return preg_match("/^(?:[-A-Za-z0-9]+\.)+[A-Za-z]{2,6}$/", $domain_name);
    }
...

We are using a simple regex that I “borrowed” from chat gpt🙂 to check the domain and return if it is valid or not.

Automate SSL renewals

Letsencrypt SSL certificates expire after 90 days. Luckily Certbort can renew SSL certificates for us.

Let’s add the method that will renew domain names for us

//App/Utils/SSLManager.php
<?php

...

 public function renewSSL($domain)
    {
        $command = "sudo certbot certonly --force-renew -d $domain";
        return $this->execute($command);
    }

...

We will need a way of knowing when to renew these SSL certificates. Let’s add a new column on the domain table that will contain the renewal date.

php artisan make:migration add_renewal_column_to_domains_table --table=domains
<?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('domains', function (Blueprint $table) {
            $table->dateTime('renewal_date')->nullable()->after('status');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('domains', function (Blueprint $table) {
            $table->dropColumn('renewal_date');
        });
    }
};

We will then prepare our job to renew these domains

php artisan make:job RenewSSLJob
<?php

namespace App\Jobs;

use App\Utils\SSLManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class RenewSSLJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Create a new job instance.
     */
    public $domains;
    public function __construct(array $domains)
    {
        $this->domains = $domains;
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        foreach ($this->domains as $domain) {
            (new SSLManager())->renewSSL($domain);
        }
    }
}

Finally, we will schedule the command to run daily. We will get all domains that have expiration due in a month and renew them.

We want to renew them 30 days before they expire to prevent having expired SSL certificates on our server.

//App/Console/Kernel.php

<?php

namespace App\Console;

use App\Jobs\RenewSSLJob;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

class Kernel extends ConsoleKernel
{
    /**
     * Define the application's command schedule.
     */
    protected function schedule(Schedule $schedule): void
    {
        // $schedule->command('inspire')->hourly();

        $renewalDate = Carbon::now()->addDays(30)->toDateTimeString();

        $domains = DB::table('domains')
            ->where('renewal_date', '<=', $renewalDate)
            ->pluck('domain')
            ->toArray();

        if (!empty($domains)) {
             $schedule->job(new RenewSSLJob($domains))->daily();
        }
    }

    /**
     * Register the commands for the application.
     */
    protected function commands(): void
    {
        $this->load(__DIR__ . '/Commands');

        require base_path('routes/console.php');
    }
}

The final helper class should resemble the one below

//App/Utils/SSLManager.php
<?php

namespace App\Utils;

use App\Models\User;
use App\Models\Domains;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;

class SSLManager
{
    public function generateSSL($domain, $email = null): bool
    {
        $command = $this->certbot_command($domain, $email);

        $certbot = Process::path(base_path())->start($command);

        $process = $certbot->wait();


        if ($process->successful()) {
            $file_path = "/etc/nginx/sites-available/$domain";

            if (File::exists($file_path)) {
                $nginx = File::put($file_path, $this->nginx_config($domain));
            } else {
                touch($file_path);
                $nginx = File::put($file_path, $this->nginx_config($domain));
            }

            if ($nginx) {
                $symlink = $this->execute($this->symlink($domain));
                if ($symlink->successful()) {
                    $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
                    if ($finally->successful()) {
                        DB::table('domains')->where('domain', '=', $domain)->update([
                            'status' => 1,
                            'renewal_date' => now()->toDateTimeString()
                        ]);

                        return true;
                    } else {
                        return false;
                    }
                }
            }
        } else {
            return false;
        }
    }

    public function certbot_command($domain, $email): string
    {

        if (config('services.certbot.test') == true) {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email --test-cert";
        } else {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email";
        }

        return $command;
    }

    public function nginx_config($domain): string
    {
        $host = "short.app.com";

        $content = "
        server {
            if (\$host = $domain) {
               return 301 https://\$host\$request_uri;
            }
            listen 80;
            listen [::]:80;
            server_name $domain;
        }
        server {
            listen 443 ssl http2;
            listen [::]:443 ssl http2;
            server_name $domain;
            return 301 https://$host\$request_uri;
            resolver 8.8.8.8;
            location / {
                include proxy_params;
                proxy_pass https://$host\$request_uri;
                proxy_set_header Host $domain;
                proxy_set_header X-Forwarded-Host \$http_host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;
                proxy_redirect off;
                proxy_http_version 1.1;
                proxy_set_header Upgrade \$http_upgrade;
            }
            ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
            ssl_trusted_certificate /etc/letsencrypt/live/$domain/chain.pem;
            include /etc/letsencrypt/options-ssl-nginx.conf;
            ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
        }";

        return $content;
    }

    private function symlink($domain): string
    {
        return "sudo ln -s /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/";
    }

    private function execute($command)
    {
        return Process::path(base_path())->run($command);
    }

    public function renewSSL($domain)
    {
        $command = "sudo certbot certonly --force-renew -d $domain";
        return $this->execute($command);
    }

    public function deleteSSL(User $user)
    {
        $domains = $this->getUserDomains($user);

        if (!empty($domains)) {
            foreach ($domains as $domain) {
                $nginx_path = "/etc/nginx/sites-available/$domain";
                $this->deleteConfig($nginx_path);
                $this->execute("sudo rm -rf /etc/nginx/sites-enabled/$domain");
                $this->execute("sudo certbot delete --cert-name $domain --non-interactive");
            }

            $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
            if ($finally->successful()) {
                return true;
            } else {
                return false;
            }
        }
    }

    public function getUserDomains(User $user)
    {
        return Domains::where('user_id', $user->id)->pluck('domain')->toArray();
    }

    public function deleteConfig($file_path)
    {
        if (File::exists($file_path)) {
            return File::delete($file_path);
        } else {
            return false;
        }
    }
}

Final SSLManager Class

Tying it all up

Let’s piece everything together through a controller

php artisan make:controller DomainController
<?php

namespace App\Http\Controllers;

use App\Jobs\GenerateSSLJob;
use App\Models\Domains;
use App\Utils\DomainHelper;
use Illuminate\Http\Request;

class DomainController extends Controller
{

    public function store(Request $request)
    {
        $domain_name = $request->input('domain');
        $email = auth()->user()->email;

        if ((new DomainHelper())->is_valid_domain_name($domain_name)) {
            $domain = Domains::create([
                'user_id' => auth()->id(),
                'domain' => $domain_name,
                'status' => 0
            ]);

            if ((new DomainHelper())->verifycname($domain_name)) {
                $job = (new GenerateSSLJob($domain_name, $email));
                dispatch($job);
            }

            return $domain;
        } else {
            return response()->json([
                'message' => $domain_name . 'is not a valid domain name. Ensure your domain name is valid',
            ], 406);
        }
    }
}

Conclusion

And that’s it. We have automated the process of generating and renewing SSL certificates using laravel.

I hope this article was insightful and showed you how to automate SSL generation and renewals using laravel.

You can tweak the logic to suit your needs. For me, I was using a simple example of a URL shortener and this is how I achieved SSL generation automation.

If you have any questions, feel free to ask them in the comment section.

Thank you for reading.

Similar Posts

4 Comments

  1. Asif Iqbal says:

    OMG. That’s a great thing happened in a long time. I have been finding a way to setup multiple domains with SSL Certificate in Nginx Level. It would be a great help for my project.
    Thank you so much for the code.

    1. No Worries. Glad you liked it

    1. That’s one way of looking at it. Traefik is a good solution when you want to offset the task to a third-party package. The approach I took provides more control and flexibility.

      The choice really depends on the needs of the project.

      Thanks for the feedback though and for reading the article 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *