Compare commits
4
Commits
8780a409de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
134bbf85b6 | ||
|
|
b7242d19de | ||
|
|
854fd315fa | ||
|
|
ffc4c79def |
@@ -401,6 +401,8 @@ def ssl_check_cmd(url: str) -> str:
|
||||
|
||||
Falls back to a minimal Python-based peer certificate fetch if openssl is not available.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or url
|
||||
@@ -420,6 +422,24 @@ def ssl_check_cmd(url: str) -> str:
|
||||
proc = subprocess.run(cmd, input='\n', capture_output=True, text=True, timeout=60)
|
||||
out = proc.stdout + "\n" + proc.stderr
|
||||
logger.debug(f"OpenSSL finished, {len(out)} bytes returned, rc={proc.returncode}")
|
||||
# Some older OpenSSL builds (or default s_client options) may
|
||||
# negotiate an incompatible protocol version with modern servers
|
||||
# and return a short error like 'tlsv1 alert protocol version'. If
|
||||
# that appears, try again forcing TLS1.2 which most servers still
|
||||
# support. This helps when the openssl on PATH is old (eg bundled
|
||||
# with other software) and defaults to legacy negotiation.
|
||||
if "tlsv1 alert protocol version" in out or "SSL23_GET_SERVER_HELLO" in out:
|
||||
logger.debug("OpenSSL reported protocol version issue; retrying with -tls1_2")
|
||||
try:
|
||||
cmd2 = cmd + ["-tls1_2"]
|
||||
proc2 = subprocess.run(cmd2, input='\n', capture_output=True, text=True, timeout=30)
|
||||
out2 = proc2.stdout + "\n" + proc2.stderr
|
||||
logger.debug(f"OpenSSL retry finished, {len(out2)} bytes returned, rc={proc2.returncode}")
|
||||
return out + "\n--- retry with -tls1_2 ---\n" + out2
|
||||
except Exception:
|
||||
logger.exception("OpenSSL tls1_2 retry failed")
|
||||
return out + "\nNote: OpenSSL appears to be too old to negotiate modern TLS.\nConsider installing a newer OpenSSL (eg Git for Windows or a Win64 OpenSSL build).\n"
|
||||
|
||||
return out
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.debug("OpenSSL timed out")
|
||||
@@ -432,20 +452,74 @@ def ssl_check_cmd(url: str) -> str:
|
||||
logger.exception("OpenSSL check failed")
|
||||
return f"OpenSSL check failed: {e}\n"
|
||||
else:
|
||||
# Fallback: use Python ssl to fetch the peer certificate (leaf only)
|
||||
# Fallback: use Python ssl to fetch the peer certificate (leaf only).
|
||||
# If a proxy is configured, perform an HTTP CONNECT to the proxy first
|
||||
# and then do TLS over the established tunnel so the result matches the
|
||||
# path used by the running Python process.
|
||||
import ssl
|
||||
from urllib.parse import urlparse
|
||||
import base64
|
||||
|
||||
logger.debug("OpenSSL not found; using Python ssl fallback")
|
||||
def fetch_via_proxy(proxy_url: str) -> str:
|
||||
parsed = urlparse(proxy_url)
|
||||
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 ""
|
||||
auth_header = base64.b64encode(f"{user}:{pwd}".encode("utf-8")).decode("ascii")
|
||||
|
||||
s = socket.create_connection((proxy_host, proxy_port), timeout=15)
|
||||
try:
|
||||
req = f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"
|
||||
if auth_header:
|
||||
req += f"Proxy-Authorization: Basic {auth_header}\r\n"
|
||||
req += "\r\n"
|
||||
s.sendall(req.encode("ascii"))
|
||||
|
||||
# read 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
|
||||
|
||||
first_line = resp.split(b"\r\n", 1)[0].decode("ascii", errors="ignore")
|
||||
if not first_line.startswith("HTTP/") or ("200" not in first_line):
|
||||
raise RuntimeError(f"Proxy CONNECT failed: {first_line}")
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
with ctx.wrap_socket(s, server_hostname=host) as ss:
|
||||
ss.settimeout(15)
|
||||
der = ss.getpeercert(True)
|
||||
return ssl.DER_cert_to_PEM_cert(der)
|
||||
except Exception:
|
||||
s.close()
|
||||
raise
|
||||
|
||||
logger.debug("OpenSSL not found; using Python ssl fallback (proxy-aware)")
|
||||
# Prefer HTTPS_PROXY then HTTP_PROXY
|
||||
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy")
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
s = socket.socket(socket.AF_INET)
|
||||
with ctx.wrap_socket(s, server_hostname=host) as ss:
|
||||
ss.settimeout(15)
|
||||
ss.connect((host, port))
|
||||
der = ss.getpeercert(True)
|
||||
pem = ssl.DER_cert_to_PEM_cert(der)
|
||||
logger.debug("Python ssl fallback succeeded")
|
||||
return pem
|
||||
if proxy:
|
||||
logger.debug(f"Using proxy for ssl check: {proxy}")
|
||||
pem = fetch_via_proxy(proxy)
|
||||
else:
|
||||
ctx = ssl.create_default_context()
|
||||
s = socket.socket(socket.AF_INET)
|
||||
with ctx.wrap_socket(s, server_hostname=host) as ss:
|
||||
ss.settimeout(15)
|
||||
ss.connect((host, port))
|
||||
der = ss.getpeercert(True)
|
||||
pem = ssl.DER_cert_to_PEM_cert(der)
|
||||
|
||||
logger.debug("Python ssl fallback succeeded")
|
||||
return pem
|
||||
except Exception as e:
|
||||
logger.exception("Python SSL fallback failed")
|
||||
return f"Python SSL fallback failed: {e}\n"
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['ssl_check.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='ssl_check',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
@@ -1,10 +1,6 @@
|
||||
2025-11-24 16:19:37.964 | DEBUG | __main__:ssl_check_ui:750 - Starting SSL check via UI
|
||||
2025-11-24 16:19:37.966 | DEBUG | __main__:ssl_check_cmd:412 - ssl_check_cmd: host=www.penracourses.org.uk port=443
|
||||
2025-11-24 16:19:37.967 | DEBUG | __main__:ssl_check_cmd:416 - Running openssl: C:\Sybase\OCS-15_0\bin\openssl.EXE s_client -showcerts -connect www.penracourses.org.uk:443 -servername www.penracourses.org.uk
|
||||
2025-11-24 16:19:39.200 | DEBUG | __main__:ssl_check_cmd:422 - OpenSSL finished, 170 bytes returned, rc=0
|
||||
|
||||
|
||||
CONNECTED(000002CC)
|
||||
|
||||
Loading 'screen' into random state - done
|
||||
5096:error:1407742E:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 alert protocol version:.\ssl\s23_clnt.c:596:
|
||||
PS T:\rad-tools\uploader\Uploader_test> .\ssl_check.exe --url http://www.penracourses.org.uk --cert-dir ./cert
|
||||
Using openssl: C:\Sybase\OCS-15_0\bin\openssl.EXE
|
||||
Server rejected legacy TLS; retrying with -tls1_2
|
||||
No certificate chain found from openssl (server may have rejected the handshake or openssl is incompatible)
|
||||
Found 2 bundled cert files in cert
|
||||
No server certs to compare; check network/openssl compatibility
|
||||
Reference in New Issue
Block a user