|
1 | | -from datetime import datetime, timezone |
2 | | -from typing import List, Tuple |
| 1 | +from datetime import datetime, timedelta, timezone |
| 2 | +from typing import List, Optional, Tuple |
3 | 3 |
|
4 | 4 | from certapi.client import RenewalManager |
5 | 5 | from certapi.crypto import Key, Certificate |
@@ -37,12 +37,21 @@ def __init__( |
37 | 37 | renew_threshold_days=max(1, int(self.update_threshold_secs // (24 * 3600))), |
38 | 38 | batch_domains=self.certapi_batch_domains, |
39 | 39 | ) |
| 40 | + self._status_rows: List[Tuple[str, str, Optional[datetime]]] = [] |
| 41 | + self._last_logged_status: Optional[Tuple] = None |
40 | 42 |
|
41 | 43 | if start_ssl_thread: |
42 | 44 | self.start() |
43 | 45 |
|
44 | 46 | def start(self): |
45 | 47 | self.renewal_manager.start() |
| 48 | + backend = f"certapi {self.certapi_url}" if self.use_certapi_server else "local ACME" |
| 49 | + print( |
| 50 | + f"[SSL Refresh Thread] Started with backend={backend}, " |
| 51 | + f"renew threshold {self._format_duration(self.update_threshold_secs)}, " |
| 52 | + f"watching {len(self._status_rows)} domains" |
| 53 | + ) |
| 54 | + self._log_next_check() |
46 | 55 |
|
47 | 56 | def ssl_renewal_callback(self): |
48 | 57 | print("[SSL] Renewal callback triggered") |
@@ -123,6 +132,134 @@ def process_ssl_certificates(self, hosts: List[Host], update_watch_domains: bool |
123 | 132 | for host in secured_hosts: |
124 | 133 | host.ssl_file = self._select_ssl_file(host) |
125 | 134 |
|
| 135 | + if update_watch_domains: |
| 136 | + self.log_certificate_status(secured_hosts) |
| 137 | + |
| 138 | + # ------------------------------------------------------------------ |
| 139 | + # Status logging |
| 140 | + # ------------------------------------------------------------------ |
| 141 | + |
| 142 | + def _certificate_expiry(self, cert_name: str) -> Optional[datetime]: |
| 143 | + try: |
| 144 | + result = self.key_store.find_key_and_cert_by_cert_id(cert_name) |
| 145 | + except Exception: |
| 146 | + return None |
| 147 | + if not isinstance(result, (tuple, list)) or len(result) < 2: |
| 148 | + return None |
| 149 | + certs = result[1] |
| 150 | + if not isinstance(certs, (list, tuple)) or not certs: |
| 151 | + return None |
| 152 | + expiry = getattr(certs[0], "not_valid_after_utc", None) |
| 153 | + return expiry if isinstance(expiry, datetime) else None |
| 154 | + |
| 155 | + def _collect_status_rows(self, hosts: List[Host]) -> List[Tuple[str, str, Optional[datetime]]]: |
| 156 | + rows = {} |
| 157 | + for host in hosts: |
| 158 | + if host.hostname in rows: |
| 159 | + continue |
| 160 | + ssl_file = host.ssl_file or "" |
| 161 | + rows[host.hostname] = (host.hostname, ssl_file, self._certificate_expiry(ssl_file) if ssl_file else None) |
| 162 | + return [rows[name] for name in sorted(rows)] |
| 163 | + |
| 164 | + @staticmethod |
| 165 | + def _format_duration(seconds: float) -> str: |
| 166 | + """Human friendly duration: '30 days', '5 hours' or '29 minutes'.""" |
| 167 | + seconds = max(0, int(seconds)) |
| 168 | + days, rem = divmod(seconds, 24 * 3600) |
| 169 | + if days: |
| 170 | + return f"{days} day{'s' if days != 1 else ''}" |
| 171 | + hours, rem = divmod(rem, 3600) |
| 172 | + if hours: |
| 173 | + return f"{hours} hour{'s' if hours != 1 else ''}" |
| 174 | + minutes = max(1, rem // 60) |
| 175 | + return f"{minutes} minute{'s' if minutes != 1 else ''}" |
| 176 | + |
| 177 | + @staticmethod |
| 178 | + def _format_remaining(delta: timedelta) -> str: |
| 179 | + """'75 days, 17:00:58' style: no microseconds, zero padded time so columns line up.""" |
| 180 | + total = int(delta.total_seconds()) |
| 181 | + days, rem = divmod(total, 24 * 3600) |
| 182 | + hours, rem = divmod(rem, 3600) |
| 183 | + minutes, seconds = divmod(rem, 60) |
| 184 | + prefix = f"{days} day{'s' if days != 1 else ''}, " if days else "" |
| 185 | + return f"{prefix}{hours:02}:{minutes:02}:{seconds:02}" |
| 186 | + |
| 187 | + def _row_text(self, hostname: str, ssl_file: str, expiry: Optional[datetime], now: datetime) -> str: |
| 188 | + if not ssl_file or expiry is None: |
| 189 | + return "no certificate" |
| 190 | + if expiry <= now: |
| 191 | + text = f"EXPIRED {self._format_remaining(now - expiry)} ago" |
| 192 | + else: |
| 193 | + text = self._format_remaining(expiry - now) |
| 194 | + if ssl_file != hostname: |
| 195 | + text += f" ({ssl_file})" |
| 196 | + return text |
| 197 | + |
| 198 | + def _next_check(self, now: datetime) -> Optional[Tuple[float, str, datetime]]: |
| 199 | + """Return (seconds_until_check, domain, expiry) for the earliest real certificate, or None.""" |
| 200 | + candidates = [ |
| 201 | + (expiry, domain) |
| 202 | + for domain, ssl_file, expiry in self._status_rows |
| 203 | + if expiry is not None and not ssl_file.endswith(".selfsigned") and expiry > now |
| 204 | + ] |
| 205 | + if not candidates: |
| 206 | + return None |
| 207 | + expiry, domain = min(candidates) |
| 208 | + threshold = getattr(self.renewal_manager, "update_threshold_secs", None) |
| 209 | + if not isinstance(threshold, (int, float)): |
| 210 | + threshold = self.update_threshold_secs |
| 211 | + slack = getattr(self.renewal_manager, "sleep_slack_seconds", None) |
| 212 | + if not isinstance(slack, (int, float)): |
| 213 | + slack = 300 |
| 214 | + max_sleep = getattr(self.renewal_manager, "max_sleep_seconds", None) |
| 215 | + if not isinstance(max_sleep, (int, float)): |
| 216 | + max_sleep = 32 * 24 * 3600 |
| 217 | + wait = (expiry - now).total_seconds() - threshold |
| 218 | + wait = min(wait + slack, max_sleep) if wait > 0 else 0 |
| 219 | + return wait, domain, expiry |
| 220 | + |
| 221 | + def _log_next_check(self, now: Optional[datetime] = None): |
| 222 | + now = now or datetime.now(timezone.utc) |
| 223 | + next_check = self._next_check(now) |
| 224 | + if next_check is None: |
| 225 | + print("[SSL Refresh Thread] Looks like there are no ssl certificates, sleeping until there's one") |
| 226 | + return |
| 227 | + wait, domain, expiry = next_check |
| 228 | + if wait <= 0: |
| 229 | + print( |
| 230 | + f"[SSL Refresh Thread] Looks like we need to refresh certificates that are about to expire " |
| 231 | + f"({domain} expires in {self._format_remaining(expiry - now)})" |
| 232 | + ) |
| 233 | + else: |
| 234 | + print( |
| 235 | + f"[SSL Refresh Thread] All the certificates are up to date sleeping for {self._format_duration(wait)}." |
| 236 | + ) |
| 237 | + |
| 238 | + def log_certificate_status(self, hosts: List[Host], force: bool = False): |
| 239 | + """ |
| 240 | + Print the watched domains with the time left on the certificate each one serves, followed by |
| 241 | + when the renewal thread will check again. Printed only when something changed unless forced. |
| 242 | + """ |
| 243 | + try: |
| 244 | + now = datetime.now(timezone.utc) |
| 245 | + self._status_rows = self._collect_status_rows(hosts) |
| 246 | + snapshot = tuple((d, f, e.isoformat() if e else None) for d, f, e in self._status_rows) |
| 247 | + if not force and snapshot == self._last_logged_status: |
| 248 | + return |
| 249 | + self._last_logged_status = snapshot |
| 250 | + |
| 251 | + real_rows = [row for row in self._status_rows if not row[1].endswith(".selfsigned")] |
| 252 | + self_signed = [row[0] for row in self._status_rows if row[1].endswith(".selfsigned")] |
| 253 | + print("[SSL Refresh Thread] SSL certificate status:") |
| 254 | + max_size = max([len(d) for d, _, _ in real_rows] + [0]) |
| 255 | + for domain, ssl_file, expiry in real_rows: |
| 256 | + print(f" {domain:<{max_size + 2}} - {self._row_text(domain, ssl_file, expiry, now)}") |
| 257 | + self._log_next_check(now) |
| 258 | + if self_signed: |
| 259 | + print(f"[SSL Refresh Thread] Selfsigned: {', '.join(self_signed)}") |
| 260 | + except Exception as e: # logging must never break a reload |
| 261 | + print(f"[SSL Refresh Thread] Could not render certificate status: {e.__class__.__name__}: {e}") |
| 262 | + |
126 | 263 | def wildcard_domain_name(self, domain, wild_char="*"): |
127 | 264 | slices = domain.split(".") |
128 | 265 | if len(slices) > 2: |
|
0 commit comments