|
| 1 | +"""Mail before an External site's TLS certificate runs out. |
| 2 | +
|
| 3 | +Once a day, every site whose certificate ends within the number of days set |
| 4 | +under Settings, External sites, is named in one message. Each certificate is |
| 5 | +named once: what was told is kept as the site and the certificate's end date, |
| 6 | +so a renewed certificate starts over and an unchanged one is not repeated |
| 7 | +every morning. |
| 8 | +
|
| 9 | +What the list knows about a certificate is as old as the site's telemetry, |
| 10 | +which may be a week. A certificate renewed since then would be a false |
| 11 | +alarm, so each site about to be named has its certificate read again first, |
| 12 | +with the same single handshake telemetry uses. When that read fails the |
| 13 | +stored date is used, and the message says where it came from. |
| 14 | +""" |
| 15 | + |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +import datetime as dt |
| 19 | +import logging |
| 20 | + |
| 21 | +from app.core import settings_store as store |
| 22 | + |
| 23 | +log = logging.getLogger("gcr.external_certs") |
| 24 | + |
| 25 | + |
| 26 | +def _when(value) -> dt.datetime | None: |
| 27 | + try: |
| 28 | + got = dt.datetime.fromisoformat(str(value).replace("Z", "+00:00")) |
| 29 | + except (TypeError, ValueError): |
| 30 | + return None |
| 31 | + return got if got.tzinfo else got.replace(tzinfo=dt.timezone.utc) |
| 32 | + |
| 33 | + |
| 34 | +def recipients(session) -> list: |
| 35 | + """The addresses set for these alerts, or the Notifications page's list.""" |
| 36 | + from app.core import notify |
| 37 | + |
| 38 | + own = notify._split(store.get(session, "external.cert_alert_to")) |
| 39 | + return own or notify.recipients(session) |
| 40 | + |
| 41 | + |
| 42 | +def stored_expiry(asset) -> dt.datetime | None: |
| 43 | + from app.core import external |
| 44 | + |
| 45 | + tls = external.telemetry_of(asset).get("tls") or {} |
| 46 | + return _when(tls.get("not_after")) |
| 47 | + |
| 48 | + |
| 49 | +def live_expiry(host: str, allow_private: bool) -> dt.datetime | None: |
| 50 | + """The certificate the site serves now, or None when it cannot be read.""" |
| 51 | + from app.core import external_probe |
| 52 | + |
| 53 | + ips = external_probe.resolve(host) |
| 54 | + if not ips or (not allow_private and not all(external_probe.is_public(i) for i in ips)): |
| 55 | + return None |
| 56 | + info = external_probe.analyze_tls(host, ips[0], legacy=False) |
| 57 | + return _when(info.get("not_after")) |
| 58 | + |
| 59 | + |
| 60 | +def expiring(session, days: int, now: dt.datetime = None, reread: bool = True) -> list: |
| 61 | + """Sites whose certificate ends within `days`, soonest first. Expired ones |
| 62 | + count: a site added after its certificate ran out is still worth saying.""" |
| 63 | + from app.core import external |
| 64 | + from app.core.models import ExternalAsset |
| 65 | + |
| 66 | + now = now or dt.datetime.now(dt.timezone.utc) |
| 67 | + horizon = now + dt.timedelta(days=days) |
| 68 | + allow_private = bool(store.get(session, "external.allow_private")) |
| 69 | + out = [] |
| 70 | + for asset in session.query(ExternalAsset).all(): |
| 71 | + if (asset.status or "active") == "obsolete": |
| 72 | + continue |
| 73 | + ends = stored_expiry(asset) |
| 74 | + if ends is None or ends > horizon: |
| 75 | + continue |
| 76 | + source = "telemetry" |
| 77 | + if reread: |
| 78 | + try: |
| 79 | + fresh = live_expiry(asset.host, allow_private) |
| 80 | + except Exception: # noqa: BLE001 - an unreadable site keeps the stored date |
| 81 | + fresh = None |
| 82 | + if fresh is not None: |
| 83 | + ends, source = fresh, "live" |
| 84 | + if ends > horizon: |
| 85 | + continue # renewed since telemetry was read |
| 86 | + tls = external.telemetry_of(asset).get("tls") or {} |
| 87 | + out.append({ |
| 88 | + "id": asset.id, "host": asset.host, |
| 89 | + "application": asset.application or "", |
| 90 | + "environment": asset.environment or "", |
| 91 | + "not_after": ends, |
| 92 | + # Whole days on either side: a day and a half ago is one day ago. |
| 93 | + "days_left": (ends - now).days if ends >= now else -((now - ends).days), |
| 94 | + "issuer": tls.get("issuer") or "", |
| 95 | + "source": source, |
| 96 | + "read_at": asset.telemetry_at, |
| 97 | + }) |
| 98 | + out.sort(key=lambda r: (r["not_after"], r["host"])) |
| 99 | + return out |
| 100 | + |
| 101 | + |
| 102 | +def _line(site: dict) -> str: |
| 103 | + name = site["host"] |
| 104 | + extra = ", ".join(x for x in (site["application"], site["environment"]) if x) |
| 105 | + if extra: |
| 106 | + name += f" ({extra})" |
| 107 | + date = site["not_after"].strftime("%Y-%m-%d") |
| 108 | + left = site["days_left"] |
| 109 | + if left < 0: |
| 110 | + when = f"expired {date}, {-left} day{'s' if left != -1 else ''} ago" |
| 111 | + elif left == 0: |
| 112 | + when = f"expires {date}, today" |
| 113 | + else: |
| 114 | + when = f"expires {date}, in {left} day{'s' if left != 1 else ''}" |
| 115 | + text = f"{name}: certificate {when}" |
| 116 | + if site["source"] != "live": |
| 117 | + read = (f"telemetry read {site['read_at'].strftime('%Y-%m-%d')}" if site["read_at"] |
| 118 | + else "an earlier telemetry read") |
| 119 | + text += f". The site did not answer just now, so this date is from {read}" |
| 120 | + return text + "." |
| 121 | + |
| 122 | + |
| 123 | +def message(sites: list, days: int) -> tuple: |
| 124 | + """-> (title, lines).""" |
| 125 | + n = len(sites) |
| 126 | + title = (f"{n} site certificate{'s' if n != 1 else ''} " |
| 127 | + f"expire{'s' if n == 1 else ''} within {days} days") |
| 128 | + lines = [_line(s) for s in sites[:60]] |
| 129 | + if n > 60: |
| 130 | + lines.append(f"and {n - 60} more") |
| 131 | + lines.append("Each certificate is named once. A renewed one is named again only " |
| 132 | + "if it too comes within the warning window.") |
| 133 | + return title, lines |
| 134 | + |
| 135 | + |
| 136 | +def daily() -> dict: |
| 137 | + """The scheduled check. Quiet unless it is switched on and a certificate |
| 138 | + newly comes within the window. -> what happened, for the audit log.""" |
| 139 | + from app.core import external, notify |
| 140 | + from app.core.audit import record |
| 141 | + from app.core.db import session_scope |
| 142 | + |
| 143 | + with session_scope() as db: |
| 144 | + if not external.enabled(db) or not store.get(db, "external.cert_alert_enabled"): |
| 145 | + return {} |
| 146 | + days = int(store.get(db, "external.cert_alert_days") or 29) |
| 147 | + try: |
| 148 | + sites = expiring(db, days) |
| 149 | + except Exception as exc: # noqa: BLE001 - a background job must not take the scheduler down |
| 150 | + log.warning("certificate check failed: %s", exc) |
| 151 | + return {} |
| 152 | + told = store.get(db, "external.cert_alert_last") or {} |
| 153 | + if not isinstance(told, dict): |
| 154 | + told = {} |
| 155 | + key = lambda s: s["not_after"].isoformat() # noqa: E731 |
| 156 | + new = [s for s in sites if told.get(str(s["id"])) != key(s)] |
| 157 | + if not new: |
| 158 | + # Forget sites that left the window, so the list never grows. |
| 159 | + store.set_value(db, "external.cert_alert_last", |
| 160 | + {str(s["id"]): key(s) for s in sites}, "scheduler") |
| 161 | + return {"new": 0, "expiring": len(sites)} |
| 162 | + to = recipients(db) |
| 163 | + title, lines = message(new, days) |
| 164 | + root = notify.base_url(db) |
| 165 | + sent = notify._mail(db, title, lines, f"{root}/external" if root else "", |
| 166 | + None, to) if to else {} |
| 167 | + ok = bool(sent) and all(sent.values()) |
| 168 | + if ok: |
| 169 | + # Only once it went, so a mail server that was down tries again |
| 170 | + # tomorrow instead of the warning being lost. |
| 171 | + store.set_value(db, "external.cert_alert_last", |
| 172 | + {str(s["id"]): key(s) for s in sites}, "scheduler") |
| 173 | + record(db, "notifications.cert_expiry", actor="scheduler", |
| 174 | + detail="%d newly expiring, %d in the window; %s" % ( |
| 175 | + len(new), len(sites), |
| 176 | + ", ".join(f"{k}: {'ok' if v else 'failed'}" for k, v in sorted(sent.items())) |
| 177 | + or "no recipients, nothing sent"), |
| 178 | + outcome="ok" if ok else "warn") |
| 179 | + return {"new": len(new), "expiring": len(sites), "sent": sent} |
0 commit comments