# frozen_string_literal: true # CertWatch - answers "is any certificate about to expire?" for the console's warning badge. # # Why this exists: the vmin CA, the operator p12s and the Whisper client cert all expire on the same day # and NOTHING renews them. The server certificate does renew itself (acme.sh), but its cron output goes to # /dev/null, so a repeatedly failing renewal is invisible until the day it stops working. Both failures # are silent, and both take out the console and transcription together. This makes them loud. # # Three sources, because the three certificates are readable in three different ways (grep anchor: # cert-watch-sources): # - server cert: read OVER TLS, not from disk. The backend runs as `helpdesk` and the key directory is # 0700 root, so the file is unreadable - but more importantly the served certificate is the honest # one. It catches "renewed on disk but nginx was never reloaded", which a file check would miss. # - vmin CA: a world-readable PEM bundle. Earliest expiry among the certs in it wins. # - Whisper client cert: a p12, readable because it is 0640 root:helpdesk and the service is in that # group. Needs the password the backend already holds in WHISPER_MTLS_PASS. # # Everything here fails SOFT. A source that cannot be read is omitted, never an exception and never a # fake "ok" - an unreadable certificate simply is not reported on. Nothing is configured by default, so # local dev checks nothing and costs nothing. # # grep anchors: cert-watch-sources, cert-watch-severity, cert-watch-cache. require "openssl" require "socket" require "time" module Helpdesk module CertWatch WARN_DAYS_DEFAULT = 30 CRITICAL_DAYS = 14 # inside this, it stops being a reminder and becomes a problem PROBE_TIMEOUT_S = 5 REFRESH_TTL_S = 6 * 3600 module_function # ---- severity ladder. Pure. grep anchor: cert-watch-severity ---------------------------------- # `warn_days` can be set below CRITICAL_DAYS, so critical never overtakes the warning threshold. def severity(days_left, warn_days: WARN_DAYS_DEFAULT) return "expired" if days_left < 0 return "critical" if days_left <= [CRITICAL_DAYS, warn_days].min return "warning" if days_left <= warn_days "ok" end # Whole days remaining, rounded DOWN: "expires in 0 days" means today, which is the honest reading. def days_between(now, expires_at) ((expires_at - now) / 86_400.0).floor end def entry(name, expires_at, what_breaks, now:, warn_days:) d = days_between(now, expires_at) { "name" => name, "expires_at" => expires_at.utc.iso8601, "days_left" => d, "severity" => severity(d, warn_days: warn_days), "what_breaks" => what_breaks } end # ---- sources. Each returns a Time or nil, and never raises. grep anchor: cert-watch-sources ----- # The certificate a client is actually served. Probe the DEVICE door (8443) rather than the console: # the console demands a client certificate, and while TLS 1.3 happens to let us read the peer cert # before the server objects, TLS 1.2 tears the handshake down first. 8443 serves the same file with # no client cert required, so it works regardless. VERIFY_NONE is deliberate and is not a security # hole: we want the certificate's DATES, not a trust decision, and trusting it would make an expired # certificate unreadable by exactly the code meant to warn about it. def served_expiry(hostport, timeout: PROBE_TIMEOUT_S) host, port = hostport.to_s.split(":") return nil if host.to_s.empty? port = (port || 443).to_i sock = ssl = nil begin sock = Socket.tcp(host, port, connect_timeout: timeout) ctx = OpenSSL::SSL::SSLContext.new ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx) ssl.hostname = host # SNI: the vhost is name-based ssl.connect rescue nil # may fail late; the peer cert is usually already here ssl.peer_cert&.not_after ensure ssl&.close rescue nil sock&.close rescue nil end rescue StandardError nil end # Earliest expiry among every certificate in a PEM bundle - a chain is only good until its first # expiry, so the soonest is the one that matters. def bundle_expiry(path) return nil if path.to_s.empty? || !File.readable?(path) pems = File.read(path).scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m) dates = pems.filter_map { |pem| OpenSSL::X509::Certificate.new(pem).not_after rescue nil } dates.min rescue StandardError nil end # EARLIEST of the leaf and every CA bundled with it, not just the leaf. This matters: the vmin client # cert's leaf runs to 2036 while the CA that signs it runs out years earlier, so reading the leaf # alone reports thousands of days of safety for something that stops working much sooner. A chain is # only usable while every certificate in it is valid. # Caveat worth knowing: the real constraint is the CA copy held by whoever VERIFIES us # (whisper.cajk.org), which we cannot see. This is the closest honest answer available locally, and it # errs toward warning early rather than late. def p12_expiry(path, pass) return nil if path.to_s.empty? || !File.readable?(path) p12 = OpenSSL::PKCS12.new(File.binread(path), pass.to_s) dates = [p12.certificate&.not_after, *Array(p12.ca_certs).map(&:not_after)].compact dates.min rescue StandardError nil end # ---- the check --------------------------------------------------------------------------------- # Returns [] when nothing is configured, which is the local-dev case: no probing, no file reads. def check(now: Time.now, warn_days: WARN_DAYS_DEFAULT, tls: nil, ca_path: nil, p12_path: nil, p12_pass: nil) out = [] if (t = served_expiry(tls)) out << entry("Server certificate", t, "the console and the phone door both stop serving TLS", now: now, warn_days: warn_days) end if (t = bundle_expiry(ca_path)) out << entry("Operator CA (vmin)", t, "operators can no longer sign in to the console", now: now, warn_days: warn_days) end if (t = p12_expiry(p12_path, p12_pass)) out << entry("Whisper client certificate", t, "call transcription stops", now: now, warn_days: warn_days) end out.sort_by { |e| e["days_left"] } end # ---- the operator's OWN certificate. grep anchor: cert-watch-presented ------------------------ # There is one client certificate PER OPERATOR and we hold none of them - they live in people's # browsers, password-protected. So they cannot be watched from a file. nginx, however, knows the # expiry of whichever certificate was just presented ($ssl_client_v_remain gives days remaining, # $ssl_client_v_end the date), so each operator gets warned about their own and nobody needs an # inventory of who holds what. Costs nothing: no file, no probe, just a header already in hand. # Returns nil when the headers are absent, which is local dev and any un-updated nginx. def presented_entry(days, ends, warn_days: WARN_DAYS_DEFAULT) d = days.to_s.strip return nil if d.empty? d = begin Integer(d) rescue return nil end { "name" => "Your operator certificate", "expires_at" => normalize_nginx_date(ends), "days_left" => d, "severity" => severity(d, warn_days: warn_days), "what_breaks" => "you can no longer sign in to the console" } end # nginx writes $ssl_client_v_end in OpenSSL's format, "Dec 15 12:00:00 2027 GMT", with the day space # padded to two characters ("Sep 9 ..."). Normalise to the ISO8601 the other entries use so the UI # formats them all the same way; pass it through unchanged if it will not parse at all. # strptime with an explicit format, NOT Time.parse: Time.parse silently ignores the GMT suffix and # reads the value as local time, which shifts the date by the server's UTC offset. def normalize_nginx_date(s) t = s.to_s.strip return nil if t.empty? (Time.strptime(t, "%b %e %H:%M:%S %Y %Z").utc.iso8601 rescue t) end def config_from_env(env = ENV) { warn_days: (env["HELPDESK_CERT_WARN_DAYS"] || WARN_DAYS_DEFAULT).to_i, tls: env["HELPDESK_CERT_WATCH_TLS"], ca_path: env["HELPDESK_CERT_WATCH_CA"], p12_path: env["WHISPER_MTLS_P12"], p12_pass: env["WHISPER_MTLS_PASS"] } end def configured?(cfg) = !(cfg[:tls].to_s.empty? && cfg[:ca_path].to_s.empty? && cfg[:p12_path].to_s.empty?) # ---- cache. grep anchor: cert-watch-cache ------------------------------------------------------ # NEVER blocks a request. A stale answer is refreshed on a background thread and the caller gets the # previous one immediately; the very first call gets [] until that thread lands. A certificate expiry # months away does not need to be up to the second, and the console polls /status every 30s, so a # blocking probe here would stall the whole UI whenever the network hiccupped. class Cache def initialize(ttl: REFRESH_TTL_S, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, &fetch) @ttl = ttl; @clock = clock; @fetch = fetch @lock = Mutex.new; @value = []; @stamp = nil; @refreshing = false end def value start = false @lock.synchronize do stale = @stamp.nil? || (@clock.call - @stamp) > @ttl start = stale && !@refreshing @refreshing = true if start end spawn_refresh if start @lock.synchronize { @value } end # Synchronous refresh - for tests and boot-time warmup, where blocking is fine. def refresh! v = @fetch.call @lock.synchronize { @value = v; @stamp = @clock.call; @refreshing = false } v end private def spawn_refresh Thread.new do refresh! rescue StandardError => e warn "[helpdesk] cert watch refresh failed (#{e.class}: #{e.message})" @lock.synchronize { @refreshing = false } end end end end end