diff --git a/install/install.py b/install/install.py index 071cff5cf..ac0224a1e 100644 --- a/install/install.py +++ b/install/install.py @@ -5228,17 +5228,20 @@ user_query = SELECT email as user, password, 'vmail' as uid, 'vmail' as gid, '/h cronFile = open(cronPath, "w") - # Randomize acme.sh cron schedule to avoid traffic spikes to Let's Encrypt - # Generate random hour (0-23) and minute (0-59) for each installation + # Randomize acme.sh and renew.py cron schedules to avoid traffic spikes to Let's Encrypt + # Each installation gets a random day (0-6 Sun-Sat), hour, and minute to spread load acme_hour = random.randint(0, 23) acme_minute = random.randint(0, 59) + renew_weekday = random.randint(0, 6) # 0=Sun, 1=Mon, ..., 6=Sat + renew_hour = random.randint(0, 23) + renew_minute = random.randint(0, 59) content = """ 0 * * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/findBWUsage.py >/dev/null 2>&1 0 * * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/postfixSenderPolicy/client.py hourlyCleanup >/dev/null 2>&1 0 0 1 * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/postfixSenderPolicy/client.py monthlyCleanup >/dev/null 2>&1 0 2 * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/upgradeCritical.py >/dev/null 2>&1 -0 0 * * 4 /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/renew.py >/dev/null 2>&1 +%d %d * * %d /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/renew.py >/dev/null 2>&1 %d %d * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null 0 0 * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/IncBackups/IncScheduler.py Daily 0 0 * * 0 /usr/local/CyberCP/bin/python /usr/local/CyberCP/IncBackups/IncScheduler.py Weekly @@ -5252,7 +5255,7 @@ user_query = SELECT email as user, password, 'vmail' as uid, 'vmail' as gid, '/h 0 0 * * 0 /usr/local/CyberCP/bin/python /usr/local/CyberCP/IncBackups/IncScheduler.py '1 Week' */3 * * * * if ! find /home/*/public_html/ -maxdepth 2 -type f -newer /usr/local/lsws/cgid -name '.htaccess' -exec false {} +; then /usr/local/lsws/bin/lswsctrl restart; fi -""" % (acme_minute, acme_hour) +""" % (renew_minute, renew_hour, renew_weekday, acme_minute, acme_hour) cronFile.write(content) cronFile.close() diff --git a/plogical/renew.py b/plogical/renew.py index f6a9145d1..6f9c9814a 100755 --- a/plogical/renew.py +++ b/plogical/renew.py @@ -16,6 +16,51 @@ import OpenSSL from plogical.virtualHostUtilities import virtualHostUtilities from plogical.processUtilities import ProcessUtilities +def _update_ssl_renewal_schedule_file(): + """Write SSL renewal schedule to world-readable file for web UI (lscpd can't read root crontab).""" + try: + from datetime import datetime, timedelta + config_path = '/usr/local/CyberCP/ssl_renewal_schedule.conf' + cron_paths = ['/var/spool/cron/root', '/var/spool/cron/crontabs/root'] + cron_content = None + for path in cron_paths: + if os.path.exists(path): + with open(path, 'r') as f: + cron_content = f.read() + break + if not cron_content: + return + renew_hour, renew_minute, renew_weekday_cron = 0, 0, 4 + for line in cron_content.splitlines(): + line = line.strip() + if 'renew.py' in line and not line.startswith('#'): + parts = line.split() + if len(parts) >= 5: + try: + renew_minute = int(parts[0]) if parts[0].isdigit() else 0 + renew_hour = int(parts[1]) if parts[1].isdigit() else 0 + renew_weekday_cron = int(parts[4]) if parts[4].isdigit() and 0 <= int(parts[4]) <= 7 else 4 + except (ValueError, IndexError): + pass + elif len(parts) >= 2: + renew_minute = int(parts[0]) if parts[0].isdigit() else 0 + renew_hour = int(parts[1]) if parts[1].isdigit() else 0 + break + now = datetime.now() + target_weekday = (renew_weekday_cron - 1) % 7 if renew_weekday_cron else 6 + days_until = (target_weekday - now.weekday()) % 7 + if days_until == 0 and (now.hour > renew_hour or (now.hour == renew_hour and now.minute >= renew_minute)): + days_until = 7 + next_run = now.replace(hour=renew_hour, minute=renew_minute, second=0, microsecond=0) + next_run += timedelta(days=days_until) + schedule_str = next_run.strftime('%B %d, %Y %I:%M %p') + with open(config_path, 'w') as f: + f.write(schedule_str) + os.chmod(config_path, 0o644) + except Exception as e: + logging.writeToFile(f'_update_ssl_renewal_schedule_file: {str(e)}', 1) + + class Renew: def _check_and_renew_ssl(self, domain: str, path: str, admin_email: str, is_child: bool = False) -> None: """Helper method to check and renew SSL for a domain.""" @@ -114,6 +159,7 @@ class Renew: def SSLObtainer(self): try: + _update_ssl_renewal_schedule_file() logging.writeToFile('Running SSL Renew Utility') # Process main domains diff --git a/plogical/upgrade.py b/plogical/upgrade.py index 731877b1a..f96dabe12 100644 --- a/plogical/upgrade.py +++ b/plogical/upgrade.py @@ -5292,17 +5292,20 @@ vmail data = open(cronPath, 'r').read() if data.find('findBWUsage') == -1: - # Randomize acme.sh cron schedule to avoid traffic spikes to Let's Encrypt - # Generate random hour (0-23) and minute (0-59) for each installation + # Randomize acme.sh and renew.py cron schedules to avoid traffic spikes to Let's Encrypt + # Each installation gets a random day (0-6 Sun-Sat), hour, and minute to spread load acme_hour = random.randint(0, 23) acme_minute = random.randint(0, 59) + renew_weekday = random.randint(0, 6) # 0=Sun, 1=Mon, ..., 6=Sat + renew_hour = random.randint(0, 23) + renew_minute = random.randint(0, 59) content = """ 0 * * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/findBWUsage.py >/dev/null 2>&1 0 * * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/postfixSenderPolicy/client.py hourlyCleanup >/dev/null 2>&1 0 0 1 * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/postfixSenderPolicy/client.py monthlyCleanup >/dev/null 2>&1 0 2 * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/upgradeCritical.py >/dev/null 2>&1 -0 0 * * 4 /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/renew.py >/dev/null 2>&1 +%d %d * * %d /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/renew.py >/dev/null 2>&1 %d %d * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null 0 1 * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/manage.py ssl_reconcile --all >/dev/null 2>&1 */3 * * * * if ! find /home/*/public_html/ -maxdepth 2 -type f -newer /usr/local/lsws/cgid -name '.htaccess' -exec false {} +; then /usr/local/lsws/bin/lswsctrl restart; fi @@ -5310,7 +5313,7 @@ vmail """ writeToFile = open(cronPath, 'w') - writeToFile.write(content % (acme_minute, acme_hour)) + writeToFile.write(content % (renew_minute, renew_hour, renew_weekday, acme_minute, acme_hour)) writeToFile.close() if data.find('IncScheduler.py') == -1: @@ -5347,23 +5350,26 @@ vmail else: - # Randomize acme.sh cron schedule to avoid traffic spikes to Let's Encrypt - # Generate random hour (0-23) and minute (0-59) for each installation + # Randomize acme.sh and renew.py cron schedules to avoid traffic spikes to Let's Encrypt + # Each installation gets a random day (0-6 Sun-Sat), hour, and minute to spread load acme_hour = random.randint(0, 23) acme_minute = random.randint(0, 59) + renew_weekday = random.randint(0, 6) # 0=Sun, 1=Mon, ..., 6=Sat + renew_hour = random.randint(0, 23) + renew_minute = random.randint(0, 59) content = """ 0 * * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/findBWUsage.py >/dev/null 2>&1 0 * * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/postfixSenderPolicy/client.py hourlyCleanup >/dev/null 2>&1 0 0 1 * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/postfixSenderPolicy/client.py monthlyCleanup >/dev/null 2>&1 0 2 * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/upgradeCritical.py >/dev/null 2>&1 -0 0 * * 4 /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/renew.py >/dev/null 2>&1 +%d %d * * %d /usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/renew.py >/dev/null 2>&1 %d %d * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null 0 1 * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/manage.py ssl_reconcile --all >/dev/null 2>&1 0 0 * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/IncBackups/IncScheduler.py Daily 0 0 * * 0 /usr/local/CyberCP/bin/python /usr/local/CyberCP/IncBackups/IncScheduler.py Weekly * * * * * /usr/local/CyberCP/bin/python /usr/local/CyberCP/manage.py run_scheduled_scans >/usr/local/lscp/logs/scheduled_scans.log 2>&1 -""" % (acme_minute, acme_hour) +""" % (renew_minute, renew_hour, renew_weekday, acme_minute, acme_hour) writeToFile = open(cronPath, 'w') writeToFile.write(content) writeToFile.close() diff --git a/websiteFunctions/templates/websiteFunctions/launchChild.html b/websiteFunctions/templates/websiteFunctions/launchChild.html index 93ebb4e1f..c9a7b6457 100644 --- a/websiteFunctions/templates/websiteFunctions/launchChild.html +++ b/websiteFunctions/templates/websiteFunctions/launchChild.html @@ -653,7 +653,7 @@

{{ authority }}

-

{% trans "Your SSL will expire in" %} {{ days }} {% trans "days" %}

+

{% trans "Your SSL will expire in" %} {{ days }} {% trans "days" %}{% if renewal_when %} • {% trans "Renews" %} {{ renewal_when }}{% endif %}

{% endif %} diff --git a/websiteFunctions/templates/websiteFunctions/listCron.html b/websiteFunctions/templates/websiteFunctions/listCron.html index 67e44acfc..9da1a7bf9 100644 --- a/websiteFunctions/templates/websiteFunctions/listCron.html +++ b/websiteFunctions/templates/websiteFunctions/listCron.html @@ -8,6 +8,64 @@ -
+

diff --git a/websiteFunctions/templates/websiteFunctions/website.html b/websiteFunctions/templates/websiteFunctions/website.html index 252926266..2f3659b10 100644 --- a/websiteFunctions/templates/websiteFunctions/website.html +++ b/websiteFunctions/templates/websiteFunctions/website.html @@ -1569,17 +1569,17 @@ {% trans "Secure" %} - • {% trans "Valid for" %} {{ days }} {% trans "days" %} + • {% trans "Valid for" %} {{ days }} {% trans "days" %}{% if renewal_when %} • {% trans "Renews" %} {{ renewal_when }}{% endif %} {% elif days|add:0 >= 7 %} {% trans "Expiring Soon" %} - • {{ days }} {% trans "days left" %} + • {{ days }} {% trans "days left" %}{% if renewal_when %} • {% trans "Renews" %} {{ renewal_when }}{% endif %} {% else %} {% trans "Critical" %} - • {{ days }} {% trans "days left" %} + • {{ days }} {% trans "days left" %}{% if renewal_when %} • {% trans "Renews" %} {{ renewal_when }}{% endif %} {% endif %}

{% endif %} diff --git a/websiteFunctions/website.py b/websiteFunctions/website.py index d2bcfee56..e13266a4c 100644 --- a/websiteFunctions/website.py +++ b/websiteFunctions/website.py @@ -49,6 +49,65 @@ from django.http import JsonResponse import ipaddress +def _get_ssl_renewal_schedule(): + """Get formatted SSL renewal schedule (e.g. 'Thursday 12:00 AM'). + Reads from world-readable config file first (web server can't read root crontab). + Cron day_of_week: 0=Sun, 1=Mon, ..., 6=Sat. Python weekday: Mon=0, ..., Sun=6.""" + try: + from datetime import datetime, timedelta + # Config file is world-readable; web server (lscpd) cannot read /var/spool/cron/root + config_path = '/usr/local/CyberCP/ssl_renewal_schedule.conf' + if os.path.exists(config_path): + try: + with open(config_path, 'r') as f: + line = f.read().strip() + if line: + return line + except (IOError, OSError): + pass + cron_paths = ['/var/spool/cron/root', '/var/spool/cron/crontabs/root'] + cron_content = None + for path in cron_paths: + if os.path.exists(path): + try: + with open(path, 'r') as f: + cron_content = f.read() + except (IOError, OSError): + continue + break + if not cron_content: + return None + renew_hour, renew_minute, renew_weekday_cron = 0, 0, 4 # default Thursday + for line in cron_content.splitlines(): + line = line.strip() + if 'renew.py' in line and not line.startswith('#'): + parts = line.split() + if len(parts) >= 5: + try: + renew_minute = int(parts[0]) if parts[0].isdigit() else 0 + renew_hour = int(parts[1]) if parts[1].isdigit() else 0 + dow = parts[4] + renew_weekday_cron = int(dow) if dow.isdigit() and 0 <= int(dow) <= 7 else 4 + except (ValueError, IndexError): + pass + elif len(parts) >= 2: + renew_minute = int(parts[0]) if parts[0].isdigit() else 0 + renew_hour = int(parts[1]) if parts[1].isdigit() else 0 + break + now = datetime.now() + # Cron: 0/7=Sun, 1=Mon, ..., 6=Sat -> Python: Mon=0, Tue=1, ..., Sun=6 + target_weekday = (renew_weekday_cron - 1) % 7 if renew_weekday_cron else 6 + days_until = (target_weekday - now.weekday()) % 7 + if days_until == 0 and (now.hour > renew_hour or (now.hour == renew_hour and now.minute >= renew_minute)): + days_until = 7 + next_run = now.replace(hour=renew_hour, minute=renew_minute, second=0, microsecond=0) + next_run += timedelta(days=days_until) + return next_run.strftime('%B %d, %Y %I:%M %p') + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile('_get_ssl_renewal_schedule: ' + str(e)) + return None + + class WebsiteManager: apache = 1 ols = 2 @@ -3782,6 +3841,8 @@ context /cyberpanel_suspension_page.html { Data['viewSSL'] = 1 Data['days'] = str(diff.days) Data['authority'] = x509.get_issuer().get_components()[1][1].decode('utf-8') + renewal_when = _get_ssl_renewal_schedule() + Data['renewal_when'] = renewal_when if Data['authority'] == 'Denial': Data['authority'] = '%s has SELF-SIGNED SSL.' % (self.domain) @@ -3790,6 +3851,7 @@ context /cyberpanel_suspension_page.html { except BaseException as msg: Data['viewSSL'] = 0 + Data['renewal_when'] = None logging.CyberCPLogFileWriter.writeToFile(str(msg)) servicePath = '/home/cyberpanel/pureftpd' @@ -4009,6 +4071,7 @@ context /cyberpanel_suspension_page.html { Data['viewSSL'] = 1 Data['days'] = str(diff.days) Data['authority'] = x509.get_issuer().get_components()[1][1].decode('utf-8') + Data['renewal_when'] = _get_ssl_renewal_schedule() if Data['authority'] == 'Denial': Data['authority'] = '%s has SELF-SIGNED SSL.' % (self.childDomain) @@ -4017,6 +4080,7 @@ context /cyberpanel_suspension_page.html { except BaseException as msg: Data['viewSSL'] = 0 + Data['renewal_when'] = None logging.CyberCPLogFileWriter.writeToFile(str(msg)) proc = httpProc(request, 'websiteFunctions/launchChild.html', Data)