Files
felhom.eu/scripts/https_only_check.py
T

106 lines
4.9 KiB
Python

#!/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.<domain>` and one name that exists nowhere. Each name gets
`GET http://<name>/r1033-check?q=1` (no credentials, no redirect following); the answer must be 301 or 308 with
`Location: https://<name>/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:]))