- Introduced `HAPROXY_STATS_CORS_ORIGIN` environment variable for enabling CORS in HAProxy stats. - Updated HAProxy configuration template to handle CORS preflight and response headers. - Added tests for static configuration mode with CORS enabled. - Updated documentation to include new CORS configuration details and examples.
710 lines
29 KiB
Python
710 lines
29 KiB
Python
import logging
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from multiprocessing import Process
|
|
from typing import Final
|
|
|
|
import psutil
|
|
import requests
|
|
from OpenSSL import crypto
|
|
|
|
|
|
class ContainerEnv:
|
|
@staticmethod
|
|
def read(yaml_config=None):
|
|
"""
|
|
Read configuration from environment variables, optionally merged with YAML config.
|
|
|
|
Args:
|
|
yaml_config: Optional dict from YAML file (for static mode). YAML values take precedence.
|
|
|
|
Returns:
|
|
Dict with configuration settings
|
|
"""
|
|
# Convert YAML config to environment variables first (if provided)
|
|
if yaml_config:
|
|
ContainerEnv._yaml_to_env(yaml_config)
|
|
|
|
env_vars = {
|
|
"customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False,
|
|
"ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default'
|
|
}
|
|
|
|
if os.getenv("HAPROXY_PASSWORD"):
|
|
env_vars["stats"] = {
|
|
"username": os.getenv("HAPROXY_USERNAME") if os.getenv("HAPROXY_USERNAME") else "admin",
|
|
"password": os.getenv("HAPROXY_PASSWORD"),
|
|
"port": os.getenv("HAPROXY_STATS_PORT") if os.getenv("HAPROXY_STATS_PORT") else "1936",
|
|
"cors_origin": os.getenv("HAPROXY_STATS_CORS_ORIGIN", ""),
|
|
}
|
|
|
|
env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv(
|
|
"EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy"
|
|
|
|
env_vars["logLevel"] = {
|
|
"easyhaproxy": os.getenv("EASYHAPROXY_LOG_LEVEL") if os.getenv(
|
|
"EASYHAPROXY_LOG_LEVEL") else Functions.DEBUG,
|
|
"haproxy": os.getenv("HAPROXY_LOG_LEVEL") if os.getenv("HAPROXY_LOG_LEVEL") else Functions.INFO,
|
|
"certbot": os.getenv("CERTBOT_LOG_LEVEL") if os.getenv("CERTBOT_LOG_LEVEL") else Functions.DEBUG,
|
|
}
|
|
|
|
env_vars["certbot"] = {
|
|
"autoconfig": os.getenv("EASYHAPROXY_CERTBOT_AUTOCONFIG", ""),
|
|
"email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""),
|
|
"server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False),
|
|
"eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""),
|
|
"eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""),
|
|
"retry_count": int(os.getenv("EASYHAPROXY_CERTBOT_RETRY_COUNT", 60)),
|
|
"preferred_challenges": os.getenv("EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES", "http"),
|
|
"manual_auth_hook": os.getenv("EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK", False),
|
|
}
|
|
|
|
if env_vars["certbot"]["autoconfig"] != "" and not env_vars["certbot"]["server"] and env_vars["certbot"]["email"] != "":
|
|
if env_vars["certbot"]["autoconfig"] == "letsencrypt":
|
|
env_vars["certbot"]["server"] = "https://acme-v02.api.letsencrypt.org/directory"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "letsencrypt_test":
|
|
env_vars["certbot"]["server"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "buypass":
|
|
env_vars["certbot"]["server"] = "https://api.buypass.com/acme/directory"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "buypass_test":
|
|
env_vars["certbot"]["server"] = "https://api.test4.buypass.no/acme/directory"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "sslcom_rca":
|
|
env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-rsa"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "sslcom_ecc":
|
|
env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-ecc"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "google":
|
|
env_vars["certbot"]["server"] = "https://dv.acme-v02.api.pki.goog/directory"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "google_test":
|
|
env_vars["certbot"]["server"] = "https://dv.acme-v02.test-api.pki.goog/directory"
|
|
|
|
if env_vars["certbot"]["autoconfig"] == "zerossl":
|
|
url = "https://api.zerossl.com/acme/eab-credentials-email"
|
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
data = "email=" + env_vars["certbot"]["email"]
|
|
resp = requests.post(url, headers=headers, data=data).json()
|
|
|
|
if resp["success"]:
|
|
env_vars["certbot"]["server"] = "https://acme.zerossl.com/v2/DV90"
|
|
env_vars["certbot"]["eab_kid"] = os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] = resp["eab_kid"]
|
|
env_vars["certbot"]["eab_hmac_key"] = os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = resp["eab_hmac_key"]
|
|
else:
|
|
del os.environ["EASYHAPROXY_CERTBOT_EMAIL"]
|
|
logger_certbot.error("Could not obtain ZeroSSL credentials " + resp["error"]["type"])
|
|
|
|
os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"]
|
|
|
|
# Plugin configuration
|
|
env_vars["plugins"] = {
|
|
"abort_on_error": os.getenv("EASYHAPROXY_PLUGINS_ABORT_ON_ERROR", "false").lower() == "true",
|
|
"enabled": os.getenv("EASYHAPROXY_PLUGINS_ENABLED", "").split(",") if os.getenv("EASYHAPROXY_PLUGINS_ENABLED") else [],
|
|
"config": {} # Individual plugin configs from env vars
|
|
}
|
|
|
|
# Parse individual plugin configs (e.g., EASYHAPROXY_PLUGIN_CLOUDFLARE_*)
|
|
for key, value in os.environ.items():
|
|
if key.startswith("EASYHAPROXY_PLUGIN_"):
|
|
parts = key.split("_", 3) # ['EASYHAPROXY', 'PLUGIN', 'NAME', 'KEY']
|
|
if len(parts) >= 4:
|
|
plugin_name = parts[2].lower()
|
|
config_key = "_".join(parts[3:]).lower()
|
|
env_vars["plugins"]["config"].setdefault(plugin_name, {})
|
|
env_vars["plugins"]["config"][plugin_name][config_key] = value
|
|
|
|
# Ingress status update configuration
|
|
env_vars["update_ingress_status"] = os.getenv("EASYHAPROXY_UPDATE_INGRESS_STATUS", "true").lower() == "true"
|
|
env_vars["deployment_mode"] = os.getenv("EASYHAPROXY_DEPLOYMENT_MODE", "auto")
|
|
env_vars["external_hostname"] = os.getenv("EASYHAPROXY_EXTERNAL_HOSTNAME", "")
|
|
env_vars["ingress_status_update_interval"] = int(os.getenv("EASYHAPROXY_STATUS_UPDATE_INTERVAL", "30"))
|
|
|
|
return env_vars
|
|
|
|
@staticmethod
|
|
def _yaml_to_env(yaml_config):
|
|
"""Convert YAML configuration to environment variables"""
|
|
|
|
# Convert customerrors
|
|
if 'customerrors' in yaml_config:
|
|
os.environ['HAPROXY_CUSTOMERRORS'] = 'true' if yaml_config['customerrors'] else 'false'
|
|
|
|
# Convert ssl_mode
|
|
if 'ssl_mode' in yaml_config:
|
|
os.environ['EASYHAPROXY_SSL_MODE'] = str(yaml_config['ssl_mode'])
|
|
|
|
# Convert stats
|
|
if 'stats' in yaml_config:
|
|
stats = yaml_config['stats']
|
|
if 'username' in stats:
|
|
os.environ['HAPROXY_USERNAME'] = str(stats['username'])
|
|
if 'password' in stats:
|
|
os.environ['HAPROXY_PASSWORD'] = str(stats['password'])
|
|
if 'port' in stats:
|
|
os.environ['HAPROXY_STATS_PORT'] = str(stats['port'])
|
|
if 'cors_origin' in stats:
|
|
os.environ['HAPROXY_STATS_CORS_ORIGIN'] = str(stats['cors_origin'])
|
|
|
|
# Convert logLevel
|
|
if 'logLevel' in yaml_config:
|
|
log_level = yaml_config['logLevel']
|
|
for source, level in log_level.items():
|
|
os.environ[source.upper() + '_LOG_LEVEL'] = str(level)
|
|
|
|
# Convert certbot
|
|
if 'certbot' in yaml_config:
|
|
certbot = yaml_config['certbot']
|
|
for config, value in certbot.items():
|
|
os.environ['EASYHAPROXY_CERTBOT_' + config.upper()] = str(value)
|
|
|
|
# Convert plugins
|
|
if 'plugins' in yaml_config:
|
|
plugins = yaml_config['plugins']
|
|
|
|
# Convert enabled list
|
|
if 'enabled' in plugins:
|
|
enabled_list = plugins['enabled'] if isinstance(plugins['enabled'], list) else [plugins['enabled']]
|
|
os.environ['EASYHAPROXY_PLUGINS_ENABLED'] = ','.join(enabled_list)
|
|
|
|
# Convert abort_on_error
|
|
if 'abort_on_error' in plugins:
|
|
os.environ['EASYHAPROXY_PLUGINS_ABORT_ON_ERROR'] = 'true' if plugins['abort_on_error'] else 'false'
|
|
|
|
# Convert plugin configs
|
|
if 'config' in plugins:
|
|
for plugin_name, plugin_config in plugins['config'].items():
|
|
for config_key, config_value in plugin_config.items():
|
|
# Convert to env var format: EASYHAPROXY_PLUGIN_<NAME>_<KEY>
|
|
env_key = f"EASYHAPROXY_PLUGIN_{plugin_name.upper()}_{config_key.upper()}"
|
|
|
|
# Convert list values to comma-separated strings
|
|
if isinstance(config_value, list):
|
|
env_value = ','.join(str(v) for v in config_value)
|
|
else:
|
|
env_value = str(config_value)
|
|
|
|
os.environ[env_key] = env_value
|
|
|
|
|
|
class Functions:
|
|
HAPROXY_LOG: Final[str] = "HAPROXY"
|
|
EASYHAPROXY_LOG: Final[str] = "EASYHAPROXY"
|
|
CERTBOT_LOG: Final[str] = "CERTBOT"
|
|
INIT_LOG: Final[str] = "INIT"
|
|
|
|
TRACE: Final[str] = "TRACE"
|
|
DEBUG: Final[str] = "DEBUG"
|
|
INFO: Final[str] = "INFO"
|
|
WARN: Final[str] = "WARN"
|
|
ERROR: Final[str] = "ERROR"
|
|
FATAL: Final[str] = "FATAL"
|
|
|
|
@staticmethod
|
|
def setup_log(source):
|
|
level = os.getenv(f"{source.name.upper()}_LOG_LEVEL", "").upper()
|
|
level_importance = {
|
|
Functions.TRACE: logging.DEBUG,
|
|
Functions.DEBUG: logging.DEBUG,
|
|
Functions.INFO: logging.INFO,
|
|
Functions.WARN: logging.WARNING,
|
|
Functions.ERROR: logging.ERROR,
|
|
Functions.FATAL: logging.FATAL
|
|
}
|
|
selected_level = level_importance[level] if level in level_importance else logging.INFO
|
|
|
|
log_source_handler = logging.StreamHandler(sys.stdout)
|
|
log_source_formatter = logging.Formatter('%(name)s [%(asctime)s] %(levelname)s - %(message)s')
|
|
log_source_handler.setFormatter(log_source_formatter)
|
|
log_source_handler.addFilter(SingleLineNonEmptyFilter())
|
|
source.setLevel(selected_level)
|
|
source.addHandler(log_source_handler)
|
|
return selected_level
|
|
|
|
@staticmethod
|
|
def load(filename):
|
|
with open(filename) as content_file:
|
|
return content_file.read()
|
|
|
|
@staticmethod
|
|
def save(filename, contents):
|
|
with open(filename, 'w') as file:
|
|
file.write(contents)
|
|
|
|
@staticmethod
|
|
def run_bash(log_source, command, log_output=True, return_result=True):
|
|
if not isinstance(command, (list, tuple)):
|
|
command = shlex.split(command)
|
|
|
|
try:
|
|
process = subprocess.Popen(command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
universal_newlines=True)
|
|
|
|
output = []
|
|
|
|
while True:
|
|
line = process.stdout.readline().rstrip()
|
|
error_line = process.stderr.readline().rstrip()
|
|
output.append(line) if return_result else None
|
|
log_source.info(line) if log_output and len(line) > 0 else None
|
|
log_source.warning(error_line) if len(error_line) > 0 else None
|
|
return_code = process.poll()
|
|
if return_code is not None:
|
|
lines = []
|
|
error_line = process.stderr.readline().rstrip()
|
|
for line in process.stdout.readlines():
|
|
output.append(line.rstrip()) if return_result else None
|
|
lines.append(line.rstrip())
|
|
log_source.info(lines) if log_output and len(lines) > 0 else None
|
|
log_source.warning(error_line) if len(error_line) > 0 else None
|
|
break
|
|
|
|
return [return_code, output]
|
|
except Exception as e:
|
|
log_source.error(f"{e}")
|
|
return [-99, e]
|
|
|
|
|
|
class classproperty:
|
|
"""Decorator for class-level properties."""
|
|
def __init__(self, func):
|
|
self.func = func
|
|
|
|
def __get__(self, obj, owner):
|
|
return self.func(owner)
|
|
|
|
|
|
class Consts:
|
|
"""Configuration constants with dynamic path resolution based on EASYHAPROXY_BASE_PATH."""
|
|
_base_path = None
|
|
|
|
@classproperty
|
|
def base_path(cls):
|
|
"""Base directory for all EasyHAProxy files."""
|
|
if cls._base_path is None:
|
|
cls._base_path = os.getenv("EASYHAPROXY_BASE_PATH", "/etc/easyhaproxy")
|
|
return cls._base_path
|
|
|
|
@classmethod
|
|
def reset(cls):
|
|
"""Reset cached base path to pick up environment variable changes."""
|
|
cls._base_path = None
|
|
|
|
@classproperty
|
|
def easyhaproxy_config(cls):
|
|
"""Path to static configuration file."""
|
|
return f"{cls.base_path}/static/config.yml"
|
|
|
|
@classproperty
|
|
def haproxy_config(cls):
|
|
"""Path to generated HAProxy configuration file."""
|
|
return f"{cls.base_path}/haproxy/haproxy.cfg"
|
|
|
|
@classproperty
|
|
def custom_config_folder(cls):
|
|
"""Path to custom HAProxy config snippets directory."""
|
|
return f"{cls.base_path}/haproxy/conf.d"
|
|
|
|
@classproperty
|
|
def certs_certbot(cls):
|
|
"""Path to Certbot/ACME certificates directory."""
|
|
return f"{cls.base_path}/certs/certbot"
|
|
|
|
@classproperty
|
|
def certs_haproxy(cls):
|
|
"""Path to user-provided certificates directory."""
|
|
return f"{cls.base_path}/certs/haproxy"
|
|
|
|
|
|
class DaemonizeHAProxy:
|
|
HAPROXY_START: Final[str] = "start"
|
|
HAPROXY_RELOAD: Final[str] = "reload"
|
|
|
|
def __init__(self, custom_config_folder = None):
|
|
self.process = None
|
|
self.thread = None
|
|
self.sleep_secs = None
|
|
self.custom_config_folder = custom_config_folder if custom_config_folder is not None else Consts.custom_config_folder
|
|
|
|
def haproxy(self, action):
|
|
error = self.__prepare(self.get_haproxy_command(action), action)
|
|
|
|
if error or self.process is None:
|
|
logger_haproxy.fatal(f"Failed to start HAProxy ({action}). Exiting.")
|
|
import sys
|
|
sys.exit(1)
|
|
|
|
self.thread = Process(target=self.__start, args=())
|
|
self.thread.start()
|
|
|
|
def get_haproxy_command(self, action, pid_file="/run/haproxy.pid"):
|
|
custom_config_files = ""
|
|
if len(list(self.get_custom_config_files().keys())) != 0:
|
|
custom_config_files = f"-f {self.custom_config_folder}"
|
|
|
|
if action == DaemonizeHAProxy.HAPROXY_START or not os.path.exists(pid_file):
|
|
return f"/usr/sbin/haproxy -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -S /var/run/haproxy.sock"
|
|
else:
|
|
return_code, output = Functions().run_bash(logger_haproxy, f"cat {pid_file}", log_output=False)
|
|
pid = "".join(output).rstrip()
|
|
if psutil.pid_exists(int(pid)):
|
|
return f"/usr/sbin/haproxy -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -x /var/run/haproxy.sock -sf {pid}"
|
|
else:
|
|
os.unlink(pid_file)
|
|
logger_haproxy.warning(
|
|
f"PID file {pid_file} does not exist. Restarting haproxy instead of reload."
|
|
)
|
|
return self.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START, pid_file)
|
|
|
|
def __validate_config(self):
|
|
"""Validate HAProxy configuration before starting."""
|
|
validation_cmd = ["haproxy", "-c", "-f", Consts.haproxy_config]
|
|
|
|
# Add custom config files if they exist
|
|
for config_file in self.get_custom_config_files().keys():
|
|
validation_cmd.extend(["-f", config_file])
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
validation_cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return result.stderr if result.stderr else result.stdout
|
|
return None
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return "HAProxy configuration validation timed out"
|
|
except Exception as e:
|
|
return f"Error validating configuration: {e}"
|
|
|
|
def __prepare(self, command, action=None):
|
|
if not isinstance(command, (list, tuple)):
|
|
command = shlex.split(command)
|
|
|
|
# Validate HAProxy config before starting (but not on reload - HAProxy validates itself during reload)
|
|
if action == DaemonizeHAProxy.HAPROXY_START:
|
|
validation_error = self.__validate_config()
|
|
if validation_error:
|
|
logger_haproxy.fatal(f"HAProxy configuration validation failed:\n{validation_error}")
|
|
return validation_error
|
|
|
|
try:
|
|
logger_haproxy.debug(f"HAPROXY command: {command}")
|
|
self.process = subprocess.Popen(command,
|
|
shell=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
bufsize=-1,
|
|
universal_newlines=True)
|
|
return None
|
|
|
|
except Exception as e:
|
|
error_msg = f"Failed to start HAProxy process: {e}"
|
|
logger_haproxy.error(error_msg)
|
|
return error_msg
|
|
|
|
def __start(self):
|
|
try:
|
|
with self.process.stdout:
|
|
for line in iter(self.process.stdout.readline, b''):
|
|
logger_haproxy.info(line.rstrip())
|
|
|
|
return_code = self.process.wait()
|
|
logger_haproxy.debug(f"Return code {return_code}")
|
|
|
|
except Exception as e:
|
|
logger_haproxy.error(f"{e}")
|
|
|
|
def is_alive(self):
|
|
return self.thread.is_alive()
|
|
|
|
def kill(self):
|
|
self.process.kill()
|
|
self.thread.kill()
|
|
|
|
def terminate(self):
|
|
self.process.terminate()
|
|
self.thread.terminate()
|
|
|
|
def sleep(self):
|
|
if self.sleep_secs is None:
|
|
try:
|
|
self.sleep_secs = int(os.getenv("EASYHAPROXY_REFRESH_CONF", "10"))
|
|
except ValueError:
|
|
self.sleep_secs = 10
|
|
|
|
time.sleep(self.sleep_secs)
|
|
|
|
def get_custom_config_files(self):
|
|
if not os.path.exists(self.custom_config_folder):
|
|
return {}
|
|
|
|
files = {}
|
|
for file in os.listdir(self.custom_config_folder):
|
|
if file.endswith(".cfg"):
|
|
files[os.path.join(self.custom_config_folder, file)] = os.path.getmtime(os.path.join(self.custom_config_folder, file))
|
|
return dict(sorted(files.items(), key=lambda t: t[0]))
|
|
|
|
|
|
class Certbot:
|
|
def __init__(self, certs):
|
|
env = ContainerEnv.read()
|
|
|
|
self.certs = certs
|
|
self.email = env["certbot"]["email"]
|
|
self.acme_server = self.set_acme_server(env["certbot"]["server"])
|
|
self.eab_kid = self.set_eab_kid(env["certbot"]["eab_kid"])
|
|
self.eab_hmac_key = self.set_eab_hmac_key(env["certbot"]["eab_hmac_key"])
|
|
self.freeze_issue = {}
|
|
self.retry_count = env["certbot"]["retry_count"]
|
|
self.certbot_preferred_challenges = env["certbot"]["preferred_challenges"]
|
|
self.certbot_manual_auth_hook = env["certbot"]["manual_auth_hook"]
|
|
|
|
@staticmethod
|
|
def set_acme_server(acme_server):
|
|
if not acme_server:
|
|
return ""
|
|
if acme_server.lower() == "staging":
|
|
return "--staging"
|
|
elif acme_server.lower().startswith("http"):
|
|
return "--server " + acme_server
|
|
else:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def set_eab_kid(eab_kid):
|
|
if eab_kid != "":
|
|
return f'--eab-kid "{eab_kid}"'
|
|
else:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def set_eab_hmac_key(eab_hmac_key):
|
|
if eab_hmac_key != "":
|
|
return f'--eab-hmac-key "{eab_hmac_key}"'
|
|
else:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def check_acme_environment_ready(email, acme_server):
|
|
"""
|
|
Check if ACME environment is ready for certificate operations.
|
|
|
|
Args:
|
|
email: EASYHAPROXY_CERTBOT_EMAIL value
|
|
acme_server: Processed ACME server string from set_acme_server()
|
|
|
|
Returns:
|
|
tuple: (is_ready: bool, error_message: str)
|
|
"""
|
|
# Check 1: Email configured
|
|
if not email or email == "":
|
|
return False, "ACME email not configured (EASYHAPROXY_CERTBOT_EMAIL)"
|
|
|
|
# Check 2: ACME server configured
|
|
if not acme_server or acme_server == "":
|
|
return False, "ACME server not configured (EASYHAPROXY_CERTBOT_SERVER)"
|
|
|
|
# Check 3: ACME server reachability (if URL provided)
|
|
if "--server " in acme_server:
|
|
server_url = acme_server.replace("--server ", "")
|
|
try:
|
|
# Use 10s timeout, respect REQUESTS_CA_BUNDLE for Pebble CA
|
|
response = requests.get(server_url, timeout=10, verify=os.getenv("REQUESTS_CA_BUNDLE", True))
|
|
if response.status_code != 200:
|
|
return False, f"ACME server {server_url} returned HTTP {response.status_code}"
|
|
|
|
# Validate ACME directory structure (RFC 8555)
|
|
data = response.json()
|
|
if "newAccount" not in data:
|
|
return False, f"ACME server {server_url} returned invalid ACME directory"
|
|
except requests.exceptions.RequestException as e:
|
|
return False, f"ACME server {server_url} not reachable: {str(e)}"
|
|
except Exception as e:
|
|
return False, f"ACME server validation failed: {str(e)}"
|
|
|
|
return True, ""
|
|
|
|
def check_certificates(self, hosts):
|
|
if self.email == "" or len(hosts) == 0:
|
|
return False
|
|
|
|
try:
|
|
request_certs = []
|
|
renew_certs = []
|
|
for host in hosts:
|
|
cert_status = self.get_certificate_status(host)
|
|
host_arg = f'-d {host}'
|
|
if cert_status == "ok" or cert_status == "error":
|
|
continue
|
|
elif host in self.freeze_issue:
|
|
freeze_count = self.freeze_issue.pop(host, 0)
|
|
if freeze_count > 0:
|
|
logger_certbot.debug(f"Waiting freezing period ({freeze_count}) for {host} due previous errors")
|
|
self.freeze_issue[host] = freeze_count-1
|
|
elif cert_status == "not_found" or cert_status == "expired":
|
|
logger_certbot.debug(f"[{cert_status}] Request new certificate for {host}")
|
|
request_certs.append(host_arg)
|
|
elif cert_status == "expiring":
|
|
logger_certbot.debug(f"[{cert_status}] Renew certificate for {host}")
|
|
renew_certs.append(host_arg)
|
|
|
|
certbot_certonly = ('/usr/bin/certbot certonly {acme_server}'
|
|
' --config-dir {base_path}/certs'
|
|
' --work-dir {base_path}/certs/work'
|
|
' --logs-dir {base_path}/certs/logs'
|
|
' --preferred-challenges {challenge}'
|
|
' --agree-tos'
|
|
' --issuance-timeout 90'
|
|
' --no-eff-email'
|
|
' --non-interactive'
|
|
' --max-log-backups=0'
|
|
' {eab_kid} {eab_hmac_key}'
|
|
' {certs} --email {email}'.format(eab_kid=self.eab_kid,
|
|
eab_hmac_key=self.eab_hmac_key,
|
|
certs=' '.join(request_certs),
|
|
email=self.email,
|
|
challenge=self.certbot_preferred_challenges,
|
|
acme_server=self.acme_server,
|
|
base_path=Consts.base_path)
|
|
)
|
|
|
|
if 'http' in self.certbot_preferred_challenges:
|
|
certbot_certonly += (' --http-01-port 2080'
|
|
' --standalone'
|
|
)
|
|
|
|
if self.certbot_manual_auth_hook:
|
|
certbot_certonly += f' --manual --manual-auth-hook \'{self.certbot_manual_auth_hook}\''
|
|
|
|
if logger_certbot.level == logging.DEBUG:
|
|
certbot_certonly += ' -v'
|
|
|
|
logger_certbot.debug(f"certbot_certonly: {certbot_certonly}")
|
|
|
|
ret_reload = False
|
|
return_code_issue = 0
|
|
return_code_renew = 0
|
|
if len(request_certs) > 0:
|
|
return_code_issue, output = Functions.run_bash(logger_certbot, certbot_certonly, return_result=False)
|
|
ret_reload = True
|
|
|
|
if len(renew_certs) > 0:
|
|
certbot_renew = f"/usr/bin/certbot renew --config-dir {Consts.base_path}/certs --work-dir {Consts.base_path}/certs/work --logs-dir {Consts.base_path}/certs/logs"
|
|
return_code_renew, output = Functions.run_bash(logger_certbot, certbot_renew, return_result=False)
|
|
ret_reload = True
|
|
|
|
if ret_reload:
|
|
self.find_live_certificates()
|
|
|
|
if return_code_issue != 0:
|
|
self.find_missing_certificates(request_certs)
|
|
if return_code_renew != 0:
|
|
self.find_missing_certificates(renew_certs)
|
|
|
|
return ret_reload
|
|
except Exception as e:
|
|
logger_certbot.error(f"{e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def merge_certificate(cert, key, filename):
|
|
Functions.save(filename, cert + key)
|
|
|
|
def find_live_certificates(self):
|
|
certbot_certs = f"{Consts.base_path}/certs/live/"
|
|
if not os.path.exists(certbot_certs):
|
|
return
|
|
for item in os.listdir(certbot_certs):
|
|
path = os.path.join(certbot_certs, item)
|
|
if os.path.isdir(path):
|
|
cert = Functions.load(os.path.join(path, "cert.pem"))
|
|
key = Functions.load(os.path.join(path, "privkey.pem"))
|
|
filename = f"{self.certs}/{item}.pem"
|
|
self.merge_certificate(cert, key, filename)
|
|
|
|
def get_certificate_status(self, host):
|
|
current_time = time.time()
|
|
filename = f"{self.certs}/{host}.pem"
|
|
if not os.path.exists(filename):
|
|
return "not_found"
|
|
|
|
try:
|
|
with open(filename, 'rb') as file:
|
|
certificate_str = file.read()
|
|
certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_str)
|
|
expiration_after = datetime.strptime(certificate.get_notAfter().decode()[:-1], '%Y%m%d%H%M%S').timestamp()
|
|
if current_time >= expiration_after:
|
|
return "expired"
|
|
elif (expiration_after - current_time) // (24 * 3600) <= 15:
|
|
return "expiring"
|
|
except Exception as e:
|
|
logger_certbot.error(f"Certificate {host} error {e}")
|
|
return "error"
|
|
|
|
return "ok"
|
|
|
|
def find_missing_certificates(self, hosts):
|
|
for host in hosts:
|
|
if host.startswith("-d "):
|
|
host = host[3:]
|
|
cert_status = self.get_certificate_status(host)
|
|
if cert_status != "ok":
|
|
self.freeze_issue[host] = self.retry_count
|
|
logger_certbot.debug(f"Freeze issuing ssl for {host} due failure. The certificate is {cert_status}")
|
|
|
|
|
|
|
|
class SingleLineNonEmptyFilter(logging.Filter):
|
|
"""
|
|
Logging filter that ensures messages are single-line and non-empty.
|
|
- Collapses newlines into spaces and strips surrounding whitespace.
|
|
- Drops the record if the resulting message is empty.
|
|
"""
|
|
def filter(self, record: logging.LogRecord) -> int:
|
|
try:
|
|
msg = record.getMessage()
|
|
except Exception:
|
|
# If formatting fails, drop the record
|
|
return 0
|
|
|
|
# Convert any non-string to string representation
|
|
if not isinstance(msg, str):
|
|
msg = str(msg)
|
|
|
|
# Collapse multi-line to single line and trim
|
|
sanitized = " ".join(msg.splitlines()).strip()
|
|
|
|
if sanitized == "":
|
|
return 0
|
|
|
|
# If we changed the message, update the record and clear args
|
|
if sanitized != record.getMessage():
|
|
record.msg = sanitized
|
|
record.args = ()
|
|
return 1
|
|
|
|
|
|
# ####################################################################################################################
|
|
# Setup Global Log
|
|
logger_init = logging.getLogger(Functions.INIT_LOG)
|
|
logger_haproxy = logging.getLogger(Functions.HAPROXY_LOG)
|
|
logger_easyhaproxy = logging.getLogger(Functions.EASYHAPROXY_LOG)
|
|
logger_certbot = logging.getLogger(Functions.CERTBOT_LOG)
|
|
Functions.setup_log(logger_init)
|
|
Functions.setup_log(logger_haproxy)
|
|
Functions.setup_log(logger_easyhaproxy)
|
|
Functions.setup_log(logger_certbot)
|