Add cert checker module: implement Kubernetes resources, certificate validation logic, and deployment restart functionality

This commit is contained in:
2025-10-02 16:53:28 +02:00
parent e6a3f3affd
commit a412c96c46
15 changed files with 644 additions and 10 deletions
@@ -0,0 +1,10 @@
locals {
app_version = var.tag
workdir = "${var.persistent_folder}/cert-checker"
commands = [
"python -m venv /tmp/venv",
". /tmp/venv/bin/activate",
"pip install -r /config/requirements.txt",
"python /config/check.py"
]
}
@@ -0,0 +1,149 @@
resource "kubernetes_service_account" "cert_checker" {
metadata {
name = "cert-checker-sa"
namespace = var.namespace
}
}
resource "kubernetes_role" "cert_checker" {
metadata {
name = "cert-checker-role"
}
rule {
api_groups = ["apps"]
resources = ["deployments"]
verbs = ["get", "list", "patch", "update"]
}
}
resource "kubernetes_role_binding" "cert_checker_auth_reader" {
metadata {
name = "cert-checker-biding"
namespace = var.namespace
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "Role"
name = "cert-checker-role"
}
subject {
kind = "ServiceAccount"
name = "cert-checker-sa"
namespace = var.namespace
}
}
resource "kubernetes_config_map" "cert_checker" {
metadata {
name = "cert-checker-config"
namespace = var.namespace
}
data = {
"check.py" = file("${path.module}/resources/check.py")
"requirements.txt" = file("${path.module}/resources/requirements.txt")
}
}
resource "kubernetes_cron_job_v1" "cert_checker" {
for_each = var.deployments
metadata {
name = "cert-checker-${each.key}"
namespace = var.namespace
labels = {
app = "cert-checker"
}
}
spec {
schedule = coalesce(each.value.schedule, "0 0 * * *") # Default to daily at midnight
successful_jobs_history_limit = 2
failed_jobs_history_limit = 1
job_template {
metadata {
name = "cert-checker"
}
spec {
backoff_limit = 1
parallelism = 1
completions = 1
template {
metadata {
labels = {
app = "cert-checker"
}
}
spec {
service_account_name = kubernetes_service_account.cert_checker.metadata[0].name
restart_policy = "Never"
container {
name = "cert-checker"
image = var.tag
command = ["sh", "-c", join(" && ", local.commands)]
volume_mount {
name = "config-volume"
mount_path = "/config"
read_only = true
}
volume_mount {
name = "cert-volume"
mount_path = "/certs"
read_only = true
}
volume_mount {
name = "workdir"
mount_path = "/tmp"
}
security_context {
run_as_user = 1000
run_as_group = 1000
allow_privilege_escalation = false
read_only_root_filesystem = true
run_as_non_root = true
capabilities {
drop = ["ALL"]
}
}
env {
name = "DEPLOYMENT_NAME"
value = each.key
}
env {
name = "NAMESPACE"
value = var.namespace
}
env {
name = "SERVICE_HOST"
value = "${each.value.service_name}.${var.namespace}.svc.cluster.local"
}
env {
name = "SERVICE_PORT"
value = tostring(each.value.service_port)
}
}
volume {
name = "config-volume"
config_map {
name = kubernetes_config_map.cert_checker.metadata[0].name
}
}
volume {
name = "cert-volume"
secret {
secret_name = each.value.secret_name
}
}
volume {
name = "workdir"
host_path {
path = local.workdir
type = "DirectoryOrCreate"
}
}
}
}
}
}
}
}
@@ -0,0 +1,9 @@
terraform {
required_version = "~>1.8"
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.36.0"
}
}
}
@@ -0,0 +1,129 @@
import ssl
import socket
from datetime import datetime, timezone
import os
import logging
from kubernetes import client, config
import tempfile
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
DEPLOYMENT = os.getenv("DEPLOYMENT_NAME", "my-deployment")
NAMESPACE = os.getenv("NAMESPACE", "default")
SECRET_CERT_PATH = os.getenv("SECRET_CERT_PATH", "/certs/tls.crt")
SERVICE_HOST = os.getenv("SERVICE_HOST", "my-app.default.svc")
SERVICE_PORT = int(os.getenv("SERVICE_PORT", 443))
def read_secret_cert():
logging.info(f"Reading certificate from secret path: {SECRET_CERT_PATH}")
return ssl._ssl._test_decode_cert(SECRET_CERT_PATH)
def get_secret_cert_expiry():
cert = read_secret_cert()
expiry = datetime.strptime(cert['notAfter'], "%b %d %H:%M:%S %Y %Z")
logging.info(f"Secret certificate expires at: {expiry}")
return expiry
def get_common_name(cert):
"""
Extract Common Name (CN) from a cert dict as returned by ssl.getpeercert()
or ssl._ssl._test_decode_cert(). Returns None if not found.
"""
if not cert:
return None
subject = cert.get('subject', [])
for rdn in subject:
# rdn is a tuple of tuples, e.g. (('commonName', 'example.com'),)
for attr in rdn:
if isinstance(attr, tuple) and len(attr) == 2:
key, val = attr
if key.lower() in ('commonname', 'cn'):
return val
return None
def get_live_cert_expiry():
logging.info(f"Connecting to service {SERVICE_HOST}:{SERVICE_PORT} to get live certificate")
# Try the secure default context first (will verify hostname)
ctx = ssl.create_default_context()
try:
with ctx.wrap_socket(socket.socket(), server_hostname=SERVICE_HOST) as s:
s.connect((SERVICE_HOST, SERVICE_PORT))
# request both the dict view and the binary DER form
cert = s.getpeercert() # may be empty
der = s.getpeercert(binary_form=True)
except ssl.SSLCertVerificationError as e:
logging.warning("Certificate verification failed (%s). Falling back to non-verifying connection to read cert (insecure).", e)
# Insecure fallback: disable hostname and certificate verification just to read the cert
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with ctx.wrap_socket(socket.socket(), server_hostname=SERVICE_HOST) as s:
s.connect((SERVICE_HOST, SERVICE_PORT))
cert = s.getpeercert()
der = s.getpeercert(binary_form=True)
# If the dict view lacks 'notAfter', try decoding the DER to get a full dict
if not cert or 'notAfter' not in cert:
if der:
pem = ssl.DER_cert_to_PEM_cert(der)
# write PEM to a temp file and decode using ssl test decoder to get 'notAfter'
with tempfile.NamedTemporaryFile(mode="w", delete=True) as tf:
tf.write(pem)
tf.flush()
cert = ssl._ssl._test_decode_cert(tf.name)
else:
raise RuntimeError("Failed to obtain certificate from peer")
expiry = datetime.strptime(cert['notAfter'], "%b %d %H:%M:%S %Y %Z")
logging.info(f"Live certificate expires at: {expiry}")
return expiry, cert
def restart_deployment():
logging.info(f"Restarting deployment {DEPLOYMENT} in namespace {NAMESPACE}")
config.load_incluster_config()
apps = client.AppsV1Api()
body = {
"spec": {
"template": {
"metadata": {
"annotations": {
"cert-checker/restartedAt": datetime.now(timezone.utc).isoformat()
}
}
}
}
}
apps.patch_namespaced_deployment(
name=DEPLOYMENT,
namespace=NAMESPACE,
body=body
)
logging.info(f"Deployment {DEPLOYMENT} restarted")
if __name__ == "__main__":
logging.info("Starting certificate check")
secret_cert = read_secret_cert()
secret_expiry = datetime.strptime(secret_cert['notAfter'], "%b %d %H:%M:%S %Y %Z")
live_expiry, live_cert = get_live_cert_expiry()
secret_cn = get_common_name(secret_cert)
live_cn = get_common_name(live_cert)
logging.info(f"Secret expiry: {secret_expiry}")
logging.info(f"Live cert expiry: {live_expiry}")
logging.info(f"Secret CN: {secret_cn}")
logging.info(f"Live CN: {live_cn}")
if secret_cn != live_cn:
logging.warning("Certificate mismatch detected (CN), check your setup.")
exit(0)
if secret_expiry != live_expiry:
logging.warning("Certificate mismatch detected (expiry), restarting deployment")
restart_deployment()
else:
logging.info("Certificates match, nothing to do.")
@@ -0,0 +1 @@
kubernetes==34.1.0
@@ -0,0 +1,26 @@
variable "tag" {
description = "Python image tag"
type = string
default = "python:3.12-slim"
}
variable "namespace" {
description = "Kubernetes namespace to deploy the metrics server"
type = string
}
variable "persistent_folder" {
description = "The path to the persistent folder"
type = string
}
variable "deployments" {
description = "A map of configuration items for the cert checker. Each item should include deployment_name, service_name, and service_port."
type = map(object({
service_name = string
service_port = number
secret_name = string
schedule = optional(string) # Cron schedule expression
}))
default = {}
}