Prd/components/backend/lib/helpdesk/cert_watch.rb
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer
answers technician calls, records both call legs as separate channels, and a
Ruby backend transcribes them through Whisper and files an AI summary against
the caller.

Squashed to a single commit for sharing. No credentials are included; secrets
live outside the repo in /etc/helpdesk/env on the server or a gitignored
.claude/env.local locally. See .claude/env.local.example for the shape.

Start at README.md, then docs/architecture.md.
2026-07-27 18:50:32 +02:00

212 lines
10 KiB
Ruby

# 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