Skip to content

Suzhou65/ACME4ZeroSSL

Repository files navigation

Acme4ZeroSSL

Python UA Size

Python script for renew certificate from ZeroSSL.

Contents

Development Purpose

I manage sh*tload of servers, including my profile page, apartment's HomeKit gateway, several Hentai@Home client. Also, some headless system based on Apache Tomcat, which don't support authentication via HTTP/HTTPS challenge file.

Even though I can update CNAME record through Cloudflare API, certificate downloading and install has to be done manually. Current certificate validity is 90 days, but as that period gets shorter, those process becomes more annoying and frequent.

Developed to automate renewal certificate with ZeroSSL REST API, pair with Cloudflare hosting DNS records for CNAME challenge.

Limitation

DNS hosting
Currently support Cloudflare only.

Domains
Single Common Name (CN).
Or single CN with single Subject Alternative Name (SAN) pairs.
Doesn't support wildcard certificate.

Usage

Configuration file

Using JSON format file storage configuration. Configuration file must include following parameters:

{
   "Telegram_BOTs":{
      "Token": "",
      "ChatID": ""
   },
   "CloudflareAPI":{
      "Token": "",
      "Mail": ""
   },
  "CloudflareRecords":{
      "ZoneID": "",
      "CNAMERecordsID": ["", ""]
   },
   "ZeroSSLAPI":{
      "AccessKey": "",
      "Cache": ""
   },
   "Certificate":{
      "Domains": ["www.example.com", "example.com"],
      "Country": "",
      "StateOrProvince": "",
      "Locality": "",
      "Organization": "",
      "OrganizationalUnit": "",
      "Config": "",
      "CSR": "",
      "PendingPK": "",
      "PK": "",
      "CA": "",
      "CAB": ""
   },
   "FileChallenge":{
      "HTMLFilePath": ""
   }
}

Configuration file must include following parameters:

Telegram BOTS token
Storage BOTs Token inside Telegram_BOTs.
Chat channel ID storage at ChatID.

Cloudflare API Key
Storage Cloudflare API Token inside CloudflareAPI.
API authy email storage at Mail.

Note:
Please remove the Bearer string and blank.

Cloudflare Zone ID
Storage Cloudflare Zone ID inside ZoneID.
CNAME records ID storage at CNAMERecordsID list.

If you only need ACME certification for a single domain name, simply keep only one DNS records ID inside CNAMERecordsID list.

"CNAMERecordsID": ["XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"]

ZeroSSL REST API Key
Storage ZeroSSL Access Key inside ZeroSSLAPI.
Storage ZeroSSL certificate verify data as JSON file at Cache.

"AccessKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"Cache": "/Documents/script/cache.domain.json"

Certificate
Storage dertificate's domains Domains.

"Domains": ["www.example.com", "example.com"],

If you only need to renew single domain name: Simply keep only one domain in Domains list inside.

"Domains": ["www.example.com"],

Certificate signing request (CSR) configuration
Country following ISO 3166-1 standard.
StateOrProvince for geographical information.
Locality for geographical information.
Organization is recommended to NGO or Personal Business.
OrganizationalUnit is recommended to NGO or Personal Business.

"Country": "JP",
"StateOrProvince": "Tokyo Metropolis",
"Locality": "Shimokitazawa",
"Organization": "STARRY",
"OrganizationalUnit": "Kessoku Bando",

Config is CSR configuration file for generate CSR.
CSR is Certificate signing request saving path.

"Config": "/Documents/script/domain.csr.conf",
"CSR": "/Documents/script/domain.csr",

Certificate catalog also include active Private key and Certificates path.
Fail-safe: private key won't update until renewal Certificate was download, will storage as pending key (PendingPK).

"PendingPK": "/Documents/script/cache.domain.key",
"PK": "/var/certificate/private.key",
"CA": "/var/certificate/certificate.crt",
"CAB": "/var/certificate/ca_bundle.crt"

FileChallenge
Files path for HTTP/HTTPS file challenge.
Usually is your Apache/Nginx webpage folder.

"HTMLFilePath": "/var/www/html/"

Cloudflare API

For using CNAME challenge function, you need to domain registered with Cloudflare, or choice Cloudflare as DNS hosting service.

For safety:
Please modify the token’s permissions. only allowing DNS record edit is is recommended.
Also make sure copy the secret to secure place.

ZeroSSL REST API

Login ZeroSSL, go to Developer page, you will find your ZeroSSL API Key, make sure to copy the secret to a secure place.

If you suspect that your API Key has been compromised:
Please click Reset Key and check is any unusual, or suspicious certificate been issued.

Telegram BOTs

Using Telegram Bot, contect BotFather create new Bot accounts.

At this point chat channel wasn't created, so you can't find the ChatID. Running Message2Me function will receive 400 Bad Request from Telegram API, following message will printout:

2025-05-14 19:19:00 | Telegram ChatID is empty, notifications will not be sent.

You need to start the chat channel with that bot, i.e. say Hello the world to him. Then running GetChatID

import acme4zerossl
ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
Tg = acme4zerossl.Telegram(ConfigFilePath)
Tg.GetChatID()

Now ChatID will printout:

2025-05-14 19:19:18 | You ChatID is: XXXXXXXXX

Webpage Server Reload or Restart

Function CertificateInstall support webpage server restart when certificate was downloaded (optional).

Command type
Adding command to ServerCommand with list object.
Default is None, after download certificate will skip webpage server reload or restart.

# Function
Rt.CertificateInstall(CertificateContent, ServerCommand)
# Default is None
ServerCommand = None
# Apache
ServerCommand = ['systemctl', 'reload', 'apache2']
# Nginx
ServerCommand = ['service', 'nginx', 'restart']

Schedule

Recommend using systemd.

systemd service file
Create service file /etc/systemd/system/acme_cname.service for systemd.

WorkingDirectory /Documents/script prevent absolute/relative path issue.
ExecStart /usr/bin/python3 depend on Python environment.
Path /Documents/script/script_cname.py is acme script located.

[Unit]
Description=ACME Script under CNAME mode
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=root
WorkingDirectory=/Documents/script
ExecStart=/usr/bin/python3 /Documents/script/script_cname.py

Timer file
Next is timer file /etc/systemd/system/acme_cname.timer.
Following example running everyday 5:00 AM and 10 minutes after boot up.

[Unit]
Description=Run ACME Script everyday

[Timer]
OnCalendar=*-*-* 05:00:00
Persistent=true

[Install]
WantedBy=timers.target

Enable service
Enable timer and clean cache.

# Enable and start the timer
systemctl enable acme_cname.timer
systemctl start acme_cname.timer

# Reload
systemctl daemon-reload

Import module

# Import as module
import acme4zerossl
# Alternative
import acme4zerossl as acme

Function

Verify Cloudflare API Token

import acme4zerossl as acme

ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
Cf = acme.Cloudflare(ConfigFilePath)
Cf.VerifyCFToken()

Default Output
Show result's value as string only.
Enable fully result by using DisplayVerifyResult

Cf.VerifyCFToken(DisplayVerifyResult = True)

Asking CNAME Records ID hosing on Cloudflare

import acme4zerossl as acme

ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
Cf = acme.Cloudflare(ConfigFilePath)
Cf.GetCFRecords()

Default Output
Output is dictionary object contain fully Cloudflare dns records data belong specify Zone ID.
Adding FileOutput for output JSON file.

FileOutput = "/Documents/script/records.cloudflare.json"
Cf.GetCFRecords(FileOutput)

Verify with CNAME challenge

Demonstration script
script_cname.py including Telegram BOTs notify and check validity date of certificate.

import acme4zerossl as acme
from time import sleep
from sys import exit
# Configuration file path
ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
ServerCommand  = None
# Script
def main():
    Rt = acme.Runtime(ConfigFilePath)
    Cf = acme.Cloudflare(ConfigFilePath)
    Zs = acme.ZeroSSL(ConfigFilePath)
    # Create certificates signing request
    ResultCreateCSR = Rt.CreateCSR()
    if isinstance(ResultCreateCSR, bool):
        raise Exception()
    elif isinstance(ResultCreateCSR, int):
        pass
    # Sending CSR
    VerifyRequest = Zs.ZeroSSLCreateCA()
    # Function error
    if isinstance(VerifyRequest, bool):
        raise Exception()
    # ZeroSSL REST API HTTP error
    elif isinstance(VerifyRequest, int):
        raise Exception()
    # Phrasing ZeroSSL verify
    elif isinstance(VerifyRequest, dict):
        VerifyData = Zs.ZeroSSLVerifyData(VerifyRequest, Mode="CNAME")
    # Check verify data
    if isinstance(VerifyData, bool):
        raise Exception()
    elif isinstance(VerifyData, dict):
        pass
    # Update CNAME via Cloudflare API
    if ("additional_domains") in VerifyData:
        UpdatePayloads = [
            VerifyData.get('common_name'),
            VerifyData.get('additional_domains')]
    elif ("additional_domains") not in VerifyData:
        UpdatePayloads = [
            VerifyData.get('common_name')]
    # Update Cloudflare CNAME records
    for UpdatePayload in UpdatePayloads:
        ResultUpdateCFCNAME = Cf.UpdateCFCNAME(UpdatePayload)
        # Function error
        if isinstance(ResultUpdateCFCNAME, bool):
            raise Exception()
        # Cloudflare API HTTP error
        elif isinstance(ResultUpdateCFCNAME, int):
            raise Exception()
        # Check CNAME update result
        elif isinstance(ResultUpdateCFCNAME, dict):
            ResultUpdateResult = ResultUpdateCFCNAME.get("success")
            if ResultUpdateResult == True:
                sleep(5)
            elif ResultUpdateResult == False:
                raise Exception()
            else:
                raise Exception()
        else:
            raise Exception()
    # Wait DNS records update and active
    sleep(30)
    # Verify CNAME challenge
    CertificateID = VerifyData.get("id","")
    VerifyResult = Zs.ZeroSSLVerification(CertificateID, ValidationMethod="CNAME_CSR_HASH")
    # Function error
    if isinstance(VerifyResult, bool):
        raise Exception()
    # ZeroSSL REST API HTTP error
    elif isinstance(VerifyResult, int):
        raise Exception()
    # Possible errors respon
    elif isinstance(VerifyResult, dict) and ("error") in VerifyResult:
        VerifyErrorStatus = VerifyResult.get("error",{}).get("type","Unknown Error")
        raise Exception (VerifyErrorStatus)
    # Check verify status
    elif isinstance(VerifyResult, dict) and ("status") in VerifyResult:
        VerifyStatus = VerifyResult.get("status")
        # Verify successful, wait issued
        if VerifyStatus == ("draft"):
            raise Exception()
        elif VerifyStatus == ("pending_validation"):
            sleep(30)
        # Verify successful and been issued
        elif VerifyStatus == ("issued"):
            sleep(5)
        # Undefined error
        else:
            raise Exception()
    # Download certificates
    CertificateContent = Zs.ZeroSSLDownloadCA(CertificateID)
    if isinstance(CertificateContent, bool):
        raise Exception()
    elif isinstance(CertificateContent, str):
        raise Exception(CertificateContent)
    elif isinstance(CertificateContent, dict) and ("certificate.crt") in CertificateContent:
        sleep(5)
    # Install certificate to server folder
    ResultCheck = Rt.CertificateInstall(CertificateContent, ServerCommand)
    if isinstance(ResultCheck, bool):
        raise Exception()
    elif isinstance(ResultCheck, int):
        pass
    elif isinstance(ResultCheck, (list,str)):
        pass
    else:
        raise Exception ()
# Runtime
try:
    main()
    exit(0)
except Exception:
    exit(1)

Verify with HTTPS file challenge

Demonstration script
script_httpsfile.py including Telegram BOTs notify and check validity date of certificate.

# -*- coding: utf-8 -*-
import acme4zerossl as acme
from time import sleep
from sys import exit

# Config
ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
ServerCommand  = None

# Script
def main():
    Rt = acme.Runtime(ConfigFilePath)
    Zs = acme.ZeroSSL(ConfigFilePath)
    # Create certificates signing request
    ResultCreateCSR = Rt.CreateCSR()
    if isinstance(ResultCreateCSR, bool):
        raise Exception()
    elif isinstance(ResultCreateCSR, int):
        pass
    # Sending CSR
    VerifyRequest = Zs.ZeroSSLCreateCA()
    # Function error
    if isinstance(VerifyRequest, bool):
        raise Exception()
    # ZeroSSL REST API HTTP error
    elif isinstance(VerifyRequest, int):
        raise Exception()
    # Phrasing ZeroSSL verify
    elif isinstance(VerifyRequest, dict):
        VerifyData = Zs.ZeroSSLVerifyData(VerifyRequest, Mode="FILE")
    # Check verify data
    if isinstance(VerifyData, bool):
        raise Exception()
    elif isinstance(VerifyData, dict):
        pass
    # Validation file path and content
    if ("additional_domains") in VerifyData:
        ValidationFiles = [
            VerifyData.get('common_name'),
            VerifyData.get('additional_domains')]
    elif ("additional_domains") not in VerifyData:
        ValidationFiles = [
            VerifyData.get('common_name')]
    # Create validation file
    for ValidationFile in ValidationFiles:
        Rt.CreateValidationFile(ValidationFile)
    # Wait for web server cahce
    sleep(15)
    # Verify file challenge
    CertificateID = VerifyData.get("id")
    VerifyResult = Zs.ZeroSSLVerification(CertificateID, ValidationMethod="HTTPS_CSR_HASH")
    # Function error
    if isinstance(VerifyResult, bool):
        raise Exception()
    # ZeroSSL REST API HTTP error
    elif isinstance(VerifyResult, int):
        raise Exception()
    # Possible errors respon
    elif isinstance(VerifyResult, dict) and ("error") in VerifyResult:
        VerifyErrorStatus = VerifyResult.get("error",{})
        ErrorType = VerifyErrorStatus.get("type", "Unknown Error")
        raise Exception (ErrorType)
    # Check verify status
    elif isinstance(VerifyResult, dict) and ("status") in VerifyResult:
        VerifyStatus = VerifyResult.get("status")
        # Verify successful, wait issued
        if VerifyStatus == ("draft"):
            raise Exception()
        elif VerifyStatus == ("pending_validation"):
            sleep(30)
        # Verify successful and been issued
        elif VerifyStatus == ("issued"):
            sleep(5)
        # Undefined error
        else:
            raise Exception()
    # Delete validation file
    for ValidationFile in ValidationFiles:
        Rt.CleanValidationFile(ValidationFile)
    # Download certificates
    CertificateContent = Zs.ZeroSSLDownloadCA(CertificateID)
    if isinstance(CertificateContent, bool):
        raise Exception()
    elif isinstance(CertificateContent, str):
        raise Exception(CertificateContent)
    elif isinstance(CertificateContent, dict) and ("certificate.crt") in CertificateContent:
        sleep(5)
    # Install certificate to server folder
    ResultCheck = Rt.CertificateInstall(CertificateContent, ServerCommand)
    ExpiresDate = VerifyResult.get("expires")
    if isinstance(ResultCheck, bool):
        raise Exception()
    elif isinstance(ResultCheck, int):
        pass
    elif isinstance(ResultCheck, (list,str)):
        pass
    else:
        raise Exception ()
# Runtime
try:
    main()
    exit(0)
except Exception:
    exit(1)

Download certificate

Demonstration script
script_download.py.

import acme4zerossl as acme
from sys import exit

# Config
ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
# Script
def DownloadScript():
    Rt = acme.Runtime(ConfigFilePath)
    Zs = acme.ZeroSSL(ConfigFilePath)
    # Input certificate hash manually
    CertificateID = input("Please input certificate ID (hash), or press ENTER using cache file: ")
    # Download certificate payload
    CertificateContent = Zs.ZeroSSLDownloadCA(CertificateID or None)
    # Check
    if isinstance(CertificateContent, bool):
        raise Exception()
    elif isinstance(CertificateContent, str):
        raise Exception()
    # Download certificate and save to folder
    elif isinstance(CertificateContent, dict) and ("certificate.crt") in CertificateContent:
        pass
    ResultCheck = Rt.CertificateInstall(CertificateContent)
    if isinstance(ResultCheck, bool):
        raise Exception()
    elif isinstance(ResultCheck, int):
        Rt.Message("Certificate been downloaded to folder. You may need to restart server manually.")
    elif isinstance(ResultCheck, (list,str)):
        Rt.Message(f"Certificate been downloaded and server has reload or restart.")
# Runtime
try:
    DownloadScript()
    exit(0)
except Exception:
    exit(1)

Cancel certificate

Only certificates with status draft or pending_validation can be cancelled.
After verification, the certificates cannot been cancelled.

Demonstration script
Demonstration script named script_cancel.py.

import acme4zerossl as acme
from sys import exit

# Config
ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
# Script
def CancelScript():
    Rt = acme.Runtime(ConfigFilePath)
    Zs = acme.ZeroSSL(ConfigFilePath)
    # Input certificate hash manually
    CertificateID = input("Please input certificate ID (hash): ")
    # Cancel certificate
    CancelStatus = Zs.ZeroSSLCancelCA(CertificateID)
    # Status check, Error
    if isinstance(CancelStatus, bool):
        raise Exception()
    # ZeroSSL REST API HTTP error
    elif isinstance(CancelStatus, int):
        raise Exception()
    # Standard response, check status code
    elif isinstance(CancelStatus, dict):
        CancelResult = CancelStatus.get("success",{})
        if CancelResult == 1:
            Rt.Message(f"Certificate ID: {CertificateID} has been cancelled.")
        elif CancelResult == 0:
            Rt.Message("ZeroSSL REST API request successful, however unable cancel certificate.")
        else:
            raise Exception()
    else:
        raise Exception()
# Runtime
try:
    CancelScript()
    exit(0)
except Exception:
    exit(1)

Revoke certificate

Note
ZeroSSL REST API require reason for certificate revoke (Optional). Only certificates with status issued can be revoked. If a certificate has already been successfully revoked you will get a success response nevertheless.

Demonstration script
Demonstration script named script_revoke.py.

import acme4zerossl as acme
from sys import exit

# Config
ConfigFilePath = "/Documents/script/acme4zerossl.config.json"
# Script
def RevokeScript():
    Rt = acme.Runtime(ConfigFilePath)
    Revoke = acme.ZeroSSL(ConfigFilePath)
    # Input certificate hash manually
    CertificateID = input("Please input certificate ID (hash): ")
    # Revoke certificate
    RevokeStatus = Revoke.ZeroSSLRevokeCA(CertificateID)
    # Status check
    if isinstance(RevokeStatus, bool):
        raise Exception()
    elif isinstance(RevokeStatus, int):
        raise Exception()
    elif isinstance(RevokeStatus, dict) and ("success") in RevokeStatus:
        RevokeResult = RevokeStatus.get("success","")
        if RevokeResult == 1:
            Rt.Message(f"Certificate ID: {CertificateID} has been revoked.")
        elif RevokeResult == 0:
            Rt.Message("ZeroSSL REST API request successful, however unable revoke certificate.")
        else:
            raise Exception()
    else:
        raise Exception()
# Runtime
try:
    RevokeScript()
    exit(0)
except Exception:
    exit(1)

Dependencies

Python version

Testing passed on above Python version:

  • 3.12.11
  • 3.9.6
  • 3.9.2
  • 3.7.3

Python module

  • logging
  • pathlib
  • json
  • datetime
  • textwrap
  • requests
  • subprocess
  • time
  • sys

License

General Public License -3.0

Resources

ZeroSSL API

Reference repository

About

Tiny script to issue and renew certs from ZeroSSL

Topics

Resources

License

Stars

Watchers

Forks

Languages