#!/usr/bin/env python3 """Standalone SSL check utility to be frozen for testing. Usage: python ssl_check.py --url https://www.penracourses.org.uk --cert-dir ./cert What it does: - Runs `openssl s_client -showcerts` against the host (if openssl on PATH). - Extracts PEM cert blocks from the output and inspects each with `openssl x509`. - Loads cert files from --cert-dir (PEM or DER) and converts DER to PEM as needed. - Compares server chain Issuers with Subjects of bundled certs and reports which CA certs are missing. This script is intentionally small and self-contained so it can be frozen with PyInstaller for quick testing on Windows where the system openssl may be too old. """ from __future__ import annotations import argparse import base64 import re import shutil import os import subprocess import sys import tempfile from pathlib import Path from typing import List, Tuple def find_openssl() -> str | None: return shutil.which("openssl") def run_openssl_s_client(host: str, port: int = 443, tls_flag: str | None = None, timeout: int = 60) -> str: openssl = find_openssl() if not openssl: raise RuntimeError("openssl not found on PATH") cmd = [openssl, "s_client", "-showcerts", "-connect", f"{host}:{port}", "-servername", host] if tls_flag: cmd.append(tls_flag) # give a newline so s_client won't wait for interactive input proc = subprocess.run(cmd, input="\n", capture_output=True, text=True, timeout=timeout) return proc.stdout + "\n" + proc.stderr def fetch_cert_via_proxy(host: str, port: int, proxy: str, timeout: int = 20) -> List[str]: """Connect to proxy, issue CONNECT, perform TLS handshake and return PEM of the leaf cert.""" from urllib.parse import urlparse import socket import ssl import base64 parsed = urlparse(proxy) proxy_host = parsed.hostname proxy_port = parsed.port or (443 if parsed.scheme == 'https' else 80) auth_header = None if parsed.username: user = parsed.username pwd = parsed.password or "" cred = f"{user}:{pwd}".encode("utf-8") auth_header = base64.b64encode(cred).decode("ascii") s = socket.create_connection((proxy_host, proxy_port), timeout=timeout) try: connect_req = f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n" if auth_header: connect_req += f"Proxy-Authorization: Basic {auth_header}\r\n" connect_req += "\r\n" s.sendall(connect_req.encode("ascii")) # read response headers resp = b"" while b"\r\n\r\n" not in resp: chunk = s.recv(4096) if not chunk: break resp += chunk if len(resp) > 65536: break header = resp.split(b"\r\n\r\n", 1)[0].decode("ascii", errors="ignore") # Simple status check first_line = header.splitlines()[0] if header.splitlines() else "" if not first_line.startswith("HTTP/") or ("200" not in first_line): raise RuntimeError(f"Proxy CONNECT failed: {first_line}") # Wrap the socket with SSL and fetch peer cert (leaf) ctx = ssl.create_default_context() with ctx.wrap_socket(s, server_hostname=host) as ss: der = ss.getpeercert(True) pem = ssl.DER_cert_to_PEM_cert(der) return [pem] except Exception: s.close() raise def extract_pem_blocks(s: str) -> List[str]: pattern = re.compile(r"-----BEGIN CERTIFICATE-----(?:.|\n)*?-----END CERTIFICATE-----", re.M) return pattern.findall(s) def write_pems_to_temp(pems: List[str]) -> List[Path]: out = [] for i, pem in enumerate(pems): f = Path(tempfile.gettempdir()) / f"ssl_check_cert_{i}.pem" f.write_text(pem) out.append(f) return out def inspect_cert(path: Path) -> Tuple[str, str, str]: """Return (subject, issuer, fingerprint) by calling openssl x509.""" openssl = find_openssl() if not openssl: raise RuntimeError("openssl not found on PATH") proc = subprocess.run([openssl, "x509", "-in", str(path), "-noout", "-subject", "-issuer", "-fingerprint"], capture_output=True, text=True) out = proc.stdout + proc.stderr subj = re.search(r"subject=\s*(.*)", out) issu = re.search(r"issuer=\s*(.*)", out) fp = re.search(r"Fingerprint=([A-F0-9:]+)", out) return (subj.group(1).strip() if subj else "", issu.group(1).strip() if issu else "", fp.group(1).strip() if fp else "") def load_cert_files_from_dir(cert_dir: Path) -> List[Path]: files = [] if not cert_dir.exists(): return files for pattern in ("*.pem", "*.crt", "*.cer"): files.extend(sorted(cert_dir.glob(pattern))) out = [] for f in files: data = f.read_bytes() if b"-----BEGIN CERTIFICATE-----" in data: # already PEM out.append(f) continue # assume DER -> convert to PEM b64 = base64.encodebytes(data).decode("ascii") pem = "-----BEGIN CERTIFICATE-----\n" + b64 + "-----END CERTIFICATE-----\n" tmp = Path(tempfile.gettempdir()) / f"ssl_check_bundle_{f.name}.pem" tmp.write_text(pem) out.append(tmp) return out def main(argv: List[str]) -> int: p = argparse.ArgumentParser() p.add_argument("--url", required=True, help="URL or host to check (eg https://www.penracourses.org.uk)") p.add_argument("--cert-dir", default="cert", help="Directory with bundled certs") p.add_argument("--force-tls12", action="store_true", help="Force -tls1_2 on openssl if initial attempt failed") args = p.parse_args(argv) # parse host/port from urllib.parse import urlparse parsed = urlparse(args.url if "//" in args.url else "//" + args.url) host = parsed.hostname or args.url port = parsed.port or 443 openssl = find_openssl() if not openssl: print("openssl not found on PATH; please install Git for Windows or OpenSSL and re-run.") # we still attempt Python fallback but chain won't be available else: print(f"Using openssl: {openssl}") out = "" if openssl: try: out = run_openssl_s_client(host, port) if "tlsv1 alert protocol version" in out or "SSL23_GET_SERVER_HELLO" in out: print("Server rejected legacy TLS; retrying with -tls1_2") out2 = run_openssl_s_client(host, port, tls_flag="-tls1_2") out = out + "\n--- retry result ---\n" + out2 except subprocess.TimeoutExpired: print("OpenSSL check timed out") except Exception as e: print(f"OpenSSL check failed: {e}") # If openssl couldn't fetch a chain (or wasn't available), attempt a proxy-aware # fetch if HTTPS_PROXY or HTTP_PROXY is set. This helps in corporate networks # where direct TLS is blocked and a proxy must be used. if not extract_pem_blocks(out): proxy = None for key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"): proxy = os.environ.get(key) if proxy: break if proxy: try: print(f"Attempting proxy CONNECT via {proxy}") pems = fetch_cert_via_proxy(host, port, proxy) if pems: print(f"Fetched {len(pems)} PEM(s) via proxy") server_paths = write_pems_to_temp(pems) server_info = [inspect_cert(p) for p in server_paths] print("Server chain (proxy fetched):") for i, (s, iss, fp) in enumerate(server_info): print(f"[{i}] SUBJECT: {s}") print(f" ISSUER: {iss}") print(f" FP: {fp}") else: print("Proxy CONNECT succeeded but no certs found") except Exception as e: print(f"Proxy CONNECT fetch failed: {e}") # extract certs from openssl output if any pems = extract_pem_blocks(out) if pems: print(f"Found {len(pems)} PEM blocks in openssl output") server_paths = write_pems_to_temp(pems) server_info = [inspect_cert(p) for p in server_paths] print("Server chain:") for i, (s, iss, fp) in enumerate(server_info): print(f"[{i}] SUBJECT: {s}") print(f" ISSUER: {iss}") print(f" FP: {fp}") else: print("No certificate chain found from openssl (server may have rejected the handshake or openssl is incompatible)") server_info = [] # load bundled certs cert_dir = Path(args.cert_dir) bundled = load_cert_files_from_dir(cert_dir) print(f"Found {len(bundled)} bundled cert files in {cert_dir}") bundled_info = [inspect_cert(p) for p in bundled] subjects = {info[0]: p for info, p in zip(bundled_info, bundled)} # Compare: ensure each server issuer is present in bundled subjects missing = set() for s, iss, fp in server_info: if iss and iss not in subjects: missing.add(iss) if not server_info: print("No server certs to compare; check network/openssl compatibility") return 2 if not missing: print("All server issuers are present in the bundled certs. OK") return 0 else: print("Missing issuer certificates in bundled certs:") for m in missing: print(" - ", m) return 3 if __name__ == "__main__": try: rc = main(sys.argv[1:]) except Exception as e: print(f"ssl_check failed: {e}") rc = 1 sys.exit(rc)