#!/usr/bin/env python3 """https_only_check.py — every public name of a box answers `http://` with 301/308 to the SAME https URL (R-1033, P1). Not a push gate (it needs the network and SSH to the box): it is the box-walk check, run on demand and in a box walk. For each box: the public names are read from the box itself — every `Host(`...`)` in a running container's traefik labels and in traefik's dynamic files, plus `felhom.` and one name that exists nowhere. Each name gets `GET http:///r1033-check?q=1` (no credentials, no redirect following); the answer must be 301 or 308 with `Location: https:///r1033-check?q=1`. Anything else convicts the box. Controls, every run, before any box is judged (a check that cannot fail is not a check): * NEGATIVE — a local stub on 127.0.0.1 answers 200 to plain http; judging it must CONVICT. If it passes, the run stops. * POSITIVE — the first box's dashboard name must PASS; if it does not, the box is convicted and the report says so. python3 scripts/https_only_check.py # the three Tier-0 boxes (BOXES below) python3 scripts/https_only_check.py tester-1 # one box Exit 0 every name redirects · 1 a name does not · 2 the controls failed (no verdict). """ import http.server import re import subprocess import sys import threading import urllib.error import urllib.request # name -> (ssh argv to the Proxmox host, guest vmid, customer domain). Host routes live in operations/nodes.md. BOXES = { "tester-1": (["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "root@192.168.0.154"], "9201", "enkicsifelhom.hu"), "demo-hp": (["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "hp"], "9201", "enkisfelhom.hu"), "demo-felhom": (["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "felhom-pve"], "9201", "demo-felhom.eu"), } PATH = "/r1033-check?q=1" UA = "Mozilla/5.0 (X11; Linux x86_64) felhom-https-only-check" class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *a, **k): return None _open = urllib.request.build_opener(_NoRedirect).open def judge(url_http, want_https): """(ok, observed) for one plain-http URL.""" try: # A browser-like User-Agent: Cloudflare answers 403 to Python's default one (measured on the first run). r = _open(urllib.request.Request(url_http, headers={"Accept": "text/html", "User-Agent": UA}), timeout=20) code, loc = r.status, r.headers.get("Location") except urllib.error.HTTPError as e: code, loc = e.code, e.headers.get("Location") except Exception as e: # noqa: BLE001 — a dead name is a conviction, said plainly return False, f"no answer ({type(e).__name__})" ok = code in (301, 308) and loc == want_https return ok, f"{code} {loc or '-'}" def names_of(ssh, vmid, domain): script = ("for c in $(docker ps -q); do docker inspect $c -f '{{range $k,$v := .Config.Labels}}{{$v}} {{end}}'; done; " "cat /opt/docker/stacks/traefik/dynamic/*.yml 2>/dev/null") # The script goes in on stdin: through ssh + pct + sh -c the $ signs of the Go template would be expanded. out = subprocess.run(ssh + [f"pct exec {vmid} -- sh -s"], input=script, capture_output=True, text=True, timeout=120).stdout found = set(re.findall(r"Host\(`([a-z0-9.-]+\." + re.escape(domain) + r")`\)", out)) found |= {f"felhom.{domain}", f"no-such-name-r1033.{domain}"} return sorted(found) def negative_control(): class H(http.server.BaseHTTPRequestHandler): def do_GET(self): self.send_response(200); self.end_headers(); self.wfile.write(b"plain http") def log_message(self, *a): pass srv = http.server.HTTPServer(("127.0.0.1", 0), H) threading.Thread(target=srv.serve_forever, daemon=True).start() port = srv.server_address[1] ok, seen = judge(f"http://127.0.0.1:{port}{PATH}", f"https://127.0.0.1:{port}{PATH}") srv.shutdown() return ok, seen def main(argv): ok, seen = negative_control() print(f"control NEGATIVE (a stub answering 200 over plain http): {'PASSED — the check cannot convict, STOP' if ok else 'convicted, as it must'} ({seen})") if ok: return 2 boxes = argv or list(BOXES) bad = 0 for i, b in enumerate(boxes): ssh, vmid, dom = BOXES[b] names = names_of(ssh, vmid, dom) print(f"== {b} ({dom}): {len(names)} public name(s)") for n in names: ok, seen = judge(f"http://{n}{PATH}", f"https://{n}{PATH}") bad += 0 if ok else 1 tag = "control POSITIVE " if (i == 0 and n == f"felhom.{dom}") else "" print(f" {'OK ' if ok else 'FAIL'} {tag}http://{n}{PATH} -> {seen}") print(f"== {bad} name(s) do not redirect" if bad else "== every public name redirects http -> https") return 1 if bad else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))