106 lines
4.9 KiB
Python
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:]))
|