Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save torsten-online/926b5baab451b805458e76f9a772a6ca to your computer and use it in GitHub Desktop.
Save torsten-online/926b5baab451b805458e76f9a772a6ca to your computer and use it in GitHub Desktop.

Howto integrate SSL at HestiaCP with Support of Domains (.DEV, .BLOG etc) which are enforcing SSL

Custom Python Crontab Script with DNS01 Challenge / Cloudflare DNS for Letsencrypt

This was an urgent issue fix - because these TLDs require an SSL Cert already in place

HTTP-01 Challenge is also working over HestiaCP, NOW!

Hint: crontab not required anymore

If u are the lazy admin and user of an "Hosting Control Panel" like HestiaCP you maybe already had the following problem:

  • Issue
Error: Let's Encrypt finalize bad status 400

This happens, because HestiaCP supports only the HTTP ACME Challenge - but for Domains where SSL is enforced, the challenge can not be done! To get SSL Letsencrypt Certificates I have to build my own solution, which supports DNS01 at Cloudflare DNS.

Solution: Build up own python crontab script, which supports certbot and DNS01

  • Requirements - Python3 with the following PIP Packages installed over distribution (or manual):
sudo apt-get install certbot python3-certbot-dns-cloudflare
  • Python SSL Certbot Task Script with HestiaCP Integration

    /usr/local/bin/hestiacp_dns01_sslcert.py

#!/usr/bin/env python3
import os
import subprocess
from datetime import datetime, timedelta
from certbot.plugins import dns_common

# --- Configuration ---
domains = ["firstdomain.dev", "secondgoogledomain.blog"]
user = "username"
cloudflare_credentials_file = "/home/username/.secrets/certbot/certbot_cloudflare_key.ini"
log_file = "/var/log/hestiacp_DNS01_cert_renewal.log"

# --- Functions ---
def check_expiry(domain):
    """Checks the expiration date of the certificate."""
    try:
        result = subprocess.run(["sudo", "certbot", "certificates", "--cert-name", domain], capture_output=True, text=True)
        for line in result.stdout.splitlines():
            if line.startswith("  NOT AFTER :"):
                expiry_date_str = line.split(": ")[1]
                expiry_date = datetime.strptime(expiry_date_str, "%Y-%m-%d %H:%M:%S")
                return (expiry_date - datetime.now()).days
    except Exception as e:  # Catch all exceptions here, including FileNotFoundError
        log_error(f"Error checking expiry for {domain}: {e}")  # Log the error
    return None  # Return None to indicate an error

def renew_certificate(domain):
    """Obtains/renews a certificate and updates HestiaCP configuration."""
    # Remove the line: dns_common.Authenticator._setup_credentials = lambda self: None

    try:
        cert_path = f"/etc/letsencrypt/live/{domain}/cert.pem"
        command = ["sudo", "certbot", "renew" if os.path.exists(cert_path) else "certonly",
                   "--dns-cloudflare", "--dns-cloudflare-credentials", cloudflare_credentials_file,
                   "-d", domain, "--non-interactive", "--keep-until-expiring"]

        result = subprocess.run(command, capture_output=True, text=True)

        if "SSL=yes already exists" in result.stderr:
            update_hestia_config(domain, exists=True)
            log_message(f"Certificate for {domain} already exists, updating HestiaCP configuration.")
        else:
            if "Certificate not yet due for renewal" in result.stdout:
                log_message(f"Certificate for {domain} is not due for renewal yet.")
            else:
                log_message(f"Certificate for {domain} {'renewed' if os.path.exists(cert_path) else 'obtained'} successfully.")
                update_hestia_config(domain, exists=False)

    except subprocess.CalledProcessError as e:
        error_message = e.stderr.decode('utf-8')
        log_error(f"Error obtaining/renewing certificate for {domain}: {error_message}")
    except Exception as e:
        log_error(f"Error obtaining/renewing certificate for {domain}: {e}")


def update_hestia_config(domain, exists=False):
    """Updates or creates the SSL configuration for the domain in HestiaCP."""
    cert_path = f"/etc/letsencrypt/live/{domain}"
    ssl_dir = f"/usr/local/hestia/data/users/{user}/ssl"

    if exists:
        command = f"sudo v-update-web-domain-ssl{user} {domain} {ssl_dir}/"
    else:
        command = f"sudo v-add-web-domain-ssl {user} {domain} {ssl_dir}/"
    os.system(command)

    # Restart Nginx (or your web server) to apply the changes
    os.system("sudo v-restart-web")

def log_message(message):
    """Logs a message to the log file."""
    with open(log_file, "a") as f:
        f.write(f"{datetime.now()} - {message}\n")

def log_error(message):
    """Logs an error message to the log file."""
    log_message(f"ERROR: {message}")

# --- Main Script ---
if __name__ == "__main__":
    for domain in domains:
        days_remaining = check_expiry(domain)
        if days_remaining is None or days_remaining < 30:
            renew_certificate(domain)
        else:
            log_message(f"Certificate for {domain} is still valid (expires in {days_remaining} days).")
  • Install as Cronjob of HestiaCP User
0 2 * * * /usr/bin/python3 /usr/local/bin/hestiacp_dns01_sslcert.py >> /var/log/hestiacp_DNS01_cert_renewal.log 2>&1
  • Set Permission for Certbot

    Simply add the cloudflare credential data to the /home/username/.secrets/certbot/certbot_cloudflare_key.ini (chmod 600!) of the hestiacp User!

# Cloudflare API Token(!) credentials used by Certbot
dns_cloudflare_api_token = YOUR_API_TOKEN

Important: This can be done more safe - e.g. using a Vault for the secret data [ToBeDone]

Use visudo to add this sudo-permission:

username ALL=(ALL) NOPASSWD: /usr/bin/certbot
username ALL=(ALL) NOPASSWD: /usr/local/hestia/bin/v-update-web-domain-ssl
username ALL=(ALL) NOPASSWD: /usr/local/hestia/bin/v-add-web-domain-ssl
username ALL=(ALL) NOPASSWD: /usr/local/hestia/bin/v-restart-web

NOTE: Replace username with your hestiacp username

Thats iT! You have now a final solution for DNS01 Challenge Support at HestiaCP.

Have a lot fun

Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment