We’ll be covering the details of automating the deployment of TLS certificates to Panorama, for a single or multiple device groups, using a python script. GlobalProtect requires TLS certificates to work, and often organisations tend to use certificates with a 1-year validity so it’s just a manual process once in a year which isn’t a big deal. But at some point, certificates will no longer be signed with a validity of 1 year, perhaps down the line the maximum might be a month. And importing the TLS certificate once a month, might be annoying.
Certbot automatically requests new certificates and LE issues them, and after a renewal they can be imported in Panorama or a firewall – which can be completely automated and that’s the purpose of this post.
It isn’t only for GlobalProtect, though. If web servers are being hosted, SSL inbound inspection (decryption) should be configured. It requires the certificate (incl private key) of the web server to be present on the firewall (or Panorama) – in order to decrypt traffic and perform threat inspection.
You can use wildcard certificates as well, which makes it easier, especially if you have multiple subdomains.
Get the API-key
This is a mandatory step, because the API key will be used by the server. It’s recommended to create a new user with limited permissions.
Single device group/template and single certificate
The name of the device group and template is HA-Pair. Output logs can be found in the following directory: /var/log/certbot-panorama-example.log
It will also encrypt the private key on the fly, only encrypted private keys can be uploaded to a firewall/Panorama.
To create a “deploy hook”, place the script in the following directory: /etc/letsencrypt/renewal-hooks/deploy
The “deploy” method will only trigger the script if the TLS certificate has actually been renewed. Alternatively you can use the “post” and “pre” methods, to do that you have to place the script in the following directories: /etc/letsencrypt/renewal-hooks/post and /etc/letsencrypt/renewal-hooks/pre
“Post” basically means that it will trigger the script after renewing it, it ignores whether the TLS renewal was successful or not. And “pre” just means that it’ll trigger the script at the beginning of the TLS renewal request, it also doesn’t care about whether the TLS renewal was successful or not.
#!/usr/bin/env python3
# Certbot deploy hook – Upload renewed certificate to Panorama
import os
import subprocess
import requests
import urllib3
import logging
import time
# =========================
# Logging configuration
# =========================
LOG_FILE = "/var/log/certbot-panorama-example.log"
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logging.info("===== Certbot Panorama HA-Pair script started =====")
# Suppress HTTPS warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# =========================
# Configuration
# =========================
PANORAMA_HOST = "IP/FQDN"
API_KEY = "API-KEY"
CERT_NAME = "Wildcard-example-com-letsencrypt"
TEMPLATE_NAME = "HA-Pair"
DEVICE_GROUP = "HA-Pair"
CERT_PATH = "/etc/letsencrypt/live/example.com/fullchain.pem"
KEY_PATH = "/etc/letsencrypt/live/example.com/privkey.pem"
ENCRYPTED_KEY_PATH = "/tmp/examplecom/privkey_encrypted.pem"
PASSPHRASE = "password"
# =========================
# Encrypt private key
# =========================
def encrypt_private_key():
logging.info("Encrypting private key with passphrase")
os.makedirs(os.path.dirname(ENCRYPTED_KEY_PATH), exist_ok=True)
cmd = [
"/usr/bin/openssl", "rsa",
"-in", KEY_PATH,
"-aes256",
"-out", ENCRYPTED_KEY_PATH,
"-passout", f"pass:{PASSPHRASE}"
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate()
if proc.returncode != 0:
logging.error("Error encrypting private key")
logging.error(err.decode().strip())
return False
logging.info("Encrypted private key saved to %s", ENCRYPTED_KEY_PATH)
return True
# =========================
# Upload certificate / key
# =========================
def upload_file(file_path, is_cert=True, to_template=False):
label = "certificate" if is_cert else "private key"
target = f"template '{TEMPLATE_NAME}'" if to_template else "Panorama"
logging.info("Uploading %s '%s' to %s", label, CERT_NAME, target)
url = f"https://{PANORAMA_HOST}/api/"
with open(file_path, "rb") as f:
files = {
"file": (os.path.basename(file_path), f)
}
data = {
"type": "import",
"category": "certificate" if is_cert else "private-key",
"certificate-name": CERT_NAME,
"format": "pem",
"key": API_KEY
}
if to_template:
data["target-tpl"] = TEMPLATE_NAME
if not is_cert:
data["passphrase"] = PASSPHRASE
try:
response = requests.post(url, data=data, files=files, verify=False, timeout=60)
logging.info("%s upload HTTP status: %s", label.capitalize(), response.status_code)
logging.debug("Response: %s", response.text)
if '<response status="success"' in response.text:
logging.info("%s upload successful to %s", label.capitalize(), target)
return True
else:
logging.error("%s upload FAILED to %s", label.capitalize(), target)
return False
except Exception as e:
logging.exception("Exception during %s upload to %s", label, target)
return False
# =========================
# Commit to Panorama
# =========================
def commit_to_panorama():
logging.info("Committing configuration on Panorama")
url = f"https://{PANORAMA_HOST}/api/"
cmd_xml = "<commit><force></force></commit>"
params = {
"type": "commit",
"cmd": cmd_xml,
"key": API_KEY
}
try:
response = requests.post(url, data=params, verify=False, timeout=60)
logging.info("Commit HTTP status: %s", response.status_code)
logging.debug("Response: %s", response.text)
return '<response status="success"' in response.text
except Exception:
logging.exception("Commit to Panorama failed")
return False
# =========================
# Push to device-group
# =========================
def push_to_devices():
logging.info("Pushing config to device-group '%s'", DEVICE_GROUP)
url = f"https://{PANORAMA_HOST}/api/"
cmd_xml = f"""
<commit-all>
<shared-policy>
<device-group>
<entry name="{DEVICE_GROUP}"/>
</device-group>
</shared-policy>
</commit-all>
"""
params = {
"type": "commit",
"action": "all",
"cmd": cmd_xml,
"key": API_KEY
}
try:
response = requests.post(url, data=params, verify=False, timeout=120)
logging.info("Push HTTP status: %s", response.status_code)
logging.debug("Response: %s", response.text)
return '<response status="success"' in response.text
except Exception:
logging.exception("Push to device-group failed")
return False
# =========================
# Main
# =========================
def main():
try:
if not encrypt_private_key():
logging.error("Private key encryption failed, aborting")
return
if not upload_file(CERT_PATH, is_cert=True, to_template=False):
logging.error("Certificate upload to Panorama failed")
return
if not upload_file(ENCRYPTED_KEY_PATH, is_cert=False, to_template=False):
logging.error("Private key upload to Panorama failed")
return
if not upload_file(CERT_PATH, is_cert=True, to_template=True):
logging.error("Certificate upload to template failed")
return
if not upload_file(ENCRYPTED_KEY_PATH, is_cert=False, to_template=True):
logging.error("Private key upload to template failed")
return
if not commit_to_panorama():
logging.error("Commit to Panorama failed")
return
time.sleep(5)
if not push_to_devices():
logging.error("Push to device-group failed")
return
logging.info("All steps completed successfully")
logging.info("===== Certbot Panorama HA-Pair script finished =====")
except Exception:
logging.exception("Unhandled exception in main()")
if __name__ == "__main__":
main()
Multiple device groups and single certificate
I would highly recommend to use wildcard certificates here, it simplifies the process a lot and the configuration doesn’t get more complicated than necessary.
Logging can be found in the following directory: /var/log/certbot-panorama-intranet.log
#!/usr/bin/env python3
import os
import subprocess
import requests
import urllib3
import time
import logging
# =========================
# Logging configuration
# =========================
LOG_FILE = "/var/log/certbot-panorama-intranet.log"
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logging.info("===== Certbot Panorama intranet script started =====")
# Suppress HTTPS warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# =========================
# Configuration
# =========================
PANORAMA_HOST = "panorama.example.com"
API_KEY = "API-Key"
CERT_NAME = "Wildcard-intranet-letsencrypt"
TEMPLATE_NAMES = [
"HA-Pair",
"PA-410",
"firewall-c",
"firewall-d",
"firewall-e"
]
DEVICE_GROUPS = [
"HA-Pair",
"PA-410",
"firewall-c",
"firewall-d",
"firewall-e"
]
CERT_PATH = "/etc/letsencrypt/live/intranet.example.com/fullchain.pem"
KEY_PATH = "/etc/letsencrypt/live/intranet.example.com/privkey.pem"
ENCRYPTED_KEY_PATH = "/tmp/intranetexamplecom/privkey_encrypted.pem"
PASSPHRASE = "password"
# =========================
# Encrypt private key
# =========================
def encrypt_private_key():
logging.info("Encrypting private key with passphrase")
os.makedirs(os.path.dirname(ENCRYPTED_KEY_PATH), exist_ok=True)
cmd = [
"openssl", "rsa",
"-in", KEY_PATH,
"-aes256",
"-out", ENCRYPTED_KEY_PATH,
"-passout", f"pass:{PASSPHRASE}"
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate()
if proc.returncode != 0:
logging.error("Error encrypting private key")
logging.error(err.decode().strip())
return False
logging.info("Encrypted private key saved to %s", ENCRYPTED_KEY_PATH)
return True
# =========================
# Upload certificate / key
# =========================
def upload_file(file_path, is_cert=True, template=None):
label = "certificate" if is_cert else "private key"
target = f"template '{template}'" if template else "Panorama"
logging.info("Uploading %s '%s' to %s", label, CERT_NAME, target)
url = f"https://{PANORAMA_HOST}/api/"
with open(file_path, "rb") as f:
files = {
"file": (os.path.basename(file_path), f)
}
data = {
"type": "import",
"category": "certificate" if is_cert else "private-key",
"certificate-name": CERT_NAME,
"format": "pem",
"key": API_KEY
}
if template:
data["target-tpl"] = template
if not is_cert:
data["passphrase"] = PASSPHRASE
response = requests.post(
url,
data=data,
files=files,
verify=False,
timeout=60
)
logging.info("%s upload HTTP status: %s", label.capitalize(), response.status_code)
logging.debug("Response: %s", response.text)
if '<response status="success"' in response.text:
logging.info("%s upload successful to %s", label.capitalize(), target)
return True
else:
logging.error("%s upload FAILED to %s", label.capitalize(), target)
return False
# =========================
# Commit to Panorama
# =========================
def commit_to_panorama():
logging.info("Committing configuration on Panorama")
url = f"https://{PANORAMA_HOST}/api/"
cmd_xml = "<commit><force></force></commit>"
params = {
"type": "commit",
"cmd": cmd_xml,
"key": API_KEY
}
response = requests.post(url, data=params, verify=False, timeout=60)
logging.info("Commit HTTP status: %s", response.status_code)
logging.debug("Response: %s", response.text)
return '<response status="success"' in response.text
# =========================
# Push to device group
# =========================
def push_to_devices(device_group):
logging.info("Pushing config to device-group '%s'", device_group)
url = f"https://{PANORAMA_HOST}/api/"
cmd_xml = f"""
<commit-all>
<shared-policy>
<device-group>
<entry name="{device_group}"/>
</device-group>
</shared-policy>
</commit-all>
"""
params = {
"type": "commit",
"action": "all",
"cmd": cmd_xml,
"key": API_KEY
}
response = requests.post(url, data=params, verify=False, timeout=120)
logging.info("Push HTTP status: %s", response.status_code)
logging.debug("Response: %s", response.text)
return '<response status="success"' in response.text
# =========================
# Main
# =========================
def main():
if not encrypt_private_key():
logging.error("Private key encryption failed, aborting")
return
if not upload_file(CERT_PATH, is_cert=True, template=None):
logging.error("Certificate upload to Panorama failed")
return
if not upload_file(ENCRYPTED_KEY_PATH, is_cert=False, template=None):
logging.error("Private key upload to Panorama failed")
return
for tpl in TEMPLATE_NAMES:
if not upload_file(CERT_PATH, is_cert=True, template=tpl):
logging.error("Certificate upload failed for template %s", tpl)
return
if not upload_file(ENCRYPTED_KEY_PATH, is_cert=False, template=tpl):
logging.error("Private key upload failed for template %s", tpl)
return
logging.info("Certificate and private key uploaded to Panorama and all templates")
if not commit_to_panorama():
logging.error("Commit to Panorama failed")
return
time.sleep(5)
for dg in DEVICE_GROUPS:
if not push_to_devices(dg):
logging.error("Push to device-group %s failed", dg)
return
logging.info("All steps completed successfully")
logging.info("===== Certbot Panorama intranet script finished =====")
if __name__ == "__main__":
main()