https only (R-1033, P1): controller v0.312.0 proven on Tester 1, demo-hp, demo-felhom (29 names); scripts/https_only_check.py; 01/13/poster facts; decision 246 (decided by CC unattended); R-1033 P1 narrowed; R-1041..R-1043
gates / gates (push) Successful in 6m34s
gates / gates (push) Successful in 6m34s
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user