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.
212 lines
10 KiB
Ruby
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
|